pilotswarm 0.5.22 → 0.5.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/tui/src/node-sdk-transport.js +22 -5
- package/ui/core/src/controller.js +329 -6
- package/ui/core/src/formatting.js +46 -1
- package/ui/core/src/history.js +29 -0
- package/ui/core/src/reducer.js +52 -2
- package/ui/core/src/selectors.js +288 -11
- package/ui/core/src/state.js +12 -1
- package/ui/core/src/themes/github-light.js +5 -1
- package/ui/core/src/themes/index.js +4 -0
- package/ui/core/src/themes/light-high-contrast.js +4 -1
- package/ui/core/src/themes/ms-dos.js +84 -0
- package/ui/core/src/themes/win95.js +81 -0
- package/ui/react/src/web-app.js +1342 -84
- package/web/dist/assets/abnfDiagram-VRR7QNED-Bjz_3uNq.js +1 -0
- package/web/dist/assets/arc-a3km8Cfi.js +1 -0
- package/web/dist/assets/architectureDiagram-ZJ3FMSHR-4twPAKdk.js +36 -0
- package/web/dist/assets/blockDiagram-677ZJIJ3-FnFRjWxr.js +132 -0
- package/web/dist/assets/c4Diagram-LMCZKHZV-B8R4iXqh.js +10 -0
- package/web/dist/assets/channel-BQwFhqIW.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-C7dcVQSj.js +1 -0
- package/web/dist/assets/chunk-32BRIVSS-DQQ_NOog.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-BRR5_oWJ.js +15 -0
- package/web/dist/assets/chunk-EX3LRPZG-C0Jg_k6w.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-CoHkckoP.js +1 -0
- package/web/dist/assets/chunk-MOJQB5TN-CTIj5Opw.js +88 -0
- package/web/dist/assets/chunk-RYQCIY6F-BsP4OCo4.js +1 -0
- package/web/dist/assets/chunk-V7JOEXUC-B5KS36_O.js +206 -0
- package/web/dist/assets/chunk-VR4S4FIN-Dm92_7ut.js +1 -0
- package/web/dist/assets/chunk-XXDRQBXY-BGivbXmv.js +1 -0
- package/web/dist/assets/classDiagram-OUVF2IWQ-C12RKvcF.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-C12RKvcF.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-DVjdL84y.js +1 -0
- package/web/dist/assets/cynefin-VYW2F7L2-pcKhk42O.js +166 -0
- package/web/dist/assets/cynefinDiagram-TSTJHNR4-CdHQXyCH.js +62 -0
- package/web/dist/assets/cytoscape.esm-D3_iZ_3b.js +321 -0
- package/web/dist/assets/dagre-VKFMJZFB-Okt50Noy.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-FQU43EPY-D52R5ow3.js +3 -0
- package/web/dist/assets/diagram-G47NLZAW-KiJTa2OF.js +24 -0
- package/web/dist/assets/diagram-NH7WQ7WH-DZ-NFYLH.js +24 -0
- package/web/dist/assets/diagram-OA4YK3LP-et-Lf7yH.js +30 -0
- package/web/dist/assets/diagram-WEI45ONY-eXhZ7Ksl.js +41 -0
- package/web/dist/assets/ebnfDiagram-CCIWWBDH-CTWYusI0.js +1 -0
- package/web/dist/assets/erDiagram-Q63AITRT-DwTN2EqF.js +85 -0
- package/web/dist/assets/flowDiagram-23GEKE2U-CmxOIpNk.js +156 -0
- package/web/dist/assets/ganttDiagram-NO4QXBWP-TsPnDFPN.js +292 -0
- package/web/dist/assets/gitGraphDiagram-IHSO6WYX-B_IVM0u8.js +106 -0
- package/web/dist/assets/graph-DOmOIIwC.js +1 -0
- package/web/dist/assets/{index-CuxEGti7.js → index-CB16o5aO.js} +3 -3
- package/web/dist/assets/index-CN_7NnbR.css +1 -0
- package/web/dist/assets/infoDiagram-FWYZ7A6U-BFjk6921.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-FXEZZL3T-BiTzJYKW.js +70 -0
- package/web/dist/assets/journeyDiagram-5HDEW3XC-eeyGUQSj.js +139 -0
- package/web/dist/assets/kanban-definition-HUTT4EX6-DPYZ3frK.js +89 -0
- package/web/dist/assets/katex-HP8lGamR.js +257 -0
- package/web/dist/assets/layout-D-LzfAck.js +1 -0
- package/web/dist/assets/linear-Buu9IOJQ.js +1 -0
- package/web/dist/assets/map-DxJ2ADlA.js +1 -0
- package/web/dist/assets/mermaid.core-xO1oSQS3.js +308 -0
- package/web/dist/assets/mindmap-definition-LN4V7U3C-TasB4KZ4.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-2B236MQR-Btr2u_Xy.js +1 -0
- package/web/dist/assets/pieDiagram-ENE6RG2P-0iBpCJcu.js +39 -0
- package/web/dist/assets/pilotswarm-v4IdVngK.js +93 -0
- package/web/dist/assets/quadrantDiagram-ABIIQ3AL-CbVQOzUs.js +7 -0
- package/web/dist/assets/railroadDiagram-RFXS5EU6-BkwzDhMx.js +1 -0
- package/web/dist/assets/requirementDiagram-TGXJPOKE-DpF9yvmD.js +84 -0
- package/web/dist/assets/sankeyDiagram-HTMAVEWB-CjvbElms.js +40 -0
- package/web/dist/assets/sequenceDiagram-DBY2YBRQ-DRj2K28H.js +162 -0
- package/web/dist/assets/sizeCapture-X5ZJPWSS-Bi3gxz8F.js +1 -0
- package/web/dist/assets/stateDiagram-2N3HPSRC-_INdmyGU.js +1 -0
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-A5HcuE43.js +1 -0
- package/web/dist/assets/swimlanes-5IMT3BWC-D1pFtL4Y.js +2 -0
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-Bf_Rn9Zk.js +8 -0
- package/web/dist/assets/timeline-definition-FHXFAJF6-PYQ9uRSp.js +120 -0
- package/web/dist/assets/vennDiagram-L72KCM5P-xCF6u31z.js +34 -0
- package/web/dist/assets/wardleyDiagram-EHGQE667-DPcm9Msn.js +78 -0
- package/web/dist/assets/xychartDiagram-FW5EYKEG-D9ZNU0hG.js +7 -0
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-CxkLM8_a.css +0 -1
- package/web/dist/assets/pilotswarm-CjMx44so.js +0 -90
package/ui/react/src/web-app.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
createStore,
|
|
15
15
|
getPromptInputRows,
|
|
16
16
|
getTheme,
|
|
17
|
+
isThemeLight,
|
|
17
18
|
parseTerminalMarkupRuns,
|
|
18
19
|
PilotSwarmUiController,
|
|
19
20
|
tokenizeInlineMarkdown,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
selectArtifactPickerModal,
|
|
23
24
|
selectArtifactUploadModal,
|
|
24
25
|
selectLiveActivityLines,
|
|
26
|
+
selectChatBlocks,
|
|
25
27
|
selectChatLines,
|
|
26
28
|
selectChatPaneChrome,
|
|
27
29
|
selectOutboxOverlayLines,
|
|
@@ -208,12 +210,39 @@ function normalizeProfileSettings(settings) {
|
|
|
208
210
|
const id = candidate.activeSessionId == null ? null : String(candidate.activeSessionId).trim();
|
|
209
211
|
normalized.activeSessionId = id || null;
|
|
210
212
|
}
|
|
211
|
-
if (candidate.chatViewMode
|
|
213
|
+
if (isChatViewMode(candidate.chatViewMode)) {
|
|
212
214
|
normalized.chatViewMode = candidate.chatViewMode;
|
|
213
215
|
}
|
|
216
|
+
// Phones and desktops keep SEPARATE view preferences: rich prose reads well
|
|
217
|
+
// on a wide transcript and poorly in a 390px column, so a single shared
|
|
218
|
+
// value would have each device overwriting the other's choice.
|
|
219
|
+
if (isChatViewMode(candidate.chatViewModeMobile)) {
|
|
220
|
+
normalized.chatViewModeMobile = candidate.chatViewModeMobile;
|
|
221
|
+
}
|
|
214
222
|
return normalized;
|
|
215
223
|
}
|
|
216
224
|
|
|
225
|
+
function isChatViewMode(value) {
|
|
226
|
+
return value === "summary" || value === "transcript" || value === "rich";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** True on phone-sized viewports — the device class that owns the mobile slot. */
|
|
230
|
+
function isNarrowViewport() {
|
|
231
|
+
return typeof window !== "undefined"
|
|
232
|
+
&& typeof window.matchMedia === "function"
|
|
233
|
+
&& window.matchMedia("(max-width: 920px)").matches;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Profile key this device reads and writes. */
|
|
237
|
+
function chatViewModeKey() {
|
|
238
|
+
return isNarrowViewport() ? "chatViewModeMobile" : "chatViewMode";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The key belonging to the OTHER device class. */
|
|
242
|
+
function otherChatViewModeKey() {
|
|
243
|
+
return isNarrowViewport() ? "chatViewMode" : "chatViewModeMobile";
|
|
244
|
+
}
|
|
245
|
+
|
|
217
246
|
function normalizeStoredCollapsedSessionIdsToArray(value) {
|
|
218
247
|
if (value instanceof Set) {
|
|
219
248
|
const out = [];
|
|
@@ -241,7 +270,7 @@ function hasOwn(value, key) {
|
|
|
241
270
|
return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
|
|
242
271
|
}
|
|
243
272
|
|
|
244
|
-
function profileSettingsFromViewState(state) {
|
|
273
|
+
function profileSettingsFromViewState(state, preservedOtherChatViewMode = null) {
|
|
245
274
|
return normalizeProfileSettings({
|
|
246
275
|
themeId: state.themeId,
|
|
247
276
|
sessionOwnerFilter: state.ownerFilter,
|
|
@@ -254,11 +283,17 @@ function profileSettingsFromViewState(state) {
|
|
|
254
283
|
pinnedSessionIds: state.pinnedIds,
|
|
255
284
|
collapsedSessionIds: state.collapsedSessionIds,
|
|
256
285
|
activeSessionId: state.activeSessionId,
|
|
257
|
-
|
|
286
|
+
[chatViewModeKey()]: state.chatViewMode,
|
|
287
|
+
// setCurrentUserProfileSettings REPLACES the settings object, so the
|
|
288
|
+
// other device class's slot has to be written back verbatim or saving
|
|
289
|
+
// from a desktop would wipe the phone's preference (and vice versa).
|
|
290
|
+
...(isChatViewMode(preservedOtherChatViewMode)
|
|
291
|
+
? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
|
|
292
|
+
: {}),
|
|
258
293
|
});
|
|
259
294
|
}
|
|
260
295
|
|
|
261
|
-
function buildDefaultProfileSettingsFromState(state) {
|
|
296
|
+
function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode = null) {
|
|
262
297
|
return normalizeProfileSettings({
|
|
263
298
|
themeId: state?.ui?.themeId,
|
|
264
299
|
// Derive the owner-filter default from the RESOLVED principal, never a
|
|
@@ -272,7 +307,10 @@ function buildDefaultProfileSettingsFromState(state) {
|
|
|
272
307
|
pinnedSessionIds: state?.sessions?.pinnedIds,
|
|
273
308
|
collapsedSessionIds: state?.sessions?.collapsedIds,
|
|
274
309
|
activeSessionId: state?.sessions?.activeSessionId,
|
|
275
|
-
|
|
310
|
+
[chatViewModeKey()]: state?.ui?.chatViewMode,
|
|
311
|
+
...(isChatViewMode(preservedOtherChatViewMode)
|
|
312
|
+
? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
|
|
313
|
+
: {}),
|
|
276
314
|
});
|
|
277
315
|
}
|
|
278
316
|
|
|
@@ -305,8 +343,11 @@ function materializeProfileSettings(remoteSettings, defaults) {
|
|
|
305
343
|
...(hasOwn(normalizedRemote, "activeSessionId")
|
|
306
344
|
? { activeSessionId: normalizedRemote.activeSessionId }
|
|
307
345
|
: {}),
|
|
308
|
-
|
|
309
|
-
|
|
346
|
+
// Apply only the slot this device owns. The other is preserved by the
|
|
347
|
+
// save path (see profileSettingsFromViewState), not applied here — it
|
|
348
|
+
// describes a screen size we are not on.
|
|
349
|
+
...(hasOwn(normalizedRemote, chatViewModeKey())
|
|
350
|
+
? { chatViewMode: normalizedRemote[chatViewModeKey()] }
|
|
310
351
|
: {}),
|
|
311
352
|
});
|
|
312
353
|
}
|
|
@@ -387,10 +428,15 @@ function computeStateLayout(state) {
|
|
|
387
428
|
}
|
|
388
429
|
|
|
389
430
|
function getPortalInspectorContentWidth(paneWidth, inspectorTab) {
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
431
|
+
// The sequence view once reserved 3 extra columns here so that "content
|
|
432
|
+
// that fits visually does not trip a cosmetic x-scrollbar". That was a
|
|
433
|
+
// fudge for a real bug: every sequence line was padded to the full width,
|
|
434
|
+
// including the final column whose trailing spaces align nothing, so with
|
|
435
|
+
// white-space:pre the content box always measured exactly the pane width
|
|
436
|
+
// and any rounding tipped it into a permanent scrollbar. The padding is now
|
|
437
|
+
// trimmed at the source (see trimTrailingRunPad in ui-core selectors), so
|
|
438
|
+
// the guard would only throw away 3 usable columns.
|
|
439
|
+
return Math.max(20, paneWidth - 4);
|
|
394
440
|
}
|
|
395
441
|
|
|
396
442
|
function normalizeLines(lines) {
|
|
@@ -547,6 +593,10 @@ function applyDocumentTheme(themeId) {
|
|
|
547
593
|
root.style.setProperty("--ps-modal-selected-background", theme.page.modalSelectedBackground);
|
|
548
594
|
root.style.setProperty("--ps-modal-selected-border", theme.page.modalSelectedBorder);
|
|
549
595
|
root.style.setProperty("--ps-modal-selected-foreground", theme.page.modalSelectedForeground);
|
|
596
|
+
// Expose the theme id so a theme can carry CHROME, not just colours. Win95
|
|
597
|
+
// is defined by its bevels — raised faces, inset wells, square corners —
|
|
598
|
+
// and none of that is expressible as a palette entry.
|
|
599
|
+
root.dataset.psTheme = theme.id;
|
|
550
600
|
}
|
|
551
601
|
|
|
552
602
|
function useMeasuredViewport(ref) {
|
|
@@ -796,6 +846,20 @@ function Runs({ runs, theme }) {
|
|
|
796
846
|
};
|
|
797
847
|
const href = String(run?.href || "").trim();
|
|
798
848
|
const isExternalHref = /^https?:\/\//i.test(href);
|
|
849
|
+
// artifact:// links used to render as an inert span — the only
|
|
850
|
+
// affordance was "press a to download", which is the wrong verb
|
|
851
|
+
// for a patch you want to READ. They now open the Files tab with
|
|
852
|
+
// the artifact selected and previewed.
|
|
853
|
+
const artifactRef = parseArtifactHref(href);
|
|
854
|
+
|
|
855
|
+
if (artifactRef) {
|
|
856
|
+
return React.createElement(ArtifactLink, {
|
|
857
|
+
key: `${index}:${run.text || ""}`,
|
|
858
|
+
label: run.text || "",
|
|
859
|
+
artifactRef,
|
|
860
|
+
style,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
799
863
|
|
|
800
864
|
return isExternalHref
|
|
801
865
|
? React.createElement("a", {
|
|
@@ -863,7 +927,12 @@ function usePanePixelScroll(ref, scrollOffset, paneKey, controller) {
|
|
|
863
927
|
|
|
864
928
|
React.useLayoutEffect(() => {
|
|
865
929
|
const node = ref.current;
|
|
866
|
-
|
|
930
|
+
// No paneKey means nobody is driving this pane's offset, so leave the
|
|
931
|
+
// scroller entirely alone. Writing scrollTop back during a touch fling
|
|
932
|
+
// interrupts momentum every frame — which is why the preview felt
|
|
933
|
+
// stiff next to the transcript — and the SCROLL_ROW_HEIGHT
|
|
934
|
+
// quantization makes it snap as well.
|
|
935
|
+
if (!node || !paneKey) return;
|
|
867
936
|
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
868
937
|
const nextScrollTop = Math.min(maxScroll, Math.max(0, Number(scrollOffset) || 0) * SCROLL_ROW_HEIGHT);
|
|
869
938
|
if (Math.abs(node.scrollTop - nextScrollTop) > 2) {
|
|
@@ -873,12 +942,19 @@ function usePanePixelScroll(ref, scrollOffset, paneKey, controller) {
|
|
|
873
942
|
programmaticScrollRef.current = false;
|
|
874
943
|
});
|
|
875
944
|
}
|
|
876
|
-
}, [ref, scrollOffset]);
|
|
945
|
+
}, [ref, scrollOffset, paneKey]);
|
|
877
946
|
|
|
878
|
-
return React.useCallback(() => {
|
|
947
|
+
return React.useCallback((event) => {
|
|
879
948
|
if (programmaticScrollRef.current) return;
|
|
880
949
|
const node = ref.current;
|
|
881
950
|
if (!node || !paneKey) return;
|
|
951
|
+
// React simulates bubbling for onScroll, so scrolling a code block or
|
|
952
|
+
// table INSIDE the document also fires this handler. Acting on it
|
|
953
|
+
// recorded the pane offset from an inner element's scroll state and
|
|
954
|
+
// then wrote it back to the outer scroller — horizontally scrolling a
|
|
955
|
+
// fenced code block snapped the whole markdown preview to the top.
|
|
956
|
+
// Only the pane's own scroller may drive the pane's offset.
|
|
957
|
+
if (event && event.target !== node) return;
|
|
882
958
|
// See useScrollSync — ignore browser auto-clamp during a
|
|
883
959
|
// transient empty/loading body.
|
|
884
960
|
if (node.scrollHeight <= node.clientHeight) return;
|
|
@@ -890,6 +966,432 @@ function usePanePixelScroll(ref, scrollOffset, paneKey, controller) {
|
|
|
890
966
|
}, [controller, paneKey, ref]);
|
|
891
967
|
}
|
|
892
968
|
|
|
969
|
+
// ── Code syntax highlighting ──────────────────────────────────────────────
|
|
970
|
+
// A dependency-free tokenizer. Vendoring a real highlighter (highlight.js,
|
|
971
|
+
// prism) is not worth the offline-vendor cost for what fenced blocks in a
|
|
972
|
+
// chat transcript need: comments, strings, numbers, keywords, and call
|
|
973
|
+
// targets. Token colors resolve through the active THEME (same path as
|
|
974
|
+
// markdown links), so every theme — light or dark — stays coherent.
|
|
975
|
+
|
|
976
|
+
const CODE_LANGUAGE_ALIASES = {
|
|
977
|
+
js: "js", jsx: "js", mjs: "js", cjs: "js", javascript: "js",
|
|
978
|
+
ts: "js", tsx: "js", typescript: "js",
|
|
979
|
+
json: "json", jsonc: "json",
|
|
980
|
+
py: "python", python: "python",
|
|
981
|
+
sh: "shell", bash: "shell", zsh: "shell", shell: "shell", console: "shell",
|
|
982
|
+
sql: "sql", postgres: "sql", postgresql: "sql", psql: "sql",
|
|
983
|
+
rs: "rust", rust: "rust",
|
|
984
|
+
go: "go", golang: "go",
|
|
985
|
+
yaml: "yaml", yml: "yaml",
|
|
986
|
+
css: "css", scss: "css",
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
// ── Diff blocks ───────────────────────────────────────────────────────────
|
|
990
|
+
// A ```diff fence is line-oriented, not token-oriented: running it through
|
|
991
|
+
// the generic tokenizer colored Rust/JS keywords inside the payload while
|
|
992
|
+
// leaving the +/- semantics — the only thing that matters in a diff — as
|
|
993
|
+
// undifferentiated text. Diffs are classified per line instead.
|
|
994
|
+
|
|
995
|
+
const DIFF_LANGUAGES = new Set(["diff", "patch", "udiff"]);
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Diff-shaped artifacts. Recognized by extension AND by content type, because
|
|
999
|
+
* an agent may write either `.patch`/`.diff` or a text/x-patch blob under some
|
|
1000
|
+
* other name.
|
|
1001
|
+
*/
|
|
1002
|
+
const DIFF_ARTIFACT_RE = /\.(patch|diff)$/i;
|
|
1003
|
+
const IMAGE_CONTENT_TYPE_RE = /^image\/(png|jpe?g|gif|webp|avif|bmp|svg\+xml|x-icon|vnd\.microsoft\.icon)$/i;
|
|
1004
|
+
const IMAGE_FILENAME_RE = /\.(png|jpe?g|gif|webp|avif|bmp|svg|ico)$/i;
|
|
1005
|
+
|
|
1006
|
+
/** Map an artifact filename to a highlighter language, or null for plain text. */
|
|
1007
|
+
const CODE_ARTIFACT_LANGUAGES = {
|
|
1008
|
+
js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript",
|
|
1009
|
+
ts: "typescript", tsx: "typescript", json: "json", jsonl: "json",
|
|
1010
|
+
py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
|
|
1011
|
+
sh: "bash", bash: "bash", zsh: "bash", yml: "yaml", yaml: "yaml",
|
|
1012
|
+
toml: "toml", sql: "sql", css: "css", html: "html", xml: "xml",
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Strip a leading YAML frontmatter block from markdown.
|
|
1017
|
+
*
|
|
1018
|
+
* The renderer has no concept of frontmatter, so `---\ntitle: x\n---` was
|
|
1019
|
+
* collapsed into a paragraph and rendered as prose above the document — which
|
|
1020
|
+
* read as the title being duplicated, since the doc's own H1 follows it.
|
|
1021
|
+
* Document viewers hide frontmatter; only strip a block that starts on line 1
|
|
1022
|
+
* and closes, so a document opening with a thematic break is untouched.
|
|
1023
|
+
*/
|
|
1024
|
+
function stripYamlFrontmatter(content) {
|
|
1025
|
+
const text = String(content || "");
|
|
1026
|
+
if (!/^---[ \t]*\r?\n/.test(text)) return text;
|
|
1027
|
+
const close = /\r?\n---[ \t]*(\r?\n|$)/.exec(text.slice(3));
|
|
1028
|
+
if (!close) return text;
|
|
1029
|
+
return text.slice(3 + close.index + close[0].length);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function codeLanguageForArtifact(filename) {
|
|
1033
|
+
const ext = /\.([A-Za-z0-9]+)$/.exec(String(filename || ""));
|
|
1034
|
+
if (!ext) return null;
|
|
1035
|
+
return CODE_ARTIFACT_LANGUAGES[ext[1].toLowerCase()] || null;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function isDiffArtifact(filename, contentType) {
|
|
1039
|
+
if (DIFF_ARTIFACT_RE.test(String(filename || ""))) return true;
|
|
1040
|
+
return /^text\/x-(patch|diff)$/i.test(String(contentType || "").trim());
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Parse an `artifact://<sessionId>/<filename>` href. */
|
|
1044
|
+
function parseArtifactHref(href) {
|
|
1045
|
+
const match = /^artifact:\/\/([^/]+)\/(.+)$/i.exec(String(href || "").trim());
|
|
1046
|
+
if (!match) return null;
|
|
1047
|
+
try {
|
|
1048
|
+
return { sessionId: match[1], filename: decodeURIComponent(match[2]) };
|
|
1049
|
+
} catch {
|
|
1050
|
+
return { sessionId: match[1], filename: match[2] };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Controller access for deeply-nested renderers.
|
|
1056
|
+
*
|
|
1057
|
+
* The markdown/run renderers are called from many places and never took a
|
|
1058
|
+
* controller — threading one through every call site to make a single link
|
|
1059
|
+
* type clickable would touch far more code than the feature is worth.
|
|
1060
|
+
*/
|
|
1061
|
+
const ControllerContext = React.createContext(null);
|
|
1062
|
+
|
|
1063
|
+
function ArtifactLink({ label, artifactRef, style }) {
|
|
1064
|
+
const controller = React.useContext(ControllerContext);
|
|
1065
|
+
const diff = isDiffArtifact(artifactRef.filename);
|
|
1066
|
+
const onOpen = React.useCallback((event) => {
|
|
1067
|
+
event.preventDefault();
|
|
1068
|
+
event.stopPropagation();
|
|
1069
|
+
if (!controller?.revealArtifact) return;
|
|
1070
|
+
// On a phone the preview takes the whole pane — a list/detail split has
|
|
1071
|
+
// no room to be useful at 390px.
|
|
1072
|
+
const fullscreen = typeof window !== "undefined"
|
|
1073
|
+
&& typeof window.matchMedia === "function"
|
|
1074
|
+
&& window.matchMedia("(max-width: 920px)").matches;
|
|
1075
|
+
Promise.resolve(controller.revealArtifact(artifactRef.sessionId, artifactRef.filename, { fullscreen }))
|
|
1076
|
+
.catch(() => {});
|
|
1077
|
+
}, [controller, artifactRef.sessionId, artifactRef.filename]);
|
|
1078
|
+
|
|
1079
|
+
return React.createElement("button", {
|
|
1080
|
+
type: "button",
|
|
1081
|
+
className: `ps-artifact-link${diff ? " is-diff" : ""}`,
|
|
1082
|
+
onClick: onOpen,
|
|
1083
|
+
title: diff
|
|
1084
|
+
? `Open ${artifactRef.filename} as a diff`
|
|
1085
|
+
: `Open ${artifactRef.filename} in Files`,
|
|
1086
|
+
style,
|
|
1087
|
+
},
|
|
1088
|
+
diff ? React.createElement("span", { className: "ps-artifact-link-glyph", "aria-hidden": "true" }, "±") : null,
|
|
1089
|
+
React.createElement("span", { className: "ps-artifact-link-label" }, label || artifactRef.filename));
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function isDiffLanguage(language) {
|
|
1093
|
+
return DIFF_LANGUAGES.has(String(language || "").trim().toLowerCase());
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// File/meta headers are matched BEFORE +/- so "--- a/x" and "+++ b/x" are not
|
|
1097
|
+
// mistaken for removed/added lines.
|
|
1098
|
+
const DIFF_META_PATTERN = /^(?:diff --git |index |--- |\+\+\+ |new file mode|deleted file mode|old mode|new mode|similarity index|dissimilarity index|rename (?:from|to) |copy (?:from|to) |Binary files |GIT binary patch|\\)/;
|
|
1099
|
+
|
|
1100
|
+
function classifyDiffLine(line) {
|
|
1101
|
+
if (DIFF_META_PATTERN.test(line)) return "meta";
|
|
1102
|
+
if (line.startsWith("@@")) return "hunk";
|
|
1103
|
+
if (line.startsWith("+")) return "add";
|
|
1104
|
+
if (line.startsWith("-")) return "del";
|
|
1105
|
+
return "context";
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const DIFF_LINE_COLORS = { add: "green", del: "red", hunk: "cyan", meta: "gray" };
|
|
1109
|
+
|
|
1110
|
+
function renderDiffCode(content, theme) {
|
|
1111
|
+
const lines = String(content || "").replace(/\n$/, "").split("\n");
|
|
1112
|
+
return lines.map((line, index) => {
|
|
1113
|
+
const kind = classifyDiffLine(line);
|
|
1114
|
+
const color = DIFF_LINE_COLORS[kind];
|
|
1115
|
+
// Payload lines carry their +/-/space in column 1. Split it off into
|
|
1116
|
+
// a gutter (GitHub/GitLab/VS Code all do this) so the marker stops
|
|
1117
|
+
// colliding with the code, wrapped lines hang under the text column
|
|
1118
|
+
// instead of under the marker, and a copied selection yields clean
|
|
1119
|
+
// code — the gutter is user-select:none. Meta/hunk lines have no
|
|
1120
|
+
// marker but keep the empty gutter so every line stays aligned.
|
|
1121
|
+
// Only strip column 1 when it really IS a marker. "context" is the
|
|
1122
|
+
// fallback for every unrecognized line, so slicing it unconditionally
|
|
1123
|
+
// ate the first real character of anything that is not a unified-diff
|
|
1124
|
+
// body line — GIT binary patch payload ("delta 142", "zcmV;91Ru…"),
|
|
1125
|
+
// format-patch commit prose, mbox headers. Real context lines start
|
|
1126
|
+
// with a space; those still get their marker split off.
|
|
1127
|
+
const hasMarker = kind === "add" || kind === "del"
|
|
1128
|
+
|| (kind === "context" && line.startsWith(" "));
|
|
1129
|
+
const marker = hasMarker ? line.slice(0, 1) : "";
|
|
1130
|
+
const text = hasMarker ? line.slice(1) : line;
|
|
1131
|
+
return React.createElement("span", {
|
|
1132
|
+
key: `d:${index}`,
|
|
1133
|
+
className: `ps-diff-line is-${kind}`,
|
|
1134
|
+
style: color ? { color: resolveColor(theme, color) || undefined } : undefined,
|
|
1135
|
+
},
|
|
1136
|
+
React.createElement("span", {
|
|
1137
|
+
className: "ps-diff-marker",
|
|
1138
|
+
// Decorative: the tint already carries the meaning visually,
|
|
1139
|
+
// and the row is announced by its text.
|
|
1140
|
+
"aria-hidden": "true",
|
|
1141
|
+
}, marker.trim()),
|
|
1142
|
+
React.createElement("span", { className: "ps-diff-text" }, text));
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function isMermaidLanguage(language) {
|
|
1147
|
+
return String(language || "").trim().toLowerCase() === "mermaid";
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const CODE_KEYWORDS = {
|
|
1151
|
+
js: new Set(["async", "await", "break", "case", "catch", "class", "const", "continue", "default", "delete", "do", "else", "export", "extends", "finally", "for", "from", "function", "if", "import", "in", "instanceof", "let", "new", "of", "return", "static", "super", "switch", "this", "throw", "try", "typeof", "var", "void", "while", "yield", "true", "false", "null", "undefined", "interface", "type", "enum", "implements", "readonly", "public", "private", "protected"]),
|
|
1152
|
+
python: new Set(["and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "return", "True", "False", "try", "while", "with", "yield", "self"]),
|
|
1153
|
+
shell: new Set(["if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "in", "function", "return", "export", "local", "set", "echo", "cd", "sudo", "source"]),
|
|
1154
|
+
sql: new Set(["select", "from", "where", "insert", "into", "values", "update", "set", "delete", "create", "table", "alter", "drop", "index", "join", "left", "right", "inner", "outer", "on", "group", "order", "by", "having", "limit", "offset", "and", "or", "not", "null", "as", "distinct", "union", "all", "case", "when", "then", "else", "end", "with", "returning", "primary", "key", "foreign", "references", "default", "constraint", "begin", "commit", "rollback"]),
|
|
1155
|
+
rust: new Set(["as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where", "while"]),
|
|
1156
|
+
go: new Set(["break", "case", "chan", "const", "continue", "default", "defer", "else", "fallthrough", "for", "func", "go", "goto", "if", "import", "interface", "map", "package", "range", "return", "select", "struct", "switch", "type", "var", "nil", "true", "false"]),
|
|
1157
|
+
json: new Set(["true", "false", "null"]),
|
|
1158
|
+
yaml: new Set(["true", "false", "null", "yes", "no"]),
|
|
1159
|
+
css: new Set(["important", "media", "keyframes", "import", "supports"]),
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
// Scanner rules per family. Order matters: comments and strings first so a
|
|
1163
|
+
// `#` or quote inside them never re-enters another rule. Sticky (`y`) so a
|
|
1164
|
+
// rule only ever matches at the current cursor.
|
|
1165
|
+
function codeScannerRules(lang) {
|
|
1166
|
+
const hashComment = { type: "comment", re: /#[^\n]*/y };
|
|
1167
|
+
const slashComment = { type: "comment", re: /\/\/[^\n]*|\/\*[\s\S]*?\*\//y };
|
|
1168
|
+
const dashComment = { type: "comment", re: /--[^\n]*|\/\*[\s\S]*?\*\//y };
|
|
1169
|
+
const number = { type: "number", re: /\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?\b|\b0[xX][0-9a-fA-F]+\b/y };
|
|
1170
|
+
const identifier = { type: "identifier", re: /[A-Za-z_$][\w$]*/y };
|
|
1171
|
+
const dq = { type: "string", re: /"(?:\\.|[^"\\])*"?/y };
|
|
1172
|
+
const sq = { type: "string", re: /'(?:\\.|[^'\\])*'?/y };
|
|
1173
|
+
const backtick = { type: "string", re: /`(?:\\.|[^`\\])*`?/y };
|
|
1174
|
+
const pyTriple = { type: "string", re: /"""[\s\S]*?"""|'''[\s\S]*?'''/y };
|
|
1175
|
+
|
|
1176
|
+
switch (lang) {
|
|
1177
|
+
case "python":
|
|
1178
|
+
return [hashComment, pyTriple, dq, sq, number, identifier];
|
|
1179
|
+
case "shell":
|
|
1180
|
+
return [hashComment, dq, sq, number, identifier];
|
|
1181
|
+
case "sql":
|
|
1182
|
+
return [dashComment, sq, dq, number, identifier];
|
|
1183
|
+
case "yaml":
|
|
1184
|
+
return [hashComment, dq, sq, number, identifier];
|
|
1185
|
+
case "json":
|
|
1186
|
+
return [dq, number, identifier];
|
|
1187
|
+
case "css":
|
|
1188
|
+
return [{ type: "comment", re: /\/\*[\s\S]*?\*\//y }, dq, sq, number, identifier];
|
|
1189
|
+
default:
|
|
1190
|
+
return [slashComment, dq, sq, backtick, number, identifier];
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const CODE_TOKEN_COLORS = {
|
|
1195
|
+
comment: "gray",
|
|
1196
|
+
string: "green",
|
|
1197
|
+
number: "yellow",
|
|
1198
|
+
keyword: "magenta",
|
|
1199
|
+
fn: "cyan",
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
// Blocks past this size skip highlighting — a chat transcript should never
|
|
1203
|
+
// pay tokenizer cost for a dumped file.
|
|
1204
|
+
const CODE_HIGHLIGHT_MAX_CHARS = 20000;
|
|
1205
|
+
|
|
1206
|
+
// Tokenizing runs inside the render path, so an unmemoized transcript
|
|
1207
|
+
// re-highlights EVERY code block on every render (a keystroke in the
|
|
1208
|
+
// composer re-renders the pane). Results are content-addressed and cached;
|
|
1209
|
+
// the cap keeps a long-lived session from growing this without bound.
|
|
1210
|
+
const CODE_TOKEN_CACHE = new Map();
|
|
1211
|
+
const CODE_TOKEN_CACHE_MAX = 120;
|
|
1212
|
+
|
|
1213
|
+
function tokenizeCodeCached(source, language) {
|
|
1214
|
+
const key = `${language || ""}\u0000${source}`;
|
|
1215
|
+
const hit = CODE_TOKEN_CACHE.get(key);
|
|
1216
|
+
if (hit) {
|
|
1217
|
+
// Refresh LRU position.
|
|
1218
|
+
CODE_TOKEN_CACHE.delete(key);
|
|
1219
|
+
CODE_TOKEN_CACHE.set(key, hit);
|
|
1220
|
+
return hit;
|
|
1221
|
+
}
|
|
1222
|
+
const tokens = tokenizeCode(source, language);
|
|
1223
|
+
CODE_TOKEN_CACHE.set(key, tokens);
|
|
1224
|
+
if (CODE_TOKEN_CACHE.size > CODE_TOKEN_CACHE_MAX) {
|
|
1225
|
+
CODE_TOKEN_CACHE.delete(CODE_TOKEN_CACHE.keys().next().value);
|
|
1226
|
+
}
|
|
1227
|
+
return tokens;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function tokenizeCode(source, language) {
|
|
1231
|
+
const lang = CODE_LANGUAGE_ALIASES[String(language || "").trim().toLowerCase()] || "default";
|
|
1232
|
+
const keywords = CODE_KEYWORDS[lang] || CODE_KEYWORDS.js;
|
|
1233
|
+
const rules = codeScannerRules(lang);
|
|
1234
|
+
const tokens = [];
|
|
1235
|
+
let cursor = 0;
|
|
1236
|
+
let plainStart = 0;
|
|
1237
|
+
|
|
1238
|
+
const flushPlain = (end) => {
|
|
1239
|
+
if (end > plainStart) tokens.push({ type: "plain", text: source.slice(plainStart, end) });
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
while (cursor < source.length) {
|
|
1243
|
+
let hit = null;
|
|
1244
|
+
for (const rule of rules) {
|
|
1245
|
+
rule.re.lastIndex = cursor;
|
|
1246
|
+
const match = rule.re.exec(source);
|
|
1247
|
+
if (match && match.index === cursor && match[0].length > 0) {
|
|
1248
|
+
hit = { type: rule.type, text: match[0] };
|
|
1249
|
+
break;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
if (!hit) {
|
|
1253
|
+
cursor += 1;
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
let type = hit.type;
|
|
1257
|
+
if (type === "identifier") {
|
|
1258
|
+
if (keywords.has(hit.text)) {
|
|
1259
|
+
type = "keyword";
|
|
1260
|
+
} else {
|
|
1261
|
+
// A call target reads as a function; anything else is plain.
|
|
1262
|
+
const after = source.slice(cursor + hit.text.length);
|
|
1263
|
+
type = /^\s*\(/.test(after) ? "fn" : "plain";
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
if (type === "plain") {
|
|
1267
|
+
cursor += hit.text.length;
|
|
1268
|
+
continue;
|
|
1269
|
+
}
|
|
1270
|
+
flushPlain(cursor);
|
|
1271
|
+
tokens.push({ type, text: hit.text });
|
|
1272
|
+
cursor += hit.text.length;
|
|
1273
|
+
plainStart = cursor;
|
|
1274
|
+
}
|
|
1275
|
+
flushPlain(source.length);
|
|
1276
|
+
return tokens;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function renderHighlightedCode(content, language, theme) {
|
|
1280
|
+
const source = String(content || "");
|
|
1281
|
+
if (!source || source.length > CODE_HIGHLIGHT_MAX_CHARS) return source;
|
|
1282
|
+
let tokens;
|
|
1283
|
+
try {
|
|
1284
|
+
tokens = tokenizeCodeCached(source, language);
|
|
1285
|
+
} catch {
|
|
1286
|
+
return source;
|
|
1287
|
+
}
|
|
1288
|
+
return tokens.map((token, index) => {
|
|
1289
|
+
const color = CODE_TOKEN_COLORS[token.type];
|
|
1290
|
+
if (!color) return React.createElement(React.Fragment, { key: `t:${index}` }, token.text);
|
|
1291
|
+
return React.createElement("span", {
|
|
1292
|
+
key: `t:${index}`,
|
|
1293
|
+
className: `ps-code-token is-${token.type}`,
|
|
1294
|
+
style: { color: resolveColor(theme, color) || undefined },
|
|
1295
|
+
}, token.text);
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// ── Mermaid diagrams ──────────────────────────────────────────────────────
|
|
1300
|
+
// mermaid is ~2MB, so it is imported DYNAMICALLY: vite splits it into its own
|
|
1301
|
+
// chunk that is fetched only when a transcript actually contains a ```mermaid
|
|
1302
|
+
// fence. A failed parse (very common while a diagram is still streaming in)
|
|
1303
|
+
// falls back to the highlighted source rather than an error box.
|
|
1304
|
+
|
|
1305
|
+
let mermaidModulePromise = null;
|
|
1306
|
+
|
|
1307
|
+
function loadMermaid() {
|
|
1308
|
+
if (!mermaidModulePromise) {
|
|
1309
|
+
mermaidModulePromise = import("mermaid")
|
|
1310
|
+
.then((module) => module.default || module)
|
|
1311
|
+
.catch((error) => {
|
|
1312
|
+
mermaidModulePromise = null;
|
|
1313
|
+
throw error;
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
return mermaidModulePromise;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// initialize() must run per render, not once at import: the module promise is
|
|
1320
|
+
// cached, so configuring the palette only on first load left every diagram
|
|
1321
|
+
// frozen on whichever theme happened to be active when mermaid loaded (a
|
|
1322
|
+
// dark->light switch kept rendering dark diagrams until a reload).
|
|
1323
|
+
function configureMermaid(mermaid, light) {
|
|
1324
|
+
mermaid.initialize({
|
|
1325
|
+
startOnLoad: false,
|
|
1326
|
+
securityLevel: "strict",
|
|
1327
|
+
theme: light ? "default" : "dark",
|
|
1328
|
+
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif",
|
|
1329
|
+
});
|
|
1330
|
+
return mermaid;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
let mermaidRenderSeq = 0;
|
|
1334
|
+
|
|
1335
|
+
// mermaid.render() is NOT re-entrant: it stages each diagram through shared
|
|
1336
|
+
// internal state, so two blocks in one message racing each other leaves one
|
|
1337
|
+
// of them unrendered (whichever lost the race falls back to source). Renders
|
|
1338
|
+
// are therefore serialized through a promise chain.
|
|
1339
|
+
let mermaidRenderQueue = Promise.resolve();
|
|
1340
|
+
|
|
1341
|
+
function queueMermaidRender(task) {
|
|
1342
|
+
const run = mermaidRenderQueue.then(task, task);
|
|
1343
|
+
mermaidRenderQueue = run.then(() => undefined, () => undefined);
|
|
1344
|
+
return run;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function MermaidDiagram({ code, theme, fallback }) {
|
|
1348
|
+
const [svg, setSvg] = React.useState(null);
|
|
1349
|
+
const [failed, setFailed] = React.useState(false);
|
|
1350
|
+
const [errorText, setErrorText] = React.useState("");
|
|
1351
|
+
const light = React.useMemo(() => isThemeLight(theme), [theme]);
|
|
1352
|
+
|
|
1353
|
+
React.useEffect(() => {
|
|
1354
|
+
let active = true;
|
|
1355
|
+
const source = String(code || "").trim();
|
|
1356
|
+
if (!source) return undefined;
|
|
1357
|
+
setFailed(false);
|
|
1358
|
+
loadMermaid()
|
|
1359
|
+
.then((mermaid) => queueMermaidRender(async () => {
|
|
1360
|
+
if (!active) return;
|
|
1361
|
+
configureMermaid(mermaid, light);
|
|
1362
|
+
mermaidRenderSeq += 1;
|
|
1363
|
+
const id = `ps-mermaid-${mermaidRenderSeq}`;
|
|
1364
|
+
// parse() first so a half-streamed diagram fails cheaply and
|
|
1365
|
+
// without mermaid injecting an error graphic into the DOM.
|
|
1366
|
+
await mermaid.parse(source);
|
|
1367
|
+
const { svg: rendered } = await mermaid.render(id, source);
|
|
1368
|
+
if (active) setSvg(rendered);
|
|
1369
|
+
}))
|
|
1370
|
+
.catch((error) => {
|
|
1371
|
+
if (active) { setSvg(null); setFailed(true); setErrorText(String(error?.message || error)); }
|
|
1372
|
+
});
|
|
1373
|
+
return () => { active = false; };
|
|
1374
|
+
}, [code, light]);
|
|
1375
|
+
|
|
1376
|
+
if (svg && !failed) {
|
|
1377
|
+
return React.createElement("div", {
|
|
1378
|
+
className: "ps-mermaid",
|
|
1379
|
+
// mermaid output is generated from the diagram source with
|
|
1380
|
+
// securityLevel "strict" (HTML labels off, scripts stripped).
|
|
1381
|
+
dangerouslySetInnerHTML: { __html: svg },
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
// A diagram mermaid cannot parse falls back to its source WITH the
|
|
1385
|
+
// reason: silently showing code left "why didn't this render?" a mystery.
|
|
1386
|
+
if (failed && errorText) {
|
|
1387
|
+
return React.createElement("div", { className: "ps-mermaid-failed" },
|
|
1388
|
+
React.createElement("div", { className: "ps-mermaid-error" },
|
|
1389
|
+
`Diagram could not be rendered — ${String(errorText).split("\n")[0]}`),
|
|
1390
|
+
fallback);
|
|
1391
|
+
}
|
|
1392
|
+
return fallback;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
893
1395
|
function renderInlineMarkdown(source, theme, keyPrefix = "md") {
|
|
894
1396
|
return tokenizeInlineMarkdown(source).map((token, index) => {
|
|
895
1397
|
const key = `${keyPrefix}:${index}`;
|
|
@@ -903,6 +1405,25 @@ function renderInlineMarkdown(source, theme, keyPrefix = "md") {
|
|
|
903
1405
|
return React.createElement("em", { key, className: "ps-md-em" }, renderInlineMarkdown(token.text, theme, `${key}:em`));
|
|
904
1406
|
}
|
|
905
1407
|
if (token.type === "link") {
|
|
1408
|
+
const artifactRef = parseArtifactHref(token.href);
|
|
1409
|
+
if (artifactRef) {
|
|
1410
|
+
return React.createElement(ArtifactLink, {
|
|
1411
|
+
key,
|
|
1412
|
+
label: token.text,
|
|
1413
|
+
artifactRef,
|
|
1414
|
+
style: { color: resolveColor(theme, "cyan") },
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
// Only http(s) becomes a navigable anchor. Anything else — an
|
|
1418
|
+
// unknown scheme, or a malformed href from a bad transform — would
|
|
1419
|
+
// resolve RELATIVE to the portal and navigate the SPA away on
|
|
1420
|
+
// click, losing all session state. Render it as inert text instead.
|
|
1421
|
+
if (!/^https?:\/\//i.test(String(token.href || "").trim())) {
|
|
1422
|
+
return React.createElement("span", {
|
|
1423
|
+
key,
|
|
1424
|
+
style: { color: resolveColor(theme, "cyan") },
|
|
1425
|
+
}, token.text);
|
|
1426
|
+
}
|
|
906
1427
|
return React.createElement("a", {
|
|
907
1428
|
key,
|
|
908
1429
|
className: "ps-md-link",
|
|
@@ -1186,10 +1707,25 @@ function MarkdownPreviewContent({ content, theme }) {
|
|
|
1186
1707
|
}, renderInlineMarkdown(block.text, theme, `heading:${index}`));
|
|
1187
1708
|
}
|
|
1188
1709
|
if (block.type === "code") {
|
|
1189
|
-
|
|
1710
|
+
const isDiff = isDiffLanguage(block.language);
|
|
1711
|
+
const codeSection = React.createElement("section", {
|
|
1712
|
+
key: `block:${index}`,
|
|
1713
|
+
className: `ps-md-code-block${isDiff ? " is-diff" : ""}`,
|
|
1714
|
+
},
|
|
1190
1715
|
React.createElement("div", { className: "ps-md-code-header" }, block.language || "text"),
|
|
1191
1716
|
React.createElement("pre", { className: "ps-md-code-pre" },
|
|
1192
|
-
React.createElement("code", null,
|
|
1717
|
+
React.createElement("code", null, isDiff
|
|
1718
|
+
? renderDiffCode(block.content, theme)
|
|
1719
|
+
: renderHighlightedCode(block.content, block.language, theme))));
|
|
1720
|
+
if (isMermaidLanguage(block.language)) {
|
|
1721
|
+
return React.createElement(MermaidDiagram, {
|
|
1722
|
+
key: `block:${index}`,
|
|
1723
|
+
code: block.content,
|
|
1724
|
+
theme,
|
|
1725
|
+
fallback: codeSection,
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
return codeSection;
|
|
1193
1729
|
}
|
|
1194
1730
|
if (block.type === "blockquote") {
|
|
1195
1731
|
return React.createElement("blockquote", { key: `block:${index}`, className: "ps-md-quote" },
|
|
@@ -1268,7 +1804,42 @@ function MarkdownPreviewContent({ content, theme }) {
|
|
|
1268
1804
|
}));
|
|
1269
1805
|
}
|
|
1270
1806
|
|
|
1271
|
-
|
|
1807
|
+
/**
|
|
1808
|
+
* Preview panel for pre-rendered content (diff, highlighted code).
|
|
1809
|
+
*
|
|
1810
|
+
* Shares MarkdownPreviewPanel's scroll wiring rather than being a plain
|
|
1811
|
+
* overflow:auto div: without the pane hookup the keyboard scroll commands
|
|
1812
|
+
* have nothing to drive, so these previews could only ever be scrolled with
|
|
1813
|
+
* the mouse — the global key handler swallows the arrows before the browser
|
|
1814
|
+
* would scroll them natively.
|
|
1815
|
+
*/
|
|
1816
|
+
function RenderedPreviewPanel({ controller, title, color, focused, scrollOffset = 0, paneKey, theme, className = "", focusRegion = null, children }) {
|
|
1817
|
+
const ref = React.useRef(null);
|
|
1818
|
+
const onScroll = usePanePixelScroll(ref, scrollOffset, paneKey, controller);
|
|
1819
|
+
// Mouse only. On touch this fired on every tap, and setFocus runs the
|
|
1820
|
+
// region through normalizeFocusRegion, which rewrites a region missing
|
|
1821
|
+
// from the current layout to order[0] — so tapping the chat on a phone
|
|
1822
|
+
// was silently reassigning focus to the inspector and jumping to the
|
|
1823
|
+
// artifact list. Keyboard scroll targeting is meaningless on touch anyway.
|
|
1824
|
+
const claimFocus = React.useCallback(() => {
|
|
1825
|
+
const region = focusRegionForPaneKey(paneKey, focusRegion);
|
|
1826
|
+
if (region && controller?.setFocus) controller.setFocus(region);
|
|
1827
|
+
}, [controller, paneKey, focusRegion]);
|
|
1828
|
+
|
|
1829
|
+
return React.createElement(Panel, { title, color, focused, theme },
|
|
1830
|
+
React.createElement("div", {
|
|
1831
|
+
ref,
|
|
1832
|
+
className: `ps-scroll-panel is-preview ${className}`.trim(),
|
|
1833
|
+
onScroll,
|
|
1834
|
+
onMouseDown: claimFocus,
|
|
1835
|
+
}, children));
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function MarkdownPreviewPanel({ controller, title, color, focused, scrollOffset = 0, paneKey, theme, content, focusRegion = null }) {
|
|
1839
|
+
const claimMarkdownFocus = React.useCallback(() => {
|
|
1840
|
+
const region = focusRegionForPaneKey(paneKey, focusRegion);
|
|
1841
|
+
if (region && controller?.setFocus) controller.setFocus(region);
|
|
1842
|
+
}, [controller, paneKey, focusRegion]);
|
|
1272
1843
|
const ref = React.useRef(null);
|
|
1273
1844
|
const onScroll = usePanePixelScroll(ref, scrollOffset, paneKey, controller);
|
|
1274
1845
|
|
|
@@ -1277,6 +1848,7 @@ function MarkdownPreviewPanel({ controller, title, color, focused, scrollOffset
|
|
|
1277
1848
|
ref,
|
|
1278
1849
|
className: "ps-scroll-panel ps-markdown-scroll",
|
|
1279
1850
|
onScroll,
|
|
1851
|
+
onMouseDown: claimMarkdownFocus,
|
|
1280
1852
|
}, React.createElement(MarkdownPreviewContent, { content, theme })));
|
|
1281
1853
|
}
|
|
1282
1854
|
|
|
@@ -1307,9 +1879,14 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
|
|
|
1307
1879
|
})
|
|
1308
1880
|
: "";
|
|
1309
1881
|
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
|
|
1882
|
+
// Images render inline (authenticated fetch → object URL). Other binary
|
|
1883
|
+
// types keep the download-only card.
|
|
1884
|
+
//
|
|
1885
|
+
// SVG goes through the SAME object-URL <img> path rather than being
|
|
1886
|
+
// inlined as markup: an artifact is untrusted content, and <img> does not
|
|
1887
|
+
// execute script inside an SVG the way inline markup would.
|
|
1888
|
+
const isRasterImage = IMAGE_CONTENT_TYPE_RE.test(String(contentType || ""))
|
|
1889
|
+
|| IMAGE_FILENAME_RE.test(String(filename || ""));
|
|
1313
1890
|
const [imageUrl, setImageUrl] = React.useState(null);
|
|
1314
1891
|
React.useEffect(() => {
|
|
1315
1892
|
setImageUrl(null);
|
|
@@ -1661,6 +2238,78 @@ function StructuredChatBlocks({ lines, theme, controller = null }) {
|
|
|
1661
2238
|
return React.createElement(StructuredBlockList, { blocks, theme, controller });
|
|
1662
2239
|
}
|
|
1663
2240
|
|
|
2241
|
+
// Rich (desktop-style) chat transcript. Consumes selectChatBlocks: plain
|
|
2242
|
+
// user/assistant prose renders as proportional-type markdown messages;
|
|
2243
|
+
// every other block (splash, thinking cards, system cards, epoch dividers)
|
|
2244
|
+
// reuses the structured line renderer so the terminal-era special cases
|
|
2245
|
+
// keep exactly one implementation.
|
|
2246
|
+
function RichChatBlocks({ blocks, theme, controller = null }) {
|
|
2247
|
+
return React.createElement("div", { className: "ps-rich-chat" },
|
|
2248
|
+
(blocks || []).map((block, index) => {
|
|
2249
|
+
if (block.kind === "message") {
|
|
2250
|
+
const header = block.header || {};
|
|
2251
|
+
const roleClass = block.role === "user" ? " is-user" : " is-assistant";
|
|
2252
|
+
const pendingClass = block.pendingPhase ? ` is-${block.pendingPhase}` : "";
|
|
2253
|
+
const text = String(block.text || "");
|
|
2254
|
+
// A speaker label only earns its place when it disambiguates:
|
|
2255
|
+
// another human in a shared session. The viewer's own turns
|
|
2256
|
+
// and the agent's are obvious from side and styling.
|
|
2257
|
+
const showLabel = block.role === "user" && header.fromOtherPerson;
|
|
2258
|
+
// Consecutive turns from the same speaker read as one
|
|
2259
|
+
// continuous passage — no repeated header, tighter spacing.
|
|
2260
|
+
const previous = blocks[index - 1];
|
|
2261
|
+
const continued = previous
|
|
2262
|
+
&& previous.kind === "message"
|
|
2263
|
+
&& previous.role === block.role
|
|
2264
|
+
&& !(previous.header && previous.header.fromOtherPerson)
|
|
2265
|
+
&& !showLabel;
|
|
2266
|
+
return React.createElement("article", {
|
|
2267
|
+
key: block.id != null ? `msg:${block.id}` : `msg:${index}`,
|
|
2268
|
+
className: `ps-rich-msg${roleClass}${pendingClass}${continued ? " is-continued" : ""}`,
|
|
2269
|
+
},
|
|
2270
|
+
React.createElement("header", { className: "ps-rich-msg-head" },
|
|
2271
|
+
showLabel
|
|
2272
|
+
? React.createElement("span", {
|
|
2273
|
+
className: "ps-rich-msg-label",
|
|
2274
|
+
style: { color: resolveColor(theme, header.roleColor) || undefined },
|
|
2275
|
+
}, header.roleLabel)
|
|
2276
|
+
: null,
|
|
2277
|
+
// Delivery glyph + timestamp are on-demand detail:
|
|
2278
|
+
// revealed on hover so a quiet transcript stays quiet.
|
|
2279
|
+
React.createElement("span", { className: "ps-rich-msg-meta" },
|
|
2280
|
+
header.glyph
|
|
2281
|
+
? React.createElement("span", {
|
|
2282
|
+
className: "ps-rich-msg-glyph",
|
|
2283
|
+
title: block.pendingPhase || undefined,
|
|
2284
|
+
style: { color: resolveColor(theme, header.glyphColor) || undefined },
|
|
2285
|
+
}, header.glyph)
|
|
2286
|
+
: null,
|
|
2287
|
+
header.time
|
|
2288
|
+
? React.createElement("span", { className: "ps-rich-msg-time" }, header.time)
|
|
2289
|
+
: null)),
|
|
2290
|
+
text.trim()
|
|
2291
|
+
? React.createElement("div", { className: "ps-rich-msg-body" },
|
|
2292
|
+
React.createElement(MarkdownPreviewContent, { content: text, theme }))
|
|
2293
|
+
: null,
|
|
2294
|
+
block.attachments
|
|
2295
|
+
? React.createElement(ArtifactImageStrip, {
|
|
2296
|
+
controller,
|
|
2297
|
+
sessionId: block.attachments.sessionId,
|
|
2298
|
+
attachments: block.attachments.attachments,
|
|
2299
|
+
})
|
|
2300
|
+
: null);
|
|
2301
|
+
}
|
|
2302
|
+
return React.createElement("div", {
|
|
2303
|
+
key: `lines:${index}`,
|
|
2304
|
+
className: `ps-rich-lineblock is-${block.variant || "event"}`,
|
|
2305
|
+
}, React.createElement(StructuredChatBlocks, {
|
|
2306
|
+
lines: normalizeLines(block.lines || []),
|
|
2307
|
+
theme,
|
|
2308
|
+
controller,
|
|
2309
|
+
}));
|
|
2310
|
+
}));
|
|
2311
|
+
}
|
|
2312
|
+
|
|
1664
2313
|
// Authenticated artifact thumbnails: bytes are fetched through the transport
|
|
1665
2314
|
// (Bearer token) and served to <img> as object URLs. Module-level cache so
|
|
1666
2315
|
// scrolling the transcript doesn't refetch; oldest entries are revoked at cap.
|
|
@@ -1767,10 +2416,25 @@ function StructuredBlockList({ blocks, theme, controller = null }) {
|
|
|
1767
2416
|
}
|
|
1768
2417
|
|
|
1769
2418
|
if (block.type === "code") {
|
|
1770
|
-
|
|
2419
|
+
const chatIsDiff = isDiffLanguage(block.language);
|
|
2420
|
+
const chatCodeSection = React.createElement("section", {
|
|
2421
|
+
key: `code:${index}`,
|
|
2422
|
+
className: `ps-md-code-block ps-chat-code-block${chatIsDiff ? " is-diff" : ""}`,
|
|
2423
|
+
},
|
|
1771
2424
|
React.createElement("div", { className: "ps-md-code-header" }, block.language || "text"),
|
|
1772
2425
|
React.createElement("pre", { className: "ps-md-code-pre" },
|
|
1773
|
-
React.createElement("code", null,
|
|
2426
|
+
React.createElement("code", null, chatIsDiff
|
|
2427
|
+
? renderDiffCode(block.content || "", theme)
|
|
2428
|
+
: renderHighlightedCode(block.content || "", block.language, theme))));
|
|
2429
|
+
if (isMermaidLanguage(block.language)) {
|
|
2430
|
+
return React.createElement(MermaidDiagram, {
|
|
2431
|
+
key: `code:${index}`,
|
|
2432
|
+
code: block.content || "",
|
|
2433
|
+
theme,
|
|
2434
|
+
fallback: chatCodeSection,
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
return chatCodeSection;
|
|
1774
2438
|
}
|
|
1775
2439
|
|
|
1776
2440
|
if (block.type === "card") {
|
|
@@ -1856,13 +2520,34 @@ function StructuredBlockList({ blocks, theme, controller = null }) {
|
|
|
1856
2520
|
}));
|
|
1857
2521
|
}
|
|
1858
2522
|
|
|
2523
|
+
// Pane titles carry terminal-style bracketed runs (" [+3]", " [5f086c7c]").
|
|
2524
|
+
// The rich view keeps the information but drops the brackets — a web title
|
|
2525
|
+
// bar shows a count and an id, it does not draw them in ASCII delimiters.
|
|
2526
|
+
function unbracketTitleRuns(runs) {
|
|
2527
|
+
if (!Array.isArray(runs)) return runs;
|
|
2528
|
+
return runs.map((run) => {
|
|
2529
|
+
const text = String(run?.text ?? "");
|
|
2530
|
+
const match = /^(\s*)\[(.+)\](\s*)$/.exec(text);
|
|
2531
|
+
if (!match) return run;
|
|
2532
|
+
return { ...run, text: `${match[1]}${match[2]}${match[3]}` };
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
|
|
1859
2536
|
function Panel({ title, titleRight = null, color = "gray", focused = false, actions = null, children, theme, className = "" }) {
|
|
1860
2537
|
const accent = resolveColor(theme, color);
|
|
2538
|
+
// A panel with nothing to put in its header should not render one. Nested
|
|
2539
|
+
// panels (the artifact list and preview inside Files) were spending a full
|
|
2540
|
+
// header row restating what the enclosing pane already said.
|
|
2541
|
+
const hasHeader = Boolean(
|
|
2542
|
+
(Array.isArray(title) ? title.length > 0 : String(title ?? "").trim())
|
|
2543
|
+
|| titleRight
|
|
2544
|
+
|| actions,
|
|
2545
|
+
);
|
|
1861
2546
|
return React.createElement("section", {
|
|
1862
|
-
className: `ps-panel${focused ? " is-focused" : ""}${className ? ` ${className}` : ""}`,
|
|
2547
|
+
className: `ps-panel${focused ? " is-focused" : ""}${hasHeader ? "" : " is-chromeless"}${className ? ` ${className}` : ""}`,
|
|
1863
2548
|
style: { "--ps-panel-accent": accent || "var(--ps-border)" },
|
|
1864
2549
|
},
|
|
1865
|
-
React.createElement("header", { className: "ps-panel-header" },
|
|
2550
|
+
hasHeader ? React.createElement("header", { className: "ps-panel-header" },
|
|
1866
2551
|
React.createElement("div", { className: "ps-panel-title" },
|
|
1867
2552
|
Array.isArray(title)
|
|
1868
2553
|
? React.createElement(Runs, { runs: title, theme })
|
|
@@ -1878,7 +2563,7 @@ function Panel({ title, titleRight = null, color = "gray", focused = false, acti
|
|
|
1878
2563
|
actions ? React.createElement("div", { className: "ps-panel-actions" }, actions) : null,
|
|
1879
2564
|
)
|
|
1880
2565
|
: null,
|
|
1881
|
-
),
|
|
2566
|
+
) : null,
|
|
1882
2567
|
React.createElement("div", { className: "ps-panel-body" }, children));
|
|
1883
2568
|
}
|
|
1884
2569
|
|
|
@@ -1981,7 +2666,33 @@ function PortalSequenceLines({ lines, theme, completionByTurn }) {
|
|
|
1981
2666
|
}
|
|
1982
2667
|
|
|
1983
2668
|
|
|
1984
|
-
|
|
2669
|
+
/**
|
|
2670
|
+
* Which focus region owns a scrollable pane.
|
|
2671
|
+
*
|
|
2672
|
+
* Keyboard scrolling already routes through focusRegion
|
|
2673
|
+
* (controller.getScrollablePaneForFocus), but nothing set the focus when you
|
|
2674
|
+
* CLICKED a pane — so the scroll keys kept acting on whatever was focused
|
|
2675
|
+
* last. Clicking into a scrollable pane now claims the keys for it.
|
|
2676
|
+
*/
|
|
2677
|
+
const PANE_KEY_FOCUS_REGIONS = {
|
|
2678
|
+
chat: "chat",
|
|
2679
|
+
activity: "activity",
|
|
2680
|
+
inspector: "inspector",
|
|
2681
|
+
// The preview is reached through the inspector's Files tab, and the
|
|
2682
|
+
// controller maps inspector-focus + files-tab back to filePreview.
|
|
2683
|
+
filePreview: "inspector",
|
|
2684
|
+
};
|
|
2685
|
+
|
|
2686
|
+
function focusRegionForPaneKey(paneKey, override = null) {
|
|
2687
|
+
// The DETACHED preview sits in the activity slot, so clicking it must claim
|
|
2688
|
+
// activity focus — that is what distinguishes "clicked the preview" from
|
|
2689
|
+
// "clicked the artifact list", which keeps inspector focus and drives the
|
|
2690
|
+
// selection with the same keys.
|
|
2691
|
+
if (override) return override;
|
|
2692
|
+
return PANE_KEY_FOCUS_REGIONS[String(paneKey || "")] || null;
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, lines, stickyLines = [], bottomStickyLines = [], scrollOffset = 0, scrollMode = "top", paneKey, controller, className = "", panelClassName = "", topContent = null, bottomContent = null, structuredBlocks = false, stickyBottom = false, renderBody = null, focusRegion = null }) {
|
|
1985
2696
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
1986
2697
|
const theme = getTheme(themeId);
|
|
1987
2698
|
const ref = React.useRef(null);
|
|
@@ -1992,6 +2703,19 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
1992
2703
|
const normalizedBottomSticky = React.useMemo(() => normalizeLines(bottomStickyLines), [bottomStickyLines]);
|
|
1993
2704
|
const preserveHorizontalScroll = className.includes("is-preserve") && panelClassName.includes("has-preserved-sticky");
|
|
1994
2705
|
|
|
2706
|
+
// Clicking (or touching) a scrollable pane makes the keyboard scroll keys
|
|
2707
|
+
// act on THAT pane. Fires on mousedown rather than click so a drag-select
|
|
2708
|
+
// inside the pane claims focus too.
|
|
2709
|
+
// Mouse only. On touch this fired on every tap, and setFocus runs the
|
|
2710
|
+
// region through normalizeFocusRegion, which rewrites a region missing
|
|
2711
|
+
// from the current layout to order[0] — so tapping the chat on a phone
|
|
2712
|
+
// was silently reassigning focus to the inspector and jumping to the
|
|
2713
|
+
// artifact list. Keyboard scroll targeting is meaningless on touch anyway.
|
|
2714
|
+
const claimFocus = React.useCallback(() => {
|
|
2715
|
+
const region = focusRegionForPaneKey(paneKey, focusRegion);
|
|
2716
|
+
if (region && controller?.setFocus) controller.setFocus(region);
|
|
2717
|
+
}, [controller, paneKey, focusRegion]);
|
|
2718
|
+
|
|
1995
2719
|
const syncScrollLeft = React.useCallback((source, target) => {
|
|
1996
2720
|
if (!source || !target) return;
|
|
1997
2721
|
if (Math.abs((target.scrollLeft || 0) - (source.scrollLeft || 0)) <= 1) return;
|
|
@@ -2039,7 +2763,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
2039
2763
|
normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
|
|
2040
2764
|
)
|
|
2041
2765
|
: null,
|
|
2042
|
-
React.createElement("div", { ref, className: `ps-scroll-panel ${className}${scrollShadow.down ? " is-scrolled-down" : ""}${scrollShadow.up ? " is-scrolled-up" : ""}`.trim(), onScroll: handleBodyScroll,
|
|
2766
|
+
React.createElement("div", { ref, className: `ps-scroll-panel ${className}${scrollShadow.down ? " is-scrolled-down" : ""}${scrollShadow.up ? " is-scrolled-up" : ""}`.trim(), onScroll: handleBodyScroll, onMouseDown: claimFocus, onTouchStart, onWheel, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
|
|
2043
2767
|
typeof renderBody === "function"
|
|
2044
2768
|
? renderBody(normalizedLines, theme)
|
|
2045
2769
|
: structuredBlocks
|
|
@@ -2056,6 +2780,68 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
2056
2780
|
bottomContent);
|
|
2057
2781
|
}
|
|
2058
2782
|
|
|
2783
|
+
// Rich (desktop-style) session row: a status dot instead of a glyph, the
|
|
2784
|
+
// title in proportional type, and the id/age/model metadata demoted to a
|
|
2785
|
+
// muted second line under the selected row. Depth becomes real indentation
|
|
2786
|
+
// with a guide rail rather than an ASCII "└" prefix.
|
|
2787
|
+
const SESSION_KIND_GLYPHS = { group: "🗂", system: "⚙", service: "⚗" };
|
|
2788
|
+
|
|
2789
|
+
function RichSessionRow({ row, theme }) {
|
|
2790
|
+
const chrome = row.chrome;
|
|
2791
|
+
if (!chrome) return React.createElement(SessionRowContent, { row, theme, structured: true });
|
|
2792
|
+
|
|
2793
|
+
const kindGlyph = SESSION_KIND_GLYPHS[chrome.kind] || null;
|
|
2794
|
+
const accent = resolveColor(theme, chrome.accentColor) || undefined;
|
|
2795
|
+
const detailRuns = Array.isArray(row.detailRuns) ? row.detailRuns : [];
|
|
2796
|
+
|
|
2797
|
+
return React.createElement("div", { className: "ps-rich-session-row" },
|
|
2798
|
+
React.createElement("div", { className: "ps-rich-session-main" },
|
|
2799
|
+
chrome.selectMode
|
|
2800
|
+
? React.createElement("span", {
|
|
2801
|
+
className: `ps-rich-session-check${chrome.checked ? " is-checked" : ""}`,
|
|
2802
|
+
}, chrome.checked ? "✓" : "")
|
|
2803
|
+
: null,
|
|
2804
|
+
kindGlyph
|
|
2805
|
+
? React.createElement("span", { className: "ps-rich-session-kind" }, kindGlyph)
|
|
2806
|
+
: React.createElement("span", {
|
|
2807
|
+
className: "ps-rich-session-dot",
|
|
2808
|
+
style: { background: resolveColor(theme, chrome.statusColor) || undefined },
|
|
2809
|
+
title: row.status || undefined,
|
|
2810
|
+
}),
|
|
2811
|
+
chrome.owner
|
|
2812
|
+
? React.createElement("span", {
|
|
2813
|
+
className: `ps-rich-session-owner${chrome.owner.isMine ? " is-mine" : ""}`,
|
|
2814
|
+
title: chrome.owner.initials,
|
|
2815
|
+
}, chrome.owner.initials)
|
|
2816
|
+
: null,
|
|
2817
|
+
React.createElement("span", {
|
|
2818
|
+
className: `ps-rich-session-title${chrome.untitled ? " is-untitled" : ""}`,
|
|
2819
|
+
style: chrome.kind === "session" ? undefined : { color: accent },
|
|
2820
|
+
title: chrome.title,
|
|
2821
|
+
}, chrome.title),
|
|
2822
|
+
chrome.pinned ? React.createElement("span", { className: "ps-rich-session-pin", title: "Pinned" }, "📌") : null,
|
|
2823
|
+
chrome.cron ? React.createElement("span", { className: "ps-rich-session-cron", title: "Scheduled" }, "⏱") : null,
|
|
2824
|
+
chrome.childBadge
|
|
2825
|
+
? React.createElement("span", {
|
|
2826
|
+
className: "ps-rich-session-count",
|
|
2827
|
+
style: { color: resolveColor(theme, chrome.childBadge.color) || undefined },
|
|
2828
|
+
}, chrome.childBadge.text)
|
|
2829
|
+
: null,
|
|
2830
|
+
chrome.ctx
|
|
2831
|
+
? React.createElement("span", {
|
|
2832
|
+
className: "ps-rich-session-ctx",
|
|
2833
|
+
style: { color: resolveColor(theme, chrome.ctx.color) || undefined },
|
|
2834
|
+
}, chrome.ctx.text)
|
|
2835
|
+
: null),
|
|
2836
|
+
// The detail line is ALWAYS rendered, selected or not. Swapping row
|
|
2837
|
+
// content on selection made the whole list jump; now the only things
|
|
2838
|
+
// that reflow are expand/collapse and the highlight moving.
|
|
2839
|
+
detailRuns.length > 0
|
|
2840
|
+
? React.createElement("div", { className: "ps-rich-session-detail" },
|
|
2841
|
+
React.createElement(Runs, { runs: detailRuns, theme }))
|
|
2842
|
+
: null);
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2059
2845
|
function SessionRowContent({ row, theme, structured = false }) {
|
|
2060
2846
|
const hasStructuredRuns = structured && Array.isArray(row.titleRuns);
|
|
2061
2847
|
if (!hasStructuredRuns) {
|
|
@@ -2088,6 +2874,36 @@ function SessionRowContent({ row, theme, structured = false }) {
|
|
|
2088
2874
|
: null);
|
|
2089
2875
|
}
|
|
2090
2876
|
|
|
2877
|
+
/**
|
|
2878
|
+
* Reuse the previous value when a freshly computed one is deep-equal.
|
|
2879
|
+
*
|
|
2880
|
+
* The session poll rebuilds sessions.byId whenever ANY session's updatedAt or
|
|
2881
|
+
* iteration count moves, which invalidates every downstream memo and re-renders
|
|
2882
|
+
* the whole list — visibly, every poll — even though the rows that are actually
|
|
2883
|
+
* displayed did not change. Comparing the rendered result and keeping the old
|
|
2884
|
+
* reference stops the churn at the render boundary, without having to chase
|
|
2885
|
+
* which upstream field ticked.
|
|
2886
|
+
*
|
|
2887
|
+
* Sized for lists of tens of rows: the comparison is far cheaper than the
|
|
2888
|
+
* reconciliation it avoids.
|
|
2889
|
+
*/
|
|
2890
|
+
function useStableValue(value) {
|
|
2891
|
+
const ref = React.useRef(value);
|
|
2892
|
+
const previousJson = React.useRef(null);
|
|
2893
|
+
const nextJson = React.useMemo(() => {
|
|
2894
|
+
try {
|
|
2895
|
+
return JSON.stringify(value);
|
|
2896
|
+
} catch {
|
|
2897
|
+
return null; // unserializable: never claim equality
|
|
2898
|
+
}
|
|
2899
|
+
}, [value]);
|
|
2900
|
+
if (nextJson === null || previousJson.current !== nextJson) {
|
|
2901
|
+
previousJson.current = nextJson;
|
|
2902
|
+
ref.current = value;
|
|
2903
|
+
}
|
|
2904
|
+
return ref.current;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2091
2907
|
function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false }) {
|
|
2092
2908
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
2093
2909
|
const theme = getTheme(themeId);
|
|
@@ -2105,8 +2921,13 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
2105
2921
|
connectionMode: state.connection?.mode || "local",
|
|
2106
2922
|
modalOpen: Boolean(state.ui.modal),
|
|
2107
2923
|
focused: state.ui.focusRegion === "sessions",
|
|
2924
|
+
// The rich chat view also restyles the session list, so the portal
|
|
2925
|
+
// reads as one product rather than half terminal / half desktop.
|
|
2926
|
+
// TODO: promote this to its own `ui.richUi` setting if the two are
|
|
2927
|
+
// ever wanted independently.
|
|
2928
|
+
rich: state.ui.chatViewMode === "rich",
|
|
2108
2929
|
}), shallowEqualObject);
|
|
2109
|
-
const
|
|
2930
|
+
const computedRows = React.useMemo(() => selectSessionRows({
|
|
2110
2931
|
sessions: {
|
|
2111
2932
|
activeSessionId: viewState.activeSessionId,
|
|
2112
2933
|
byId: viewState.sessionsById,
|
|
@@ -2122,6 +2943,8 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
2122
2943
|
mode: viewState.connectionMode,
|
|
2123
2944
|
},
|
|
2124
2945
|
}), [viewState.activeSessionId, viewState.auth, viewState.connectionMode, viewState.filterQuery, viewState.ownerFilter, viewState.pinnedIds, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
|
|
2946
|
+
// Hold the previous rows when a poll produced identical output.
|
|
2947
|
+
const rows = useStableValue(computedRows);
|
|
2125
2948
|
const activeSession = viewState.activeSessionId
|
|
2126
2949
|
? viewState.sessionsById[viewState.activeSessionId] || null
|
|
2127
2950
|
: null;
|
|
@@ -2207,7 +3030,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
2207
3030
|
})
|
|
2208
3031
|
: (activeSession && !activeSession.isSystem && !activeSession.isGroup && !activeSession.parentSessionId ? [activeSession.sessionId] : []);
|
|
2209
3032
|
const canMoveToGroup = groupableIds.length > 0;
|
|
2210
|
-
const combinedPanelClassName = `ps-session-pane${panelClassName ? ` ${panelClassName}` : ""}`;
|
|
3033
|
+
const combinedPanelClassName = `ps-session-pane${viewState.rich ? " is-rich" : ""}${panelClassName ? ` ${panelClassName}` : ""}`;
|
|
2211
3034
|
const setSessionButtonRef = React.useCallback((sessionId, node) => {
|
|
2212
3035
|
if (!sessionId) return;
|
|
2213
3036
|
if (node) {
|
|
@@ -2376,10 +3199,16 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
2376
3199
|
},
|
|
2377
3200
|
},
|
|
2378
3201
|
React.createElement("div", {
|
|
2379
|
-
className: "ps-line ps-session-row-content",
|
|
2380
|
-
style: {
|
|
3202
|
+
className: viewState.rich ? "ps-session-row-content is-rich" : "ps-line ps-session-row-content",
|
|
3203
|
+
style: {
|
|
3204
|
+
paddingInlineStart: viewState.rich
|
|
3205
|
+
? `${Math.max(0, row.depth) * 14}px`
|
|
3206
|
+
: `${Math.max(0, row.depth) * 4}px`,
|
|
3207
|
+
},
|
|
2381
3208
|
},
|
|
2382
|
-
|
|
3209
|
+
viewState.rich
|
|
3210
|
+
? React.createElement(RichSessionRow, { row, theme })
|
|
3211
|
+
: React.createElement(SessionRowContent, { row, theme, structured: structuredRows })),
|
|
2383
3212
|
)),
|
|
2384
3213
|
)),
|
|
2385
3214
|
(manageOpen && activeSession && !activeSession.isGroup)
|
|
@@ -2422,7 +3251,7 @@ function SessionLinkModal({ url, warn, onCopyAgain, onClose }) {
|
|
|
2422
3251
|
React.createElement("div", { className: "ps-link-modal", onClick: stop },
|
|
2423
3252
|
React.createElement("div", { className: "ps-share-modal-head" },
|
|
2424
3253
|
React.createElement("span", null, "Link copied"),
|
|
2425
|
-
React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
|
|
3254
|
+
React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
|
|
2426
3255
|
React.createElement("div", { className: "ps-share-section-sub" },
|
|
2427
3256
|
"Copied to your clipboard — anyone with access can open the session from this link."),
|
|
2428
3257
|
React.createElement("div", { className: "ps-link-row" },
|
|
@@ -2659,7 +3488,7 @@ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel,
|
|
|
2659
3488
|
React.createElement("div", { className: "ps-share-modal", onClick: stop },
|
|
2660
3489
|
React.createElement("div", { className: "ps-share-modal-head" },
|
|
2661
3490
|
React.createElement("span", null, "Manage session"),
|
|
2662
|
-
React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
|
|
3491
|
+
React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
|
|
2663
3492
|
|
|
2664
3493
|
// ── Tab bar ───────────────────────────────────────────────
|
|
2665
3494
|
React.createElement("div", { className: "ps-manage-tabs", role: "tablist" },
|
|
@@ -2831,10 +3660,24 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
|
|
|
2831
3660
|
() => appendAnimatedDotsToRuns(chrome.titleRight, chrome.animateTitleRight ? animatedDots : ""),
|
|
2832
3661
|
[animatedDots, chrome.animateTitleRight, chrome.titleRight],
|
|
2833
3662
|
);
|
|
2834
|
-
const
|
|
2835
|
-
|
|
2836
|
-
|
|
3663
|
+
const richMode = viewState.chatViewMode === "rich" && !viewState.activeSessionIsGroup;
|
|
3664
|
+
const richBlocks = React.useMemo(
|
|
3665
|
+
() => (richMode ? selectChatBlocks(selectorState, viewState.contentWidth, { tableMode: "sentinel" }) : null),
|
|
3666
|
+
[richMode, selectorState, viewState.contentWidth],
|
|
2837
3667
|
);
|
|
3668
|
+
const lines = React.useMemo(() => {
|
|
3669
|
+
if (richBlocks) {
|
|
3670
|
+
// Rich mode paints from `richBlocks` via renderBody; these
|
|
3671
|
+
// pseudo-lines exist only as the scroll-sync change signal
|
|
3672
|
+
// (autoscroll-to-bottom fires when a block grows or arrives).
|
|
3673
|
+
return richBlocks.map((block, index) => ({
|
|
3674
|
+
text: block.kind === "message"
|
|
3675
|
+
? `m:${block.id ?? index}:${(block.text || "").length}:${block.pendingPhase || ""}`
|
|
3676
|
+
: `l:${index}:${(block.lines || []).length}`,
|
|
3677
|
+
}));
|
|
3678
|
+
}
|
|
3679
|
+
return selectChatLines(selectorState, viewState.contentWidth, { tableMode: "sentinel" });
|
|
3680
|
+
}, [richBlocks, selectorState, viewState.contentWidth]);
|
|
2838
3681
|
const spinnerFrame = useSpinnerFrame(viewState.activeSessionStatus === "running");
|
|
2839
3682
|
const liveActivityLines = React.useMemo(
|
|
2840
3683
|
() => selectLiveActivityLines(selectorState, { spinnerFrame, maxWidth: viewState.contentWidth }),
|
|
@@ -2903,7 +3746,10 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
|
|
|
2903
3746
|
|
|
2904
3747
|
return React.createElement(ScrollLinesPanel, {
|
|
2905
3748
|
controller,
|
|
2906
|
-
title:
|
|
3749
|
+
title: (() => {
|
|
3750
|
+
const baseTitle = mobile ? compactTitleRuns(chrome.title, 28) : chrome.title;
|
|
3751
|
+
return richMode ? unbracketTitleRuns(baseTitle) : baseTitle;
|
|
3752
|
+
})(),
|
|
2907
3753
|
titleRight: mobile && titleRight ? compactTitleRuns(titleRight, 18) : titleRight,
|
|
2908
3754
|
// Chat/Summary toggling lives on the top toolbar so the pane chrome
|
|
2909
3755
|
// stays clean. The toolbar button is disabled for group sessions.
|
|
@@ -2915,13 +3761,93 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
|
|
|
2915
3761
|
scrollOffset: viewState.scroll,
|
|
2916
3762
|
scrollMode: "bottom",
|
|
2917
3763
|
paneKey: "chat",
|
|
2918
|
-
className: "is-wrapped",
|
|
2919
|
-
panelClassName: "ps-chat-panel",
|
|
3764
|
+
className: richMode ? "is-wrapped is-rich" : "is-wrapped",
|
|
3765
|
+
panelClassName: richMode ? "ps-chat-panel is-rich" : "ps-chat-panel",
|
|
2920
3766
|
bottomContent: composer,
|
|
2921
3767
|
structuredBlocks: true,
|
|
3768
|
+
renderBody: richBlocks
|
|
3769
|
+
? (_bodyLines, bodyTheme) => React.createElement(RichChatBlocks, {
|
|
3770
|
+
blocks: richBlocks,
|
|
3771
|
+
theme: bodyTheme,
|
|
3772
|
+
controller,
|
|
3773
|
+
})
|
|
3774
|
+
: null,
|
|
2922
3775
|
});
|
|
2923
3776
|
}
|
|
2924
3777
|
|
|
3778
|
+
/**
|
|
3779
|
+
* Mobile artifact viewer — a genuine full-viewport overlay, not a pane.
|
|
3780
|
+
*
|
|
3781
|
+
* Rendered OUTSIDE the workspace and fixed to the viewport, so the portal
|
|
3782
|
+
* header, session list and composer are all covered: opening an artifact on a
|
|
3783
|
+
* phone should feel like pushing a detail screen, the way a native app does,
|
|
3784
|
+
* not like shrinking content into one more box. Desktop never mounts this.
|
|
3785
|
+
*/
|
|
3786
|
+
function MobileArtifactOverlay({ controller }) {
|
|
3787
|
+
const view = useControllerSelector(controller, (state) => {
|
|
3788
|
+
const id = state.files.selectedArtifactId || "";
|
|
3789
|
+
const filename = id ? id.slice(id.indexOf("/") + 1) : "";
|
|
3790
|
+
return {
|
|
3791
|
+
filename,
|
|
3792
|
+
origin: state.files.previewOrigin || null,
|
|
3793
|
+
canPrev: controller.canStepArtifactPreview(-1),
|
|
3794
|
+
canNext: controller.canStepArtifactPreview(1),
|
|
3795
|
+
};
|
|
3796
|
+
}, shallowEqualObject);
|
|
3797
|
+
|
|
3798
|
+
// Wrap toggle applies to CODE only. Markdown always wraps — prose in a
|
|
3799
|
+
// horizontal scroller is unreadable — and its fenced blocks keep their own
|
|
3800
|
+
// behavior. Images have nothing to wrap.
|
|
3801
|
+
const isCode = Boolean(codeLanguageForArtifact(view.filename))
|
|
3802
|
+
|| isDiffArtifact(view.filename);
|
|
3803
|
+
const [contentWidth, setContentWidth] = React.useState(false);
|
|
3804
|
+
React.useEffect(() => { setContentWidth(false); }, [view.filename]);
|
|
3805
|
+
|
|
3806
|
+
const step = React.useCallback((delta) => {
|
|
3807
|
+
controller.stepArtifactPreview(delta).catch(() => {});
|
|
3808
|
+
}, [controller]);
|
|
3809
|
+
|
|
3810
|
+
return React.createElement("div", { className: "ps-artifact-overlay" },
|
|
3811
|
+
React.createElement("header", { className: "ps-artifact-overlay-bar" },
|
|
3812
|
+
React.createElement("button", {
|
|
3813
|
+
type: "button",
|
|
3814
|
+
className: "ps-artifact-overlay-btn",
|
|
3815
|
+
onClick: () => controller.closeArtifactPreview().catch(() => {}),
|
|
3816
|
+
"aria-label": view.origin === "chat" ? "Close, back to chat" : "Close, back to artifacts",
|
|
3817
|
+
}, "✕"),
|
|
3818
|
+
React.createElement("span", { className: "ps-artifact-overlay-title" }, view.filename || "Artifact"),
|
|
3819
|
+
isCode
|
|
3820
|
+
? React.createElement("button", {
|
|
3821
|
+
type: "button",
|
|
3822
|
+
className: `ps-artifact-overlay-btn${contentWidth ? " is-active" : ""}`,
|
|
3823
|
+
onClick: () => setContentWidth((v) => !v),
|
|
3824
|
+
"aria-label": contentWidth ? "Wrap lines" : "Show full lines",
|
|
3825
|
+
title: contentWidth ? "Wrap to screen width" : "Full line width (scroll horizontally)",
|
|
3826
|
+
}, contentWidth ? "⇥" : "↔")
|
|
3827
|
+
: null,
|
|
3828
|
+
React.createElement("button", {
|
|
3829
|
+
type: "button",
|
|
3830
|
+
className: "ps-artifact-overlay-btn",
|
|
3831
|
+
disabled: !view.canPrev,
|
|
3832
|
+
onClick: () => step(-1),
|
|
3833
|
+
"aria-label": "Previous artifact",
|
|
3834
|
+
}, "‹"),
|
|
3835
|
+
React.createElement("button", {
|
|
3836
|
+
type: "button",
|
|
3837
|
+
className: "ps-artifact-overlay-btn",
|
|
3838
|
+
disabled: !view.canNext,
|
|
3839
|
+
onClick: () => step(1),
|
|
3840
|
+
"aria-label": "Next artifact",
|
|
3841
|
+
}, "›")),
|
|
3842
|
+
// No swipe handlers: horizontal drag belongs to the content, so a wide
|
|
3843
|
+
// code line can be scrolled without also changing which file you are on.
|
|
3844
|
+
React.createElement("div", {
|
|
3845
|
+
className: `ps-artifact-overlay-body${contentWidth ? " is-content-width" : ""}`,
|
|
3846
|
+
}, React.createElement(FilesPane, {
|
|
3847
|
+
controller, focused: true, mobile: true, previewOnly: true, nativeScroll: true,
|
|
3848
|
+
})));
|
|
3849
|
+
}
|
|
3850
|
+
|
|
2925
3851
|
function MobileWorkspace({ controller }) {
|
|
2926
3852
|
// The session list is always shown; use the toolbar Focus button to give
|
|
2927
3853
|
// the chat the full screen (the old Show/Hide toggle was redundant with it).
|
|
@@ -2953,7 +3879,11 @@ function InspectorTabs({ activeTab, controller }) {
|
|
|
2953
3879
|
})));
|
|
2954
3880
|
}
|
|
2955
3881
|
|
|
2956
|
-
|
|
3882
|
+
// `previewOnly` renders JUST the artifact preview panel, so the desktop layout
|
|
3883
|
+
// can host it in the activity slot where it gets the existing row resizer.
|
|
3884
|
+
// Detached this way the preview is resizable; nested inside the inspector it
|
|
3885
|
+
// could only ever have half of a pane.
|
|
3886
|
+
function FilesPane({ controller, focused, mobile = false, previewOnly = false, nativeScroll = false }) {
|
|
2957
3887
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
2958
3888
|
const theme = getTheme(themeId);
|
|
2959
3889
|
const fileInputRef = React.useRef(null);
|
|
@@ -2970,6 +3900,8 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
2970
3900
|
filesBySessionId: state.files.bySessionId,
|
|
2971
3901
|
filesFilter: state.files.filter,
|
|
2972
3902
|
selectedArtifactId: state.files.selectedArtifactId,
|
|
3903
|
+
markedIds: state.files.markedIds || [],
|
|
3904
|
+
previewOrigin: state.files.previewOrigin || null,
|
|
2973
3905
|
focused,
|
|
2974
3906
|
previewScroll: state.ui.scroll.filePreview,
|
|
2975
3907
|
fullscreen: Boolean(state.files.fullscreen),
|
|
@@ -3031,7 +3963,35 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3031
3963
|
}
|
|
3032
3964
|
}, [controller, viewState.canBrowserUpload, viewState.canPathUpload]);
|
|
3033
3965
|
|
|
3966
|
+
// Declared BEFORE panelActions, which reads it. When this sat further down
|
|
3967
|
+
// the component it was a temporal dead zone violation: panelActions threw
|
|
3968
|
+
// "Cannot access 'markedSet' before initialization" during render, React
|
|
3969
|
+
// unmounted the whole tree, and the portal went blank on opening Artifacts.
|
|
3970
|
+
const markedSet = React.useMemo(() => new Set(viewState.markedIds || []), [viewState.markedIds]);
|
|
3971
|
+
|
|
3034
3972
|
const panelActions = React.createElement(React.Fragment, null,
|
|
3973
|
+
// Bulk actions only appear once something is marked, so the header
|
|
3974
|
+
// stays quiet in the common single-selection case.
|
|
3975
|
+
markedSet.size > 0
|
|
3976
|
+
? React.createElement(React.Fragment, null,
|
|
3977
|
+
React.createElement("span", {
|
|
3978
|
+
className: "ps-mini-button-label",
|
|
3979
|
+
style: { padding: "0 6px", opacity: 0.85 },
|
|
3980
|
+
title: "Cmd/Ctrl-click to mark, Shift-click for a range",
|
|
3981
|
+
}, `${markedSet.size} marked`),
|
|
3982
|
+
viewState.canDeleteArtifacts
|
|
3983
|
+
? React.createElement(IconButton, {
|
|
3984
|
+
icon: "🗑",
|
|
3985
|
+
label: `Delete ${markedSet.size} marked`,
|
|
3986
|
+
onClick: () => controller.deleteMarkedArtifacts().catch(() => {}),
|
|
3987
|
+
})
|
|
3988
|
+
: null,
|
|
3989
|
+
React.createElement(IconButton, {
|
|
3990
|
+
icon: "✕",
|
|
3991
|
+
label: "Clear marks",
|
|
3992
|
+
onClick: () => controller.clearArtifactMarks(),
|
|
3993
|
+
}))
|
|
3994
|
+
: null,
|
|
3035
3995
|
React.createElement("input", {
|
|
3036
3996
|
ref: fileInputRef,
|
|
3037
3997
|
type: "file",
|
|
@@ -3073,12 +4033,26 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3073
4033
|
label: "Filter",
|
|
3074
4034
|
onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {}),
|
|
3075
4035
|
}),
|
|
4036
|
+
// Desktop keeps the plain fullscreen toggle it has always had. The
|
|
4037
|
+
// back affordance belongs to the mobile overlay, which renders its own
|
|
4038
|
+
// top bar — putting it here too would change desktop behavior.
|
|
3076
4039
|
React.createElement(IconButton, {
|
|
3077
4040
|
icon: viewState.fullscreen ? "⇱" : "⛶",
|
|
3078
4041
|
label: viewState.fullscreen ? "Exit fullscreen" : "Fullscreen",
|
|
3079
4042
|
onClick: () => controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {}),
|
|
3080
4043
|
}));
|
|
3081
4044
|
|
|
4045
|
+
// Keyboard selection has to drag the viewport with it, exactly as the
|
|
4046
|
+
// session list does — otherwise j/k walks the selection straight out of
|
|
4047
|
+
// view and the list appears frozen.
|
|
4048
|
+
const selectedItemRef = React.useRef(null);
|
|
4049
|
+
React.useEffect(() => {
|
|
4050
|
+
const node = selectedItemRef.current;
|
|
4051
|
+
if (node && typeof node.scrollIntoView === "function") {
|
|
4052
|
+
node.scrollIntoView({ block: "nearest" });
|
|
4053
|
+
}
|
|
4054
|
+
}, [filesView.selectedIndex, viewState.selectedArtifactId]);
|
|
4055
|
+
|
|
3082
4056
|
const listContent = items.length === 0
|
|
3083
4057
|
? normalizeLines(filesView.listBodyLines || []).map((line, index) => React.createElement(Line, {
|
|
3084
4058
|
key: `empty:${index}`,
|
|
@@ -3088,34 +4062,114 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3088
4062
|
: items.map((item, index) => React.createElement("button", {
|
|
3089
4063
|
key: item.id,
|
|
3090
4064
|
type: "button",
|
|
3091
|
-
|
|
3092
|
-
|
|
4065
|
+
ref: index === filesView.selectedIndex ? selectedItemRef : null,
|
|
4066
|
+
className: `ps-list-button${index === filesView.selectedIndex ? " is-selected" : ""}`
|
|
4067
|
+
+ (markedSet.has(item.id) ? " is-marked" : ""),
|
|
4068
|
+
onClick: (event) => {
|
|
3093
4069
|
controller.setFocus("inspector");
|
|
3094
|
-
|
|
4070
|
+
// Cmd/Ctrl toggles one, Shift extends a run, plain click
|
|
4071
|
+
// selects and drops the marks — the usual list conventions.
|
|
4072
|
+
if (event.metaKey || event.ctrlKey) {
|
|
4073
|
+
controller.toggleArtifactMark(item);
|
|
4074
|
+
return;
|
|
4075
|
+
}
|
|
4076
|
+
if (event.shiftKey) {
|
|
4077
|
+
controller.markArtifactRange(item);
|
|
4078
|
+
return;
|
|
4079
|
+
}
|
|
4080
|
+
controller.clearArtifactMarks();
|
|
4081
|
+
controller.selectFileBrowserItem(item)
|
|
4082
|
+
.then(() => { if (mobile) controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {}); })
|
|
4083
|
+
.catch(() => {});
|
|
3095
4084
|
},
|
|
3096
4085
|
}, React.createElement(Line, {
|
|
3097
4086
|
line: normalizeLines([filesView.listBodyLines?.[index]])[0],
|
|
3098
4087
|
theme,
|
|
3099
4088
|
})));
|
|
3100
4089
|
|
|
3101
|
-
|
|
4090
|
+
// A .patch/.diff artifact is rendered AS a diff — gutter markers, add/remove
|
|
4091
|
+
// tinting — rather than as undifferentiated grey text. This is the whole
|
|
4092
|
+
// point of clicking through from the transcript.
|
|
4093
|
+
const previewReady = !filesView.previewLoading && !filesView.previewError;
|
|
4094
|
+
const previewIsDiff = previewReady
|
|
4095
|
+
&& isDiffArtifact(filesView.selectedFilename, filesView.previewContentType)
|
|
4096
|
+
&& !filesView.previewIsBinary
|
|
4097
|
+
&& Boolean(filesView.previewContent);
|
|
4098
|
+
|
|
4099
|
+
// Images are routed by content type OR extension, and go to the binary
|
|
4100
|
+
// panel even when the payload is text (SVG) — it owns the authenticated
|
|
4101
|
+
// fetch → object URL path that renders an artifact safely.
|
|
4102
|
+
const previewIsImage = previewReady
|
|
4103
|
+
&& (IMAGE_CONTENT_TYPE_RE.test(String(filesView.previewContentType || ""))
|
|
4104
|
+
|| IMAGE_FILENAME_RE.test(String(filesView.selectedFilename || "")));
|
|
4105
|
+
|
|
4106
|
+
// Source files get real syntax highlighting instead of undifferentiated
|
|
4107
|
+
// grey text — the same highlighter the chat code blocks use.
|
|
4108
|
+
const previewCodeLanguage = previewReady
|
|
4109
|
+
&& !filesView.previewIsBinary
|
|
4110
|
+
&& !previewIsDiff
|
|
4111
|
+
&& filesView.previewRenderMode !== "markdown"
|
|
4112
|
+
&& Boolean(filesView.previewContent)
|
|
4113
|
+
? codeLanguageForArtifact(filesView.selectedFilename)
|
|
4114
|
+
: null;
|
|
4115
|
+
|
|
4116
|
+
// Detached (desktop) => the preview is its own pane in the activity slot and
|
|
4117
|
+
// claims activity focus, so arrows/j/k/PgUp scroll IT. Inline (mobile) it is
|
|
4118
|
+
// part of the inspector and keeps inspector focus.
|
|
4119
|
+
const previewFocusRegion = previewOnly ? "activity" : null;
|
|
4120
|
+
// In the mobile overlay the browser owns scrolling outright.
|
|
4121
|
+
const previewPaneKey = nativeScroll ? null : "filePreview";
|
|
4122
|
+
|
|
4123
|
+
const previewPane = previewIsImage
|
|
4124
|
+
? React.createElement(BinaryArtifactPreviewPanel, {
|
|
4125
|
+
title: null,
|
|
4126
|
+
color: "cyan",
|
|
4127
|
+
focused: false,
|
|
4128
|
+
theme,
|
|
4129
|
+
filename: filesView.selectedFilename,
|
|
4130
|
+
contentType: filesView.previewContentType,
|
|
4131
|
+
sizeBytes: filesView.previewSizeBytes,
|
|
4132
|
+
source: filesView.previewSource,
|
|
4133
|
+
uploadedAt: filesView.previewUploadedAt,
|
|
4134
|
+
controller,
|
|
4135
|
+
sessionId: filesView.selectedSessionId,
|
|
4136
|
+
})
|
|
4137
|
+
: previewCodeLanguage
|
|
4138
|
+
? React.createElement(RenderedPreviewPanel, {
|
|
4139
|
+
controller, title: null, color: "cyan", focused: false, theme,
|
|
4140
|
+
paneKey: previewPaneKey, scrollOffset: viewState.previewScroll,
|
|
4141
|
+
className: "ps-diff-preview", focusRegion: previewFocusRegion,
|
|
4142
|
+
},
|
|
4143
|
+
React.createElement("pre", { className: "ps-md-code-pre" },
|
|
4144
|
+
React.createElement("code", null,
|
|
4145
|
+
renderHighlightedCode(filesView.previewContent, previewCodeLanguage, theme))))
|
|
4146
|
+
: previewIsDiff
|
|
4147
|
+
? React.createElement(RenderedPreviewPanel, {
|
|
4148
|
+
controller, title: null, color: "cyan", focused: false, theme,
|
|
4149
|
+
paneKey: previewPaneKey, scrollOffset: viewState.previewScroll,
|
|
4150
|
+
className: "ps-diff-preview", focusRegion: previewFocusRegion,
|
|
4151
|
+
},
|
|
4152
|
+
React.createElement("pre", { className: "ps-md-code-pre is-diff" },
|
|
4153
|
+
React.createElement("code", null, renderDiffCode(filesView.previewContent, theme))))
|
|
4154
|
+
: filesView.previewRenderMode === "markdown"
|
|
3102
4155
|
&& !filesView.previewLoading
|
|
3103
4156
|
&& !filesView.previewError
|
|
3104
4157
|
? React.createElement(MarkdownPreviewPanel, {
|
|
3105
4158
|
controller,
|
|
3106
|
-
title:
|
|
4159
|
+
title: null,
|
|
3107
4160
|
color: "cyan",
|
|
3108
4161
|
focused: false,
|
|
3109
4162
|
scrollOffset: viewState.previewScroll,
|
|
3110
|
-
paneKey:
|
|
4163
|
+
paneKey: previewPaneKey,
|
|
3111
4164
|
theme,
|
|
3112
|
-
|
|
4165
|
+
focusRegion: previewFocusRegion,
|
|
4166
|
+
content: stripYamlFrontmatter(filesView.previewContent || ""),
|
|
3113
4167
|
})
|
|
3114
4168
|
: filesView.previewIsBinary
|
|
3115
4169
|
&& !filesView.previewLoading
|
|
3116
4170
|
&& !filesView.previewError
|
|
3117
4171
|
? React.createElement(BinaryArtifactPreviewPanel, {
|
|
3118
|
-
title:
|
|
4172
|
+
title: null,
|
|
3119
4173
|
color: "cyan",
|
|
3120
4174
|
focused: false,
|
|
3121
4175
|
theme,
|
|
@@ -3129,17 +4183,20 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3129
4183
|
})
|
|
3130
4184
|
: React.createElement(ScrollLinesPanel, {
|
|
3131
4185
|
controller,
|
|
3132
|
-
title:
|
|
4186
|
+
title: null,
|
|
3133
4187
|
color: "cyan",
|
|
3134
4188
|
focused: false,
|
|
3135
4189
|
lines: filesView.previewLines,
|
|
3136
4190
|
scrollOffset: viewState.previewScroll,
|
|
3137
4191
|
scrollMode: "top",
|
|
3138
|
-
paneKey:
|
|
4192
|
+
paneKey: previewPaneKey,
|
|
4193
|
+
focusRegion: previewFocusRegion,
|
|
3139
4194
|
className: "is-preview is-wrapped",
|
|
3140
4195
|
});
|
|
3141
4196
|
const view = viewState;
|
|
3142
4197
|
|
|
4198
|
+
if (previewOnly) return previewPane;
|
|
4199
|
+
|
|
3143
4200
|
return React.createElement(Panel, {
|
|
3144
4201
|
title: view.fullscreen
|
|
3145
4202
|
? filesView.fullscreenTitle
|
|
@@ -3155,10 +4212,16 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3155
4212
|
// Fullscreen files mode shows only the preview pane; the list stays hidden.
|
|
3156
4213
|
view.fullscreen
|
|
3157
4214
|
? previewPane
|
|
3158
|
-
: React.createElement("div", {
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
4215
|
+
: React.createElement("div", {
|
|
4216
|
+
// The pane is a LIST, full stop. Desktop detaches the preview into
|
|
4217
|
+
// the activity slot; mobile opens the full-screen overlay on tap.
|
|
4218
|
+
// Neither wants a cramped preview stacked under the list.
|
|
4219
|
+
className: "ps-files-grid is-list-only",
|
|
4220
|
+
},
|
|
4221
|
+
// No nested panel: the enclosing Files pane is already the box, and
|
|
4222
|
+
// a second border + header inside it only ate vertical space to
|
|
4223
|
+
// restate what the pane title says.
|
|
4224
|
+
React.createElement("div", { className: "ps-action-list ps-files-list" }, listContent),
|
|
3162
4225
|
));
|
|
3163
4226
|
}
|
|
3164
4227
|
|
|
@@ -3325,7 +4388,7 @@ function InspectorPane({ controller, mobile = false, panelClassName = "", extraA
|
|
|
3325
4388
|
stickyBottom: inspector.activeTab === "logs",
|
|
3326
4389
|
paneKey: "inspector",
|
|
3327
4390
|
className: inspector.activeTab === "history" || inspector.activeTab === "logs" ? "is-wrapped" : "is-preserve",
|
|
3328
|
-
panelClassName:
|
|
4391
|
+
panelClassName: `ps-inspector-pane${inspector.activeTab === "sequence" ? " has-preserved-sticky" : ""}${panelClassName ? ` ${panelClassName}` : ""}`.trim(),
|
|
3329
4392
|
});
|
|
3330
4393
|
}
|
|
3331
4394
|
|
|
@@ -3374,7 +4437,9 @@ function ActivityPane({ controller, panelClassName = "", extraActions = null })
|
|
|
3374
4437
|
stickyBottom: true,
|
|
3375
4438
|
paneKey: "activity",
|
|
3376
4439
|
className: "is-wrapped",
|
|
3377
|
-
|
|
4440
|
+
// Stable identity class so styling can target the activity surface
|
|
4441
|
+
// without depending on which slot rendered it.
|
|
4442
|
+
panelClassName: `ps-activity-pane${panelClassName ? ` ${panelClassName}` : ""}`,
|
|
3378
4443
|
});
|
|
3379
4444
|
}
|
|
3380
4445
|
|
|
@@ -3395,9 +4460,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
|
|
|
3395
4460
|
structuredRows: mobile,
|
|
3396
4461
|
actions: React.createElement("button", {
|
|
3397
4462
|
type: "button",
|
|
3398
|
-
className: "ps-mini-button",
|
|
4463
|
+
className: "ps-mini-button ps-overlay-close",
|
|
3399
4464
|
onClick: onClose,
|
|
3400
|
-
|
|
4465
|
+
"aria-label": "Close",
|
|
4466
|
+
title: "Close",
|
|
4467
|
+
}, "✕"),
|
|
3401
4468
|
});
|
|
3402
4469
|
} else if (pane === "inspector") {
|
|
3403
4470
|
content = React.createElement(InspectorPane, {
|
|
@@ -3406,9 +4473,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
|
|
|
3406
4473
|
panelClassName: "ps-chat-focus-pane",
|
|
3407
4474
|
extraActions: React.createElement("button", {
|
|
3408
4475
|
type: "button",
|
|
3409
|
-
className: "ps-mini-button",
|
|
4476
|
+
className: "ps-mini-button ps-overlay-close",
|
|
3410
4477
|
onClick: onClose,
|
|
3411
|
-
|
|
4478
|
+
"aria-label": "Close",
|
|
4479
|
+
title: "Close",
|
|
4480
|
+
}, "✕"),
|
|
3412
4481
|
});
|
|
3413
4482
|
} else if (pane === "activity") {
|
|
3414
4483
|
content = React.createElement(ActivityPane, {
|
|
@@ -3416,9 +4485,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
|
|
|
3416
4485
|
panelClassName: "ps-chat-focus-pane",
|
|
3417
4486
|
extraActions: React.createElement("button", {
|
|
3418
4487
|
type: "button",
|
|
3419
|
-
className: "ps-mini-button",
|
|
4488
|
+
className: "ps-mini-button ps-overlay-close",
|
|
3420
4489
|
onClick: onClose,
|
|
3421
|
-
|
|
4490
|
+
"aria-label": "Close",
|
|
4491
|
+
title: "Close",
|
|
4492
|
+
}, "✕"),
|
|
3422
4493
|
});
|
|
3423
4494
|
}
|
|
3424
4495
|
|
|
@@ -3646,7 +4717,19 @@ function PromptComposer({ controller, mobile, active = true, onAfterSend = null
|
|
|
3646
4717
|
}, "✕"));
|
|
3647
4718
|
}))
|
|
3648
4719
|
: null,
|
|
3649
|
-
|
|
4720
|
+
// The label carries real mode signal (answer / queued / pending /
|
|
4721
|
+
// cancelling); only the idle "you" is a terminal-prompt affordance,
|
|
4722
|
+
// so it is tagged for the rich view to hide.
|
|
4723
|
+
(() => {
|
|
4724
|
+
const promptLabelText = promptState.answerMode ? "answer"
|
|
4725
|
+
: selectedCancelling ? "cancelling"
|
|
4726
|
+
: selectedQueued ? "queued"
|
|
4727
|
+
: promptState.editingPending ? "pending"
|
|
4728
|
+
: "you";
|
|
4729
|
+
return React.createElement("label", {
|
|
4730
|
+
className: `ps-prompt-label${promptLabelText === "you" ? " is-default" : ""}`,
|
|
4731
|
+
}, promptLabelText);
|
|
4732
|
+
})(),
|
|
3650
4733
|
React.createElement("textarea", {
|
|
3651
4734
|
ref: inputRef,
|
|
3652
4735
|
className: "ps-prompt-input",
|
|
@@ -3933,6 +5016,12 @@ function IconButton({ icon, label, onClick, disabled = false, active = false, cl
|
|
|
3933
5016
|
}
|
|
3934
5017
|
|
|
3935
5018
|
function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus = null, chatFocusDisabled = false }) {
|
|
5019
|
+
const richUi = useControllerSelector(controller, (state) => state.ui.chatViewMode === "rich");
|
|
5020
|
+
const [headerSlot, setHeaderSlot] = React.useState(null);
|
|
5021
|
+
React.useEffect(() => {
|
|
5022
|
+
if (typeof document === "undefined") return;
|
|
5023
|
+
setHeaderSlot(richUi && !mobile ? document.getElementById("ps-header-toolbar-slot") : null);
|
|
5024
|
+
}, [richUi, mobile]);
|
|
3936
5025
|
const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
|
|
3937
5026
|
const chatView = useControllerSelector(controller, (state) => ({
|
|
3938
5027
|
mode: state.ui.chatViewMode || "transcript",
|
|
@@ -3966,6 +5055,16 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
|
|
|
3966
5055
|
label: "Theme",
|
|
3967
5056
|
onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_THEME_PICKER).catch(() => {}),
|
|
3968
5057
|
},
|
|
5058
|
+
{
|
|
5059
|
+
key: "rich",
|
|
5060
|
+
icon: "Aa",
|
|
5061
|
+
label: chatView.activeSessionIsGroup
|
|
5062
|
+
? "Groups show group details"
|
|
5063
|
+
: (chatView.mode === "rich" ? "Show terminal transcript" : "Rich chat view (experimental)"),
|
|
5064
|
+
onClick: () => controller.setChatViewMode(chatView.mode === "rich" ? "transcript" : "rich"),
|
|
5065
|
+
disabled: chatView.activeSessionIsGroup,
|
|
5066
|
+
active: chatView.mode === "rich",
|
|
5067
|
+
},
|
|
3969
5068
|
{
|
|
3970
5069
|
key: "summary",
|
|
3971
5070
|
icon: chatView.mode === "summary" ? "💬" : "≣",
|
|
@@ -4012,9 +5111,17 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
|
|
|
4012
5111
|
);
|
|
4013
5112
|
}
|
|
4014
5113
|
|
|
4015
|
-
|
|
5114
|
+
const toolbar = React.createElement("div", { className: "ps-toolbar" },
|
|
4016
5115
|
React.createElement("div", { className: "ps-toolbar-actions" }, buttonDefs.map(renderButton)),
|
|
4017
5116
|
);
|
|
5117
|
+
|
|
5118
|
+
// Rich desktop UI: the toolbar lives IN the portal header (one top bar)
|
|
5119
|
+
// rather than as its own strip, handing that row back to the panes. The
|
|
5120
|
+
// slot is rendered by PortalHeader in App.jsx, outside this tree, so the
|
|
5121
|
+
// handoff is a portal. Falls back to inline rendering when the slot is
|
|
5122
|
+
// absent (mobile layouts, embeddings that use their own header).
|
|
5123
|
+
if (headerSlot) return createPortal(toolbar, headerSlot);
|
|
5124
|
+
return toolbar;
|
|
4018
5125
|
}
|
|
4019
5126
|
|
|
4020
5127
|
function ColumnResizeHandle({ controller, paneAdjust = 0 }) {
|
|
@@ -4488,7 +5595,7 @@ function ModalLayer({ controller }) {
|
|
|
4488
5595
|
React.createElement("div", { className: `ps-modal${modal.type === "themePicker" ? " is-theme-picker" : ""}`, onClick: (event) => event.stopPropagation() },
|
|
4489
5596
|
React.createElement("div", { className: "ps-modal-header" },
|
|
4490
5597
|
React.createElement("div", { className: "ps-modal-title" }, presentation.title),
|
|
4491
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5598
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
4492
5599
|
),
|
|
4493
5600
|
React.createElement("div", { className: "ps-modal-grid" },
|
|
4494
5601
|
React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
|
|
@@ -4520,7 +5627,7 @@ function ModalLayer({ controller }) {
|
|
|
4520
5627
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
4521
5628
|
React.createElement("div", { className: "ps-modal-header" },
|
|
4522
5629
|
React.createElement("div", { className: "ps-modal-title" }, modalState.confirm.title),
|
|
4523
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5630
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
4524
5631
|
),
|
|
4525
5632
|
React.createElement("div", { className: "ps-modal-body", style: { padding: "16px 20px" } },
|
|
4526
5633
|
React.createElement("p", { style: { color: "#94a3b8", margin: 0 } }, modalState.confirm.message),
|
|
@@ -4539,6 +5646,9 @@ function ModalLayer({ controller }) {
|
|
|
4539
5646
|
React.createElement("option", { value: "deterministic" }, "Fast (no LLM — tail + pointers)"),
|
|
4540
5647
|
),
|
|
4541
5648
|
),
|
|
5649
|
+
(extras.distillMode || "llm") === "llm"
|
|
5650
|
+
? React.createElement(DistillerModelPickers, { controller, extras })
|
|
5651
|
+
: null,
|
|
4542
5652
|
(extras.distillMode || "llm") === "llm"
|
|
4543
5653
|
? React.createElement("textarea", {
|
|
4544
5654
|
className: "ps-modal-input",
|
|
@@ -4591,7 +5701,7 @@ function ModalLayer({ controller }) {
|
|
|
4591
5701
|
React.createElement("div", { className: "ps-modal", onClick: (event) => event.stopPropagation() },
|
|
4592
5702
|
React.createElement("div", { className: "ps-modal-header" },
|
|
4593
5703
|
React.createElement("div", { className: "ps-modal-title" }, presentation.title),
|
|
4594
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5704
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
4595
5705
|
),
|
|
4596
5706
|
React.createElement("div", { className: "ps-modal-grid" },
|
|
4597
5707
|
React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
|
|
@@ -4623,7 +5733,7 @@ function ModalLayer({ controller }) {
|
|
|
4623
5733
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
4624
5734
|
React.createElement("div", { className: "ps-modal-header" },
|
|
4625
5735
|
React.createElement("div", { className: "ps-modal-title" }, modalState.renameSession.title),
|
|
4626
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5736
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
4627
5737
|
),
|
|
4628
5738
|
React.createElement("input", {
|
|
4629
5739
|
ref: renameInputRef,
|
|
@@ -4656,7 +5766,7 @@ function ModalLayer({ controller }) {
|
|
|
4656
5766
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
4657
5767
|
React.createElement("div", { className: "ps-modal-header" },
|
|
4658
5768
|
React.createElement("div", { className: "ps-modal-title" }, modalState.sessionGroupName.title),
|
|
4659
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5769
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
4660
5770
|
),
|
|
4661
5771
|
React.createElement("input", {
|
|
4662
5772
|
ref: groupNameInputRef,
|
|
@@ -4701,7 +5811,7 @@ function ModalLayer({ controller }) {
|
|
|
4701
5811
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
4702
5812
|
React.createElement("div", { className: "ps-modal-header" },
|
|
4703
5813
|
React.createElement("div", { className: "ps-modal-title" }, modal.title || "Session Lifecycle"),
|
|
4704
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5814
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
4705
5815
|
),
|
|
4706
5816
|
React.createElement("div", { className: "ps-modal-body", style: { padding: "12px 16px 4px" } },
|
|
4707
5817
|
React.createElement("p", { style: { color: "#94a3b8", margin: 0 } }, bodyText)),
|
|
@@ -4746,7 +5856,7 @@ function ModalLayer({ controller }) {
|
|
|
4746
5856
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
4747
5857
|
React.createElement("div", { className: "ps-modal-header" },
|
|
4748
5858
|
React.createElement("div", { className: "ps-modal-title" }, modalState.artifactUpload.title),
|
|
4749
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5859
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
4750
5860
|
),
|
|
4751
5861
|
React.createElement("input", {
|
|
4752
5862
|
className: "ps-modal-input",
|
|
@@ -4780,7 +5890,7 @@ function ModalLayer({ controller }) {
|
|
|
4780
5890
|
React.createElement("div", { className: "ps-modal is-wide", onClick: (event) => event.stopPropagation() },
|
|
4781
5891
|
React.createElement("div", { className: "ps-modal-header" },
|
|
4782
5892
|
React.createElement("div", { className: "ps-modal-title" }, filterPresentation.title),
|
|
4783
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5893
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
4784
5894
|
),
|
|
4785
5895
|
React.createElement("div", { className: "ps-filter-grid" },
|
|
4786
5896
|
(modal.items || []).map((item, itemIndex) => {
|
|
@@ -4818,6 +5928,75 @@ function ModalLayer({ controller }) {
|
|
|
4818
5928
|
return null;
|
|
4819
5929
|
}
|
|
4820
5930
|
|
|
5931
|
+
// Distiller model / effort / context-tier pickers for the regenerate confirm.
|
|
5932
|
+
// The distiller runs on deployment machinery config rather than the served
|
|
5933
|
+
// session's model, so these are explicit choices; empty means "deployment
|
|
5934
|
+
// default". Effort and tier lists follow the SELECTED model, so an
|
|
5935
|
+
// unsupported combination cannot be submitted.
|
|
5936
|
+
function DistillerModelPickers({ controller, extras }) {
|
|
5937
|
+
const options = Array.isArray(extras.distillerModelOptions) ? extras.distillerModelOptions : [];
|
|
5938
|
+
const selected = options.find((m) => m.qualifiedName === extras.distillerModel) || null;
|
|
5939
|
+
const efforts = selected?.supportedReasoningEfforts || [];
|
|
5940
|
+
const tiers = selected?.supportedContextTiers || [];
|
|
5941
|
+
const labelStyle = { color: "#94a3b8", fontSize: "12px", display: "flex", alignItems: "center", gap: 8 };
|
|
5942
|
+
const selectStyle = { flex: "1", padding: "4px 8px" };
|
|
5943
|
+
|
|
5944
|
+
const pick = (patch) => controller.updateConfirmExtras(patch);
|
|
5945
|
+
|
|
5946
|
+
return React.createElement(React.Fragment, null,
|
|
5947
|
+
React.createElement("label", { style: labelStyle },
|
|
5948
|
+
"Distiller model",
|
|
5949
|
+
React.createElement("select", {
|
|
5950
|
+
className: "ps-modal-input",
|
|
5951
|
+
style: selectStyle,
|
|
5952
|
+
value: extras.distillerModel || "",
|
|
5953
|
+
// Switching model clears effort/tier: the previous values may
|
|
5954
|
+
// not exist on the new model.
|
|
5955
|
+
onChange: (event) => pick({
|
|
5956
|
+
distillerModel: event.currentTarget.value,
|
|
5957
|
+
distillerEffort: "",
|
|
5958
|
+
distillerContextTier: "",
|
|
5959
|
+
}),
|
|
5960
|
+
},
|
|
5961
|
+
React.createElement("option", { value: "" }, "Deployment default"),
|
|
5962
|
+
options.map((m) => React.createElement("option", {
|
|
5963
|
+
key: m.qualifiedName,
|
|
5964
|
+
value: m.qualifiedName,
|
|
5965
|
+
}, m.qualifiedName)),
|
|
5966
|
+
),
|
|
5967
|
+
),
|
|
5968
|
+
efforts.length > 0
|
|
5969
|
+
? React.createElement("label", { style: labelStyle },
|
|
5970
|
+
"Reasoning",
|
|
5971
|
+
React.createElement("select", {
|
|
5972
|
+
className: "ps-modal-input",
|
|
5973
|
+
style: selectStyle,
|
|
5974
|
+
value: extras.distillerEffort || "",
|
|
5975
|
+
onChange: (event) => pick({ distillerEffort: event.currentTarget.value }),
|
|
5976
|
+
},
|
|
5977
|
+
React.createElement("option", { value: "" }, "Model default"),
|
|
5978
|
+
efforts.map((e) => React.createElement("option", { key: e, value: e }, e)),
|
|
5979
|
+
),
|
|
5980
|
+
)
|
|
5981
|
+
: null,
|
|
5982
|
+
tiers.length > 0
|
|
5983
|
+
? React.createElement("label", { style: labelStyle },
|
|
5984
|
+
"Context",
|
|
5985
|
+
React.createElement("select", {
|
|
5986
|
+
className: "ps-modal-input",
|
|
5987
|
+
style: selectStyle,
|
|
5988
|
+
value: extras.distillerContextTier || "",
|
|
5989
|
+
onChange: (event) => pick({ distillerContextTier: event.currentTarget.value }),
|
|
5990
|
+
},
|
|
5991
|
+
React.createElement("option", { value: "" }, "Model default"),
|
|
5992
|
+
tiers.map((t) => React.createElement("option", { key: t, value: t },
|
|
5993
|
+
t === "long_context" ? "long context (fits a larger transcript)" : t)),
|
|
5994
|
+
),
|
|
5995
|
+
)
|
|
5996
|
+
: null,
|
|
5997
|
+
);
|
|
5998
|
+
}
|
|
5999
|
+
|
|
4821
6000
|
function useKeyboardShortcuts(controller, mobile) {
|
|
4822
6001
|
React.useEffect(() => {
|
|
4823
6002
|
const handler = (event) => {
|
|
@@ -5086,6 +6265,23 @@ function useKeyboardShortcuts(controller, mobile) {
|
|
|
5086
6265
|
controller.handleCommand(UI_COMMANDS.FOCUS_RIGHT).catch(() => {});
|
|
5087
6266
|
return;
|
|
5088
6267
|
}
|
|
6268
|
+
// The Files tab is a LIST, so arrows/j/k must move the selection
|
|
6269
|
+
// there the same way they do in the session list — otherwise they
|
|
6270
|
+
// fall through to the generic scroll below and the artifact list
|
|
6271
|
+
// cannot be driven from the keyboard at all. Must precede the
|
|
6272
|
+
// scroll fallback.
|
|
6273
|
+
if (!mobile && focusRegion === "inspector" && currentInspectorTab === "files"
|
|
6274
|
+
&& (event.key === "ArrowUp" || event.key === "k")) {
|
|
6275
|
+
event.preventDefault();
|
|
6276
|
+
controller.handleCommand(UI_COMMANDS.MOVE_FILE_UP).catch(() => {});
|
|
6277
|
+
return;
|
|
6278
|
+
}
|
|
6279
|
+
if (!mobile && focusRegion === "inspector" && currentInspectorTab === "files"
|
|
6280
|
+
&& (event.key === "ArrowDown" || event.key === "j")) {
|
|
6281
|
+
event.preventDefault();
|
|
6282
|
+
controller.handleCommand(UI_COMMANDS.MOVE_FILE_DOWN).catch(() => {});
|
|
6283
|
+
return;
|
|
6284
|
+
}
|
|
5089
6285
|
if (!mobile && (event.key === "ArrowUp" || event.key === "k")) {
|
|
5090
6286
|
event.preventDefault();
|
|
5091
6287
|
controller.handleCommand(UI_COMMANDS.SCROLL_UP).catch(() => {});
|
|
@@ -5247,14 +6443,32 @@ function AdminConsolePanel({ controller }) {
|
|
|
5247
6443
|
|
|
5248
6444
|
export function createWebPilotSwarmController({ transport, mode = "remote", branding = null } = {}) {
|
|
5249
6445
|
clearBrowserPreferenceCache();
|
|
6446
|
+
// Rich prose is the right default on a desktop transcript; on a phone the
|
|
6447
|
+
// terminal view fits far more per screen and does not reflow wide content.
|
|
6448
|
+
// This is only the DEFAULT — a stored profile preference still wins once
|
|
6449
|
+
// settings load, so an explicit choice is never overridden.
|
|
6450
|
+
const isNarrowViewport = typeof window !== "undefined"
|
|
6451
|
+
&& typeof window.matchMedia === "function"
|
|
6452
|
+
&& window.matchMedia("(max-width: 920px)").matches;
|
|
5250
6453
|
const store = createStore(appReducer, createInitialState({
|
|
5251
6454
|
mode,
|
|
5252
6455
|
branding,
|
|
6456
|
+
chatViewMode: isNarrowViewport ? "transcript" : "rich",
|
|
5253
6457
|
}));
|
|
5254
6458
|
return new PilotSwarmUiController({ store, transport });
|
|
5255
6459
|
}
|
|
5256
6460
|
|
|
5257
6461
|
export function PilotSwarmWebApp({ controller }) {
|
|
6462
|
+
// The rich UI restyles chrome that lives OUTSIDE this tree (the portal
|
|
6463
|
+
// header in App.jsx), so the flag rides on <body> and every surface
|
|
6464
|
+
// keys off `body.ps-rich-ui` in CSS rather than threading a prop.
|
|
6465
|
+
const richUi = useControllerSelector(controller, (state) => state.ui.chatViewMode === "rich");
|
|
6466
|
+
React.useEffect(() => {
|
|
6467
|
+
if (typeof document === "undefined") return undefined;
|
|
6468
|
+
document.body.classList.toggle("ps-rich-ui", richUi);
|
|
6469
|
+
return () => document.body.classList.remove("ps-rich-ui");
|
|
6470
|
+
}, [richUi]);
|
|
6471
|
+
|
|
5258
6472
|
const viewportRef = React.useRef(null);
|
|
5259
6473
|
const mainGridRef = React.useRef(null);
|
|
5260
6474
|
const viewport = useMeasuredViewport(viewportRef);
|
|
@@ -5316,10 +6530,15 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5316
6530
|
focusRegion: rootState.ui.focusRegion,
|
|
5317
6531
|
inspectorTab: rootState.ui.inspectorTab,
|
|
5318
6532
|
filesFullscreen: Boolean(rootState.files.fullscreen),
|
|
6533
|
+
selectedArtifactId: rootState.files.selectedArtifactId || null,
|
|
5319
6534
|
adminVisible: Boolean(rootState.admin?.visible),
|
|
5320
6535
|
}), shallowEqualObject);
|
|
5321
6536
|
const profileSettingsHydratedRef = React.useRef(false);
|
|
5322
6537
|
const lastProfileSettingsJsonRef = React.useRef(null);
|
|
6538
|
+
// Last-known value of the OTHER device class's chat-view slot. The save
|
|
6539
|
+
// endpoint replaces the whole settings object, so this is what keeps a
|
|
6540
|
+
// desktop save from erasing the phone's preference.
|
|
6541
|
+
const otherChatViewModeRef = React.useRef(null);
|
|
5323
6542
|
const profileSettingsSaveTimerRef = React.useRef(null);
|
|
5324
6543
|
const profileSettingsPollTimerRef = React.useRef(null);
|
|
5325
6544
|
const profileSettingsPollInFlightRef = React.useRef(false);
|
|
@@ -5373,13 +6592,21 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5373
6592
|
// the principal is resolved by now (it was likely null at mount),
|
|
5374
6593
|
// so the "no persisted filter" fallback becomes the correct
|
|
5375
6594
|
// principal-scoped default (Me+System) instead of {all:true}.
|
|
5376
|
-
|
|
6595
|
+
const remoteNormalized = normalizeProfileSettings(profile?.profileSettings);
|
|
6596
|
+
if (isChatViewMode(remoteNormalized[otherChatViewModeKey()])) {
|
|
6597
|
+
otherChatViewModeRef.current = remoteNormalized[otherChatViewModeKey()];
|
|
6598
|
+
}
|
|
6599
|
+
defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
|
|
6600
|
+
controller.getState(), otherChatViewModeRef.current,
|
|
6601
|
+
);
|
|
5377
6602
|
const settings = materializeProfileSettings(
|
|
5378
6603
|
profile?.profileSettings,
|
|
5379
6604
|
defaultProfileSettingsRef.current,
|
|
5380
6605
|
);
|
|
5381
6606
|
const settingsJson = JSON.stringify(settings);
|
|
5382
|
-
const currentSettingsBeforeApply = profileSettingsFromViewState(
|
|
6607
|
+
const currentSettingsBeforeApply = profileSettingsFromViewState(
|
|
6608
|
+
controller.getState(), otherChatViewModeRef.current,
|
|
6609
|
+
);
|
|
5383
6610
|
const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
|
|
5384
6611
|
const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
|
|
5385
6612
|
&& lastProfileSettingsJsonRef.current != null
|
|
@@ -5392,7 +6619,9 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5392
6619
|
appliedProfileSettingsJsonRef.current = settingsJson;
|
|
5393
6620
|
}
|
|
5394
6621
|
|
|
5395
|
-
const currentSettings = profileSettingsFromViewState(
|
|
6622
|
+
const currentSettings = profileSettingsFromViewState(
|
|
6623
|
+
controller.getState(), otherChatViewModeRef.current,
|
|
6624
|
+
);
|
|
5396
6625
|
lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
|
|
5397
6626
|
profileSettingsHydratedRef.current = true;
|
|
5398
6627
|
} catch {
|
|
@@ -5424,7 +6653,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5424
6653
|
|
|
5425
6654
|
React.useEffect(() => {
|
|
5426
6655
|
if (!profileSettingsHydratedRef.current) return undefined;
|
|
5427
|
-
const settings = profileSettingsFromViewState(state);
|
|
6656
|
+
const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current);
|
|
5428
6657
|
const settingsJson = JSON.stringify(settings);
|
|
5429
6658
|
if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
|
|
5430
6659
|
lastProfileSettingsJsonRef.current = settingsJson;
|
|
@@ -5447,7 +6676,19 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5447
6676
|
applyDocumentTheme(state.themeId);
|
|
5448
6677
|
}, [state.themeId]);
|
|
5449
6678
|
|
|
6679
|
+
// Follow focus only on an actual TRANSITION, never on the first run.
|
|
6680
|
+
//
|
|
6681
|
+
// The default focus is "sessions", but the mobile layout has no sessions
|
|
6682
|
+
// region, so normalizeFocusRegion rewrites it to order[0] — the inspector.
|
|
6683
|
+
// Syncing that initial value made a plain page load open the inspector on
|
|
6684
|
+
// its default (sequence) tab instead of the chat. A real focus change, e.g.
|
|
6685
|
+
// tapping the mobile nav, still switches panes.
|
|
6686
|
+
const lastFocusRef = React.useRef(null);
|
|
5450
6687
|
React.useEffect(() => {
|
|
6688
|
+
const previous = lastFocusRef.current;
|
|
6689
|
+
lastFocusRef.current = state.focusRegion;
|
|
6690
|
+
if (previous === null) return;
|
|
6691
|
+
if (previous === state.focusRegion) return;
|
|
5451
6692
|
if (mobile && state.focusRegion !== "prompt") {
|
|
5452
6693
|
setMobilePane(state.focusRegion === "activity"
|
|
5453
6694
|
? "activity"
|
|
@@ -5469,6 +6710,12 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5469
6710
|
[gridViewport, state.activityPaneAdjust, state.paneAdjust, effectivePromptRows, state.sessionPaneAdjust],
|
|
5470
6711
|
);
|
|
5471
6712
|
const filesFullscreenActive = state.filesFullscreen && state.inspectorTab === "files";
|
|
6713
|
+
// Preview detaches into the activity slot only while the Files tab is open
|
|
6714
|
+
// AND something is selected — otherwise Activity keeps the slot. Fullscreen
|
|
6715
|
+
// is excluded because it already shows the preview alone.
|
|
6716
|
+
const artifactPreviewDetached = state.inspectorTab === "files"
|
|
6717
|
+
&& Boolean(state.selectedArtifactId)
|
|
6718
|
+
&& !filesFullscreenActive;
|
|
5472
6719
|
|
|
5473
6720
|
React.useEffect(() => {
|
|
5474
6721
|
if (!filesFullscreenActive || !chatFocusMode) return;
|
|
@@ -5507,14 +6754,14 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5507
6754
|
const desktopWorkspace = React.createElement("div", {
|
|
5508
6755
|
className: "ps-workspace-grid",
|
|
5509
6756
|
style: {
|
|
5510
|
-
gridTemplateColumns: `minmax(0, ${layout.leftWidth}fr) 16px minmax(0, ${layout.rightWidth}fr)`,
|
|
6757
|
+
gridTemplateColumns: `minmax(0, ${layout.leftWidth}fr) var(--ps-resizer-track, 16px) minmax(0, ${layout.rightWidth}fr)`,
|
|
5511
6758
|
},
|
|
5512
6759
|
},
|
|
5513
6760
|
React.createElement("div", {
|
|
5514
6761
|
ref: mainGridRef,
|
|
5515
6762
|
className: `ps-workspace-main-grid is-session-${sessionColumnMode}`,
|
|
5516
6763
|
style: {
|
|
5517
|
-
gridTemplateColumns: `${sessionColumnTrack} 16px minmax(14rem, 1fr)`,
|
|
6764
|
+
gridTemplateColumns: `${sessionColumnTrack} var(--ps-resizer-track, 16px) minmax(14rem, 1fr)`,
|
|
5518
6765
|
},
|
|
5519
6766
|
},
|
|
5520
6767
|
sessionColumnMode !== "hidden" ? React.createElement("div", {
|
|
@@ -5538,7 +6785,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5538
6785
|
React.createElement(ColumnResizeHandle, { controller, paneAdjust: state.paneAdjust }),
|
|
5539
6786
|
React.createElement("div", {
|
|
5540
6787
|
className: "ps-workspace-column",
|
|
5541
|
-
style: { gridTemplateRows: `${layout.inspectorPaneHeight}fr 16px ${layout.activityPaneHeight}fr` },
|
|
6788
|
+
style: { gridTemplateRows: `${layout.inspectorPaneHeight}fr var(--ps-resizer-track, 16px) ${layout.activityPaneHeight}fr` },
|
|
5542
6789
|
},
|
|
5543
6790
|
React.createElement("div", {
|
|
5544
6791
|
className: "ps-workspace-pane-slot",
|
|
@@ -5558,7 +6805,12 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5558
6805
|
className: "ps-workspace-pane-slot",
|
|
5559
6806
|
style: { gridRow: "3" },
|
|
5560
6807
|
},
|
|
5561
|
-
|
|
6808
|
+
// The artifact preview takes over the activity slot while an artifact
|
|
6809
|
+
// is selected, so it inherits the row resizer and can be sized freely.
|
|
6810
|
+
// It yields back to Activity the moment the selection clears.
|
|
6811
|
+
artifactPreviewDetached
|
|
6812
|
+
? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
|
|
6813
|
+
: (!layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null))));
|
|
5562
6814
|
const chatFocusWorkspace = React.createElement(ChatFocusWorkspace, {
|
|
5563
6815
|
controller,
|
|
5564
6816
|
openPane: chatFocusPane,
|
|
@@ -5570,7 +6822,9 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5570
6822
|
React.createElement(InspectorPane, { controller, mobile: false }));
|
|
5571
6823
|
|
|
5572
6824
|
let mobileContent = null;
|
|
5573
|
-
|
|
6825
|
+
// Mobile gets the overlay below instead of an in-pane fullscreen view; the
|
|
6826
|
+
// workspace keeps rendering underneath so backing out restores it intact.
|
|
6827
|
+
if (filesFullscreenActive && !mobile) mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
|
|
5574
6828
|
React.createElement(InspectorPane, { controller, mobile: true }));
|
|
5575
6829
|
else if (mobilePane === "inspector") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
|
|
5576
6830
|
React.createElement(InspectorPane, { controller, mobile: true }));
|
|
@@ -5578,7 +6832,8 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5578
6832
|
React.createElement(ActivityPane, { controller }));
|
|
5579
6833
|
else mobileContent = React.createElement(MobileWorkspace, { controller });
|
|
5580
6834
|
|
|
5581
|
-
return React.createElement(
|
|
6835
|
+
return React.createElement(ControllerContext.Provider, { value: controller },
|
|
6836
|
+
React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
|
|
5582
6837
|
// Hide the top toolbar on mobile inspector/activity panes (and in
|
|
5583
6838
|
// chat-focus mode). Those panes are pure read-only surfaces;
|
|
5584
6839
|
// session/model/theme actions stay reachable from the Main pane,
|
|
@@ -5603,5 +6858,8 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5603
6858
|
? chatFocusWorkspace
|
|
5604
6859
|
: (mobile ? mobileContent : desktopWorkspace)))),
|
|
5605
6860
|
mobile && !chatFocusMode ? React.createElement(MobileNav, { activePane: mobilePane, setActivePane: setMobilePane, controller }) : null,
|
|
5606
|
-
|
|
6861
|
+
mobile && filesFullscreenActive
|
|
6862
|
+
? React.createElement(MobileArtifactOverlay, { controller })
|
|
6863
|
+
: null,
|
|
6864
|
+
React.createElement(ModalLayer, { controller })));
|
|
5607
6865
|
}
|