pilotswarm 0.5.23 → 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 +1 -1
- package/ui/core/src/controller.js +328 -5
- package/ui/core/src/formatting.js +46 -1
- package/ui/core/src/history.js +29 -0
- package/ui/core/src/reducer.js +49 -0
- package/ui/core/src/selectors.js +147 -10
- package/ui/core/src/state.js +11 -0
- 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 +806 -67
- package/web/dist/assets/{abnfDiagram-VRR7QNED-BHkTpRkx.js → abnfDiagram-VRR7QNED-Bjz_3uNq.js} +1 -1
- package/web/dist/assets/{arc-BOgZyEWA.js → arc-a3km8Cfi.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DeE8fDMy.js → architectureDiagram-ZJ3FMSHR-4twPAKdk.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-CzhdxVEu.js → blockDiagram-677ZJIJ3-FnFRjWxr.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-Cc6p_IKP.js → c4Diagram-LMCZKHZV-B8R4iXqh.js} +1 -1
- package/web/dist/assets/channel-BQwFhqIW.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-BCo94LTM.js → chunk-2Q5K7J3B-C7dcVQSj.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-DaklJ-6i.js → chunk-32BRIVSS-DQQ_NOog.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-BN_wg2pr.js → chunk-5VM5RSS4-BRR5_oWJ.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-DSFFLUoL.js → chunk-EX3LRPZG-C0Jg_k6w.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-DB4dAjza.js → chunk-JWPE2WC7-CoHkckoP.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-CRM5eGQS.js → chunk-MOJQB5TN-CTIj5Opw.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-BFlkjazU.js → chunk-RYQCIY6F-BsP4OCo4.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-Bnfh4AAv.js → chunk-V7JOEXUC-B5KS36_O.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-CQc4_qVM.js → chunk-VR4S4FIN-Dm92_7ut.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-CZivyoDm.js → chunk-XXDRQBXY-BGivbXmv.js} +1 -1
- 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-BUibhTD-.js → cose-bilkent-JH36ORCC-DVjdL84y.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CRkjg5zF.js → cynefin-VYW2F7L2-pcKhk42O.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BoeQn4Um.js → cynefinDiagram-TSTJHNR4-CdHQXyCH.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-DGo7-PY5.js → dagre-VKFMJZFB-Okt50Noy.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-DMXd2bdO.js → diagram-FQU43EPY-D52R5ow3.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-CkYHblMZ.js → diagram-G47NLZAW-KiJTa2OF.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-C9iwEe4T.js → diagram-NH7WQ7WH-DZ-NFYLH.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-DpYLrfwb.js → diagram-OA4YK3LP-et-Lf7yH.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-BvrBukHb.js → diagram-WEI45ONY-eXhZ7Ksl.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DGl1r3zR.js → ebnfDiagram-CCIWWBDH-CTWYusI0.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-CoP5DBSb.js → erDiagram-Q63AITRT-DwTN2EqF.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-DVHtyYyr.js → flowDiagram-23GEKE2U-CmxOIpNk.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-_JyR78yC.js → ganttDiagram-NO4QXBWP-TsPnDFPN.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Bj7sh0Db.js → gitGraphDiagram-IHSO6WYX-B_IVM0u8.js} +1 -1
- package/web/dist/assets/{index-CB6NfSZb.js → index-CB16o5aO.js} +2 -2
- package/web/dist/assets/index-CN_7NnbR.css +1 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-BrvMeRmA.js → infoDiagram-FWYZ7A6U-BFjk6921.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-lNDujpEC.js → ishikawaDiagram-FXEZZL3T-BiTzJYKW.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-yoD2QRni.js → journeyDiagram-5HDEW3XC-eeyGUQSj.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-DtyMlypg.js → kanban-definition-HUTT4EX6-DPYZ3frK.js} +1 -1
- package/web/dist/assets/{linear-DXPbXF9v.js → linear-Buu9IOJQ.js} +1 -1
- package/web/dist/assets/{mermaid.core-DCEXqVUL.js → mermaid.core-xO1oSQS3.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-DpKHDJFg.js → mindmap-definition-LN4V7U3C-TasB4KZ4.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-mxFI_8qh.js → pegDiagram-2B236MQR-Btr2u_Xy.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-sUWLrwMG.js → pieDiagram-ENE6RG2P-0iBpCJcu.js} +1 -1
- package/web/dist/assets/pilotswarm-v4IdVngK.js +93 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cph1z7ye.js → quadrantDiagram-ABIIQ3AL-CbVQOzUs.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-BPKkt2va.js → railroadDiagram-RFXS5EU6-BkwzDhMx.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-BB6DNHCy.js → requirementDiagram-TGXJPOKE-DpF9yvmD.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-BASvPQk2.js → sankeyDiagram-HTMAVEWB-CjvbElms.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DMrNas01.js → sequenceDiagram-DBY2YBRQ-DRj2K28H.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-Ddq5yeZZ.js → sizeCapture-X5ZJPWSS-Bi3gxz8F.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-DUb_IFST.js → stateDiagram-2N3HPSRC-_INdmyGU.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-A5HcuE43.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-Cs9JsGaf.js → swimlanes-5IMT3BWC-D1pFtL4Y.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-Bf_Rn9Zk.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-xuNF5fTm.js → timeline-definition-FHXFAJF6-PYQ9uRSp.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-fsI1GFLy.js → vennDiagram-L72KCM5P-xCF6u31z.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-DEc-kumC.js → wardleyDiagram-EHGQE667-DPcm9Msn.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-BCo6Qrfu.js → xychartDiagram-FW5EYKEG-D9ZNU0hG.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/channel-DhhWyKRW.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-CQBxGpio.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-CQBxGpio.js +0 -1
- package/web/dist/assets/index-SmXlZ1eu.css +0 -1
- package/web/dist/assets/pilotswarm-CdEkcq2V.js +0 -91
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-C3nsHf5u.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-C2xZA5V6.js +0 -8
package/ui/react/src/web-app.js
CHANGED
|
@@ -210,12 +210,39 @@ function normalizeProfileSettings(settings) {
|
|
|
210
210
|
const id = candidate.activeSessionId == null ? null : String(candidate.activeSessionId).trim();
|
|
211
211
|
normalized.activeSessionId = id || null;
|
|
212
212
|
}
|
|
213
|
-
if (candidate.chatViewMode
|
|
213
|
+
if (isChatViewMode(candidate.chatViewMode)) {
|
|
214
214
|
normalized.chatViewMode = candidate.chatViewMode;
|
|
215
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
|
+
}
|
|
216
222
|
return normalized;
|
|
217
223
|
}
|
|
218
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
|
+
|
|
219
246
|
function normalizeStoredCollapsedSessionIdsToArray(value) {
|
|
220
247
|
if (value instanceof Set) {
|
|
221
248
|
const out = [];
|
|
@@ -243,7 +270,7 @@ function hasOwn(value, key) {
|
|
|
243
270
|
return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
|
|
244
271
|
}
|
|
245
272
|
|
|
246
|
-
function profileSettingsFromViewState(state) {
|
|
273
|
+
function profileSettingsFromViewState(state, preservedOtherChatViewMode = null) {
|
|
247
274
|
return normalizeProfileSettings({
|
|
248
275
|
themeId: state.themeId,
|
|
249
276
|
sessionOwnerFilter: state.ownerFilter,
|
|
@@ -256,11 +283,17 @@ function profileSettingsFromViewState(state) {
|
|
|
256
283
|
pinnedSessionIds: state.pinnedIds,
|
|
257
284
|
collapsedSessionIds: state.collapsedSessionIds,
|
|
258
285
|
activeSessionId: state.activeSessionId,
|
|
259
|
-
|
|
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
|
+
: {}),
|
|
260
293
|
});
|
|
261
294
|
}
|
|
262
295
|
|
|
263
|
-
function buildDefaultProfileSettingsFromState(state) {
|
|
296
|
+
function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode = null) {
|
|
264
297
|
return normalizeProfileSettings({
|
|
265
298
|
themeId: state?.ui?.themeId,
|
|
266
299
|
// Derive the owner-filter default from the RESOLVED principal, never a
|
|
@@ -274,7 +307,10 @@ function buildDefaultProfileSettingsFromState(state) {
|
|
|
274
307
|
pinnedSessionIds: state?.sessions?.pinnedIds,
|
|
275
308
|
collapsedSessionIds: state?.sessions?.collapsedIds,
|
|
276
309
|
activeSessionId: state?.sessions?.activeSessionId,
|
|
277
|
-
|
|
310
|
+
[chatViewModeKey()]: state?.ui?.chatViewMode,
|
|
311
|
+
...(isChatViewMode(preservedOtherChatViewMode)
|
|
312
|
+
? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
|
|
313
|
+
: {}),
|
|
278
314
|
});
|
|
279
315
|
}
|
|
280
316
|
|
|
@@ -307,8 +343,11 @@ function materializeProfileSettings(remoteSettings, defaults) {
|
|
|
307
343
|
...(hasOwn(normalizedRemote, "activeSessionId")
|
|
308
344
|
? { activeSessionId: normalizedRemote.activeSessionId }
|
|
309
345
|
: {}),
|
|
310
|
-
|
|
311
|
-
|
|
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()] }
|
|
312
351
|
: {}),
|
|
313
352
|
});
|
|
314
353
|
}
|
|
@@ -389,10 +428,15 @@ function computeStateLayout(state) {
|
|
|
389
428
|
}
|
|
390
429
|
|
|
391
430
|
function getPortalInspectorContentWidth(paneWidth, inspectorTab) {
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
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);
|
|
396
440
|
}
|
|
397
441
|
|
|
398
442
|
function normalizeLines(lines) {
|
|
@@ -549,6 +593,10 @@ function applyDocumentTheme(themeId) {
|
|
|
549
593
|
root.style.setProperty("--ps-modal-selected-background", theme.page.modalSelectedBackground);
|
|
550
594
|
root.style.setProperty("--ps-modal-selected-border", theme.page.modalSelectedBorder);
|
|
551
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;
|
|
552
600
|
}
|
|
553
601
|
|
|
554
602
|
function useMeasuredViewport(ref) {
|
|
@@ -798,6 +846,20 @@ function Runs({ runs, theme }) {
|
|
|
798
846
|
};
|
|
799
847
|
const href = String(run?.href || "").trim();
|
|
800
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
|
+
}
|
|
801
863
|
|
|
802
864
|
return isExternalHref
|
|
803
865
|
? React.createElement("a", {
|
|
@@ -865,7 +927,12 @@ function usePanePixelScroll(ref, scrollOffset, paneKey, controller) {
|
|
|
865
927
|
|
|
866
928
|
React.useLayoutEffect(() => {
|
|
867
929
|
const node = ref.current;
|
|
868
|
-
|
|
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;
|
|
869
936
|
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
870
937
|
const nextScrollTop = Math.min(maxScroll, Math.max(0, Number(scrollOffset) || 0) * SCROLL_ROW_HEIGHT);
|
|
871
938
|
if (Math.abs(node.scrollTop - nextScrollTop) > 2) {
|
|
@@ -875,12 +942,19 @@ function usePanePixelScroll(ref, scrollOffset, paneKey, controller) {
|
|
|
875
942
|
programmaticScrollRef.current = false;
|
|
876
943
|
});
|
|
877
944
|
}
|
|
878
|
-
}, [ref, scrollOffset]);
|
|
945
|
+
}, [ref, scrollOffset, paneKey]);
|
|
879
946
|
|
|
880
|
-
return React.useCallback(() => {
|
|
947
|
+
return React.useCallback((event) => {
|
|
881
948
|
if (programmaticScrollRef.current) return;
|
|
882
949
|
const node = ref.current;
|
|
883
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;
|
|
884
958
|
// See useScrollSync — ignore browser auto-clamp during a
|
|
885
959
|
// transient empty/loading body.
|
|
886
960
|
if (node.scrollHeight <= node.clientHeight) return;
|
|
@@ -912,6 +986,163 @@ const CODE_LANGUAGE_ALIASES = {
|
|
|
912
986
|
css: "css", scss: "css",
|
|
913
987
|
};
|
|
914
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
|
+
|
|
915
1146
|
function isMermaidLanguage(language) {
|
|
916
1147
|
return String(language || "").trim().toLowerCase() === "mermaid";
|
|
917
1148
|
}
|
|
@@ -1174,6 +1405,25 @@ function renderInlineMarkdown(source, theme, keyPrefix = "md") {
|
|
|
1174
1405
|
return React.createElement("em", { key, className: "ps-md-em" }, renderInlineMarkdown(token.text, theme, `${key}:em`));
|
|
1175
1406
|
}
|
|
1176
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
|
+
}
|
|
1177
1427
|
return React.createElement("a", {
|
|
1178
1428
|
key,
|
|
1179
1429
|
className: "ps-md-link",
|
|
@@ -1457,10 +1707,16 @@ function MarkdownPreviewContent({ content, theme }) {
|
|
|
1457
1707
|
}, renderInlineMarkdown(block.text, theme, `heading:${index}`));
|
|
1458
1708
|
}
|
|
1459
1709
|
if (block.type === "code") {
|
|
1460
|
-
const
|
|
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
|
+
},
|
|
1461
1715
|
React.createElement("div", { className: "ps-md-code-header" }, block.language || "text"),
|
|
1462
1716
|
React.createElement("pre", { className: "ps-md-code-pre" },
|
|
1463
|
-
React.createElement("code", null,
|
|
1717
|
+
React.createElement("code", null, isDiff
|
|
1718
|
+
? renderDiffCode(block.content, theme)
|
|
1719
|
+
: renderHighlightedCode(block.content, block.language, theme))));
|
|
1464
1720
|
if (isMermaidLanguage(block.language)) {
|
|
1465
1721
|
return React.createElement(MermaidDiagram, {
|
|
1466
1722
|
key: `block:${index}`,
|
|
@@ -1548,7 +1804,42 @@ function MarkdownPreviewContent({ content, theme }) {
|
|
|
1548
1804
|
}));
|
|
1549
1805
|
}
|
|
1550
1806
|
|
|
1551
|
-
|
|
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]);
|
|
1552
1843
|
const ref = React.useRef(null);
|
|
1553
1844
|
const onScroll = usePanePixelScroll(ref, scrollOffset, paneKey, controller);
|
|
1554
1845
|
|
|
@@ -1557,6 +1848,7 @@ function MarkdownPreviewPanel({ controller, title, color, focused, scrollOffset
|
|
|
1557
1848
|
ref,
|
|
1558
1849
|
className: "ps-scroll-panel ps-markdown-scroll",
|
|
1559
1850
|
onScroll,
|
|
1851
|
+
onMouseDown: claimMarkdownFocus,
|
|
1560
1852
|
}, React.createElement(MarkdownPreviewContent, { content, theme })));
|
|
1561
1853
|
}
|
|
1562
1854
|
|
|
@@ -1587,9 +1879,14 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
|
|
|
1587
1879
|
})
|
|
1588
1880
|
: "";
|
|
1589
1881
|
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
|
|
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 || ""));
|
|
1593
1890
|
const [imageUrl, setImageUrl] = React.useState(null);
|
|
1594
1891
|
React.useEffect(() => {
|
|
1595
1892
|
setImageUrl(null);
|
|
@@ -2119,10 +2416,16 @@ function StructuredBlockList({ blocks, theme, controller = null }) {
|
|
|
2119
2416
|
}
|
|
2120
2417
|
|
|
2121
2418
|
if (block.type === "code") {
|
|
2122
|
-
const
|
|
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
|
+
},
|
|
2123
2424
|
React.createElement("div", { className: "ps-md-code-header" }, block.language || "text"),
|
|
2124
2425
|
React.createElement("pre", { className: "ps-md-code-pre" },
|
|
2125
|
-
React.createElement("code", null,
|
|
2426
|
+
React.createElement("code", null, chatIsDiff
|
|
2427
|
+
? renderDiffCode(block.content || "", theme)
|
|
2428
|
+
: renderHighlightedCode(block.content || "", block.language, theme))));
|
|
2126
2429
|
if (isMermaidLanguage(block.language)) {
|
|
2127
2430
|
return React.createElement(MermaidDiagram, {
|
|
2128
2431
|
key: `code:${index}`,
|
|
@@ -2232,11 +2535,19 @@ function unbracketTitleRuns(runs) {
|
|
|
2232
2535
|
|
|
2233
2536
|
function Panel({ title, titleRight = null, color = "gray", focused = false, actions = null, children, theme, className = "" }) {
|
|
2234
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
|
+
);
|
|
2235
2546
|
return React.createElement("section", {
|
|
2236
|
-
className: `ps-panel${focused ? " is-focused" : ""}${className ? ` ${className}` : ""}`,
|
|
2547
|
+
className: `ps-panel${focused ? " is-focused" : ""}${hasHeader ? "" : " is-chromeless"}${className ? ` ${className}` : ""}`,
|
|
2237
2548
|
style: { "--ps-panel-accent": accent || "var(--ps-border)" },
|
|
2238
2549
|
},
|
|
2239
|
-
React.createElement("header", { className: "ps-panel-header" },
|
|
2550
|
+
hasHeader ? React.createElement("header", { className: "ps-panel-header" },
|
|
2240
2551
|
React.createElement("div", { className: "ps-panel-title" },
|
|
2241
2552
|
Array.isArray(title)
|
|
2242
2553
|
? React.createElement(Runs, { runs: title, theme })
|
|
@@ -2252,7 +2563,7 @@ function Panel({ title, titleRight = null, color = "gray", focused = false, acti
|
|
|
2252
2563
|
actions ? React.createElement("div", { className: "ps-panel-actions" }, actions) : null,
|
|
2253
2564
|
)
|
|
2254
2565
|
: null,
|
|
2255
|
-
),
|
|
2566
|
+
) : null,
|
|
2256
2567
|
React.createElement("div", { className: "ps-panel-body" }, children));
|
|
2257
2568
|
}
|
|
2258
2569
|
|
|
@@ -2355,7 +2666,33 @@ function PortalSequenceLines({ lines, theme, completionByTurn }) {
|
|
|
2355
2666
|
}
|
|
2356
2667
|
|
|
2357
2668
|
|
|
2358
|
-
|
|
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 }) {
|
|
2359
2696
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
2360
2697
|
const theme = getTheme(themeId);
|
|
2361
2698
|
const ref = React.useRef(null);
|
|
@@ -2366,6 +2703,19 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
2366
2703
|
const normalizedBottomSticky = React.useMemo(() => normalizeLines(bottomStickyLines), [bottomStickyLines]);
|
|
2367
2704
|
const preserveHorizontalScroll = className.includes("is-preserve") && panelClassName.includes("has-preserved-sticky");
|
|
2368
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
|
+
|
|
2369
2719
|
const syncScrollLeft = React.useCallback((source, target) => {
|
|
2370
2720
|
if (!source || !target) return;
|
|
2371
2721
|
if (Math.abs((target.scrollLeft || 0) - (source.scrollLeft || 0)) <= 1) return;
|
|
@@ -2413,7 +2763,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
2413
2763
|
normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
|
|
2414
2764
|
)
|
|
2415
2765
|
: null,
|
|
2416
|
-
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 },
|
|
2417
2767
|
typeof renderBody === "function"
|
|
2418
2768
|
? renderBody(normalizedLines, theme)
|
|
2419
2769
|
: structuredBlocks
|
|
@@ -2524,6 +2874,36 @@ function SessionRowContent({ row, theme, structured = false }) {
|
|
|
2524
2874
|
: null);
|
|
2525
2875
|
}
|
|
2526
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
|
+
|
|
2527
2907
|
function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false }) {
|
|
2528
2908
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
2529
2909
|
const theme = getTheme(themeId);
|
|
@@ -2547,7 +2927,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
2547
2927
|
// ever wanted independently.
|
|
2548
2928
|
rich: state.ui.chatViewMode === "rich",
|
|
2549
2929
|
}), shallowEqualObject);
|
|
2550
|
-
const
|
|
2930
|
+
const computedRows = React.useMemo(() => selectSessionRows({
|
|
2551
2931
|
sessions: {
|
|
2552
2932
|
activeSessionId: viewState.activeSessionId,
|
|
2553
2933
|
byId: viewState.sessionsById,
|
|
@@ -2563,6 +2943,8 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
2563
2943
|
mode: viewState.connectionMode,
|
|
2564
2944
|
},
|
|
2565
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);
|
|
2566
2948
|
const activeSession = viewState.activeSessionId
|
|
2567
2949
|
? viewState.sessionsById[viewState.activeSessionId] || null
|
|
2568
2950
|
: null;
|
|
@@ -2869,7 +3251,7 @@ function SessionLinkModal({ url, warn, onCopyAgain, onClose }) {
|
|
|
2869
3251
|
React.createElement("div", { className: "ps-link-modal", onClick: stop },
|
|
2870
3252
|
React.createElement("div", { className: "ps-share-modal-head" },
|
|
2871
3253
|
React.createElement("span", null, "Link copied"),
|
|
2872
|
-
React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
|
|
3254
|
+
React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
|
|
2873
3255
|
React.createElement("div", { className: "ps-share-section-sub" },
|
|
2874
3256
|
"Copied to your clipboard — anyone with access can open the session from this link."),
|
|
2875
3257
|
React.createElement("div", { className: "ps-link-row" },
|
|
@@ -3106,7 +3488,7 @@ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel,
|
|
|
3106
3488
|
React.createElement("div", { className: "ps-share-modal", onClick: stop },
|
|
3107
3489
|
React.createElement("div", { className: "ps-share-modal-head" },
|
|
3108
3490
|
React.createElement("span", null, "Manage session"),
|
|
3109
|
-
React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
|
|
3491
|
+
React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
|
|
3110
3492
|
|
|
3111
3493
|
// ── Tab bar ───────────────────────────────────────────────
|
|
3112
3494
|
React.createElement("div", { className: "ps-manage-tabs", role: "tablist" },
|
|
@@ -3393,6 +3775,79 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
|
|
|
3393
3775
|
});
|
|
3394
3776
|
}
|
|
3395
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
|
+
|
|
3396
3851
|
function MobileWorkspace({ controller }) {
|
|
3397
3852
|
// The session list is always shown; use the toolbar Focus button to give
|
|
3398
3853
|
// the chat the full screen (the old Show/Hide toggle was redundant with it).
|
|
@@ -3424,7 +3879,11 @@ function InspectorTabs({ activeTab, controller }) {
|
|
|
3424
3879
|
})));
|
|
3425
3880
|
}
|
|
3426
3881
|
|
|
3427
|
-
|
|
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 }) {
|
|
3428
3887
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
3429
3888
|
const theme = getTheme(themeId);
|
|
3430
3889
|
const fileInputRef = React.useRef(null);
|
|
@@ -3441,6 +3900,8 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3441
3900
|
filesBySessionId: state.files.bySessionId,
|
|
3442
3901
|
filesFilter: state.files.filter,
|
|
3443
3902
|
selectedArtifactId: state.files.selectedArtifactId,
|
|
3903
|
+
markedIds: state.files.markedIds || [],
|
|
3904
|
+
previewOrigin: state.files.previewOrigin || null,
|
|
3444
3905
|
focused,
|
|
3445
3906
|
previewScroll: state.ui.scroll.filePreview,
|
|
3446
3907
|
fullscreen: Boolean(state.files.fullscreen),
|
|
@@ -3502,7 +3963,35 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3502
3963
|
}
|
|
3503
3964
|
}, [controller, viewState.canBrowserUpload, viewState.canPathUpload]);
|
|
3504
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
|
+
|
|
3505
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,
|
|
3506
3995
|
React.createElement("input", {
|
|
3507
3996
|
ref: fileInputRef,
|
|
3508
3997
|
type: "file",
|
|
@@ -3544,12 +4033,26 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3544
4033
|
label: "Filter",
|
|
3545
4034
|
onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {}),
|
|
3546
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.
|
|
3547
4039
|
React.createElement(IconButton, {
|
|
3548
4040
|
icon: viewState.fullscreen ? "⇱" : "⛶",
|
|
3549
4041
|
label: viewState.fullscreen ? "Exit fullscreen" : "Fullscreen",
|
|
3550
4042
|
onClick: () => controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {}),
|
|
3551
4043
|
}));
|
|
3552
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
|
+
|
|
3553
4056
|
const listContent = items.length === 0
|
|
3554
4057
|
? normalizeLines(filesView.listBodyLines || []).map((line, index) => React.createElement(Line, {
|
|
3555
4058
|
key: `empty:${index}`,
|
|
@@ -3559,34 +4062,114 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3559
4062
|
: items.map((item, index) => React.createElement("button", {
|
|
3560
4063
|
key: item.id,
|
|
3561
4064
|
type: "button",
|
|
3562
|
-
|
|
3563
|
-
|
|
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) => {
|
|
3564
4069
|
controller.setFocus("inspector");
|
|
3565
|
-
|
|
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(() => {});
|
|
3566
4084
|
},
|
|
3567
4085
|
}, React.createElement(Line, {
|
|
3568
4086
|
line: normalizeLines([filesView.listBodyLines?.[index]])[0],
|
|
3569
4087
|
theme,
|
|
3570
4088
|
})));
|
|
3571
4089
|
|
|
3572
|
-
|
|
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"
|
|
3573
4155
|
&& !filesView.previewLoading
|
|
3574
4156
|
&& !filesView.previewError
|
|
3575
4157
|
? React.createElement(MarkdownPreviewPanel, {
|
|
3576
4158
|
controller,
|
|
3577
|
-
title:
|
|
4159
|
+
title: null,
|
|
3578
4160
|
color: "cyan",
|
|
3579
4161
|
focused: false,
|
|
3580
4162
|
scrollOffset: viewState.previewScroll,
|
|
3581
|
-
paneKey:
|
|
4163
|
+
paneKey: previewPaneKey,
|
|
3582
4164
|
theme,
|
|
3583
|
-
|
|
4165
|
+
focusRegion: previewFocusRegion,
|
|
4166
|
+
content: stripYamlFrontmatter(filesView.previewContent || ""),
|
|
3584
4167
|
})
|
|
3585
4168
|
: filesView.previewIsBinary
|
|
3586
4169
|
&& !filesView.previewLoading
|
|
3587
4170
|
&& !filesView.previewError
|
|
3588
4171
|
? React.createElement(BinaryArtifactPreviewPanel, {
|
|
3589
|
-
title:
|
|
4172
|
+
title: null,
|
|
3590
4173
|
color: "cyan",
|
|
3591
4174
|
focused: false,
|
|
3592
4175
|
theme,
|
|
@@ -3600,17 +4183,20 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3600
4183
|
})
|
|
3601
4184
|
: React.createElement(ScrollLinesPanel, {
|
|
3602
4185
|
controller,
|
|
3603
|
-
title:
|
|
4186
|
+
title: null,
|
|
3604
4187
|
color: "cyan",
|
|
3605
4188
|
focused: false,
|
|
3606
4189
|
lines: filesView.previewLines,
|
|
3607
4190
|
scrollOffset: viewState.previewScroll,
|
|
3608
4191
|
scrollMode: "top",
|
|
3609
|
-
paneKey:
|
|
4192
|
+
paneKey: previewPaneKey,
|
|
4193
|
+
focusRegion: previewFocusRegion,
|
|
3610
4194
|
className: "is-preview is-wrapped",
|
|
3611
4195
|
});
|
|
3612
4196
|
const view = viewState;
|
|
3613
4197
|
|
|
4198
|
+
if (previewOnly) return previewPane;
|
|
4199
|
+
|
|
3614
4200
|
return React.createElement(Panel, {
|
|
3615
4201
|
title: view.fullscreen
|
|
3616
4202
|
? filesView.fullscreenTitle
|
|
@@ -3626,10 +4212,16 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3626
4212
|
// Fullscreen files mode shows only the preview pane; the list stays hidden.
|
|
3627
4213
|
view.fullscreen
|
|
3628
4214
|
? previewPane
|
|
3629
|
-
: React.createElement("div", {
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
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),
|
|
3633
4225
|
));
|
|
3634
4226
|
}
|
|
3635
4227
|
|
|
@@ -3868,9 +4460,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
|
|
|
3868
4460
|
structuredRows: mobile,
|
|
3869
4461
|
actions: React.createElement("button", {
|
|
3870
4462
|
type: "button",
|
|
3871
|
-
className: "ps-mini-button",
|
|
4463
|
+
className: "ps-mini-button ps-overlay-close",
|
|
3872
4464
|
onClick: onClose,
|
|
3873
|
-
|
|
4465
|
+
"aria-label": "Close",
|
|
4466
|
+
title: "Close",
|
|
4467
|
+
}, "✕"),
|
|
3874
4468
|
});
|
|
3875
4469
|
} else if (pane === "inspector") {
|
|
3876
4470
|
content = React.createElement(InspectorPane, {
|
|
@@ -3879,9 +4473,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
|
|
|
3879
4473
|
panelClassName: "ps-chat-focus-pane",
|
|
3880
4474
|
extraActions: React.createElement("button", {
|
|
3881
4475
|
type: "button",
|
|
3882
|
-
className: "ps-mini-button",
|
|
4476
|
+
className: "ps-mini-button ps-overlay-close",
|
|
3883
4477
|
onClick: onClose,
|
|
3884
|
-
|
|
4478
|
+
"aria-label": "Close",
|
|
4479
|
+
title: "Close",
|
|
4480
|
+
}, "✕"),
|
|
3885
4481
|
});
|
|
3886
4482
|
} else if (pane === "activity") {
|
|
3887
4483
|
content = React.createElement(ActivityPane, {
|
|
@@ -3889,9 +4485,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
|
|
|
3889
4485
|
panelClassName: "ps-chat-focus-pane",
|
|
3890
4486
|
extraActions: React.createElement("button", {
|
|
3891
4487
|
type: "button",
|
|
3892
|
-
className: "ps-mini-button",
|
|
4488
|
+
className: "ps-mini-button ps-overlay-close",
|
|
3893
4489
|
onClick: onClose,
|
|
3894
|
-
|
|
4490
|
+
"aria-label": "Close",
|
|
4491
|
+
title: "Close",
|
|
4492
|
+
}, "✕"),
|
|
3895
4493
|
});
|
|
3896
4494
|
}
|
|
3897
4495
|
|
|
@@ -4997,7 +5595,7 @@ function ModalLayer({ controller }) {
|
|
|
4997
5595
|
React.createElement("div", { className: `ps-modal${modal.type === "themePicker" ? " is-theme-picker" : ""}`, onClick: (event) => event.stopPropagation() },
|
|
4998
5596
|
React.createElement("div", { className: "ps-modal-header" },
|
|
4999
5597
|
React.createElement("div", { className: "ps-modal-title" }, presentation.title),
|
|
5000
|
-
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" }, "✕"),
|
|
5001
5599
|
),
|
|
5002
5600
|
React.createElement("div", { className: "ps-modal-grid" },
|
|
5003
5601
|
React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
|
|
@@ -5029,7 +5627,7 @@ function ModalLayer({ controller }) {
|
|
|
5029
5627
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
5030
5628
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5031
5629
|
React.createElement("div", { className: "ps-modal-title" }, modalState.confirm.title),
|
|
5032
|
-
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" }, "✕"),
|
|
5033
5631
|
),
|
|
5034
5632
|
React.createElement("div", { className: "ps-modal-body", style: { padding: "16px 20px" } },
|
|
5035
5633
|
React.createElement("p", { style: { color: "#94a3b8", margin: 0 } }, modalState.confirm.message),
|
|
@@ -5048,6 +5646,9 @@ function ModalLayer({ controller }) {
|
|
|
5048
5646
|
React.createElement("option", { value: "deterministic" }, "Fast (no LLM — tail + pointers)"),
|
|
5049
5647
|
),
|
|
5050
5648
|
),
|
|
5649
|
+
(extras.distillMode || "llm") === "llm"
|
|
5650
|
+
? React.createElement(DistillerModelPickers, { controller, extras })
|
|
5651
|
+
: null,
|
|
5051
5652
|
(extras.distillMode || "llm") === "llm"
|
|
5052
5653
|
? React.createElement("textarea", {
|
|
5053
5654
|
className: "ps-modal-input",
|
|
@@ -5100,7 +5701,7 @@ function ModalLayer({ controller }) {
|
|
|
5100
5701
|
React.createElement("div", { className: "ps-modal", onClick: (event) => event.stopPropagation() },
|
|
5101
5702
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5102
5703
|
React.createElement("div", { className: "ps-modal-title" }, presentation.title),
|
|
5103
|
-
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" }, "✕"),
|
|
5104
5705
|
),
|
|
5105
5706
|
React.createElement("div", { className: "ps-modal-grid" },
|
|
5106
5707
|
React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
|
|
@@ -5132,7 +5733,7 @@ function ModalLayer({ controller }) {
|
|
|
5132
5733
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
5133
5734
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5134
5735
|
React.createElement("div", { className: "ps-modal-title" }, modalState.renameSession.title),
|
|
5135
|
-
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" }, "✕"),
|
|
5136
5737
|
),
|
|
5137
5738
|
React.createElement("input", {
|
|
5138
5739
|
ref: renameInputRef,
|
|
@@ -5165,7 +5766,7 @@ function ModalLayer({ controller }) {
|
|
|
5165
5766
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
5166
5767
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5167
5768
|
React.createElement("div", { className: "ps-modal-title" }, modalState.sessionGroupName.title),
|
|
5168
|
-
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" }, "✕"),
|
|
5169
5770
|
),
|
|
5170
5771
|
React.createElement("input", {
|
|
5171
5772
|
ref: groupNameInputRef,
|
|
@@ -5210,7 +5811,7 @@ function ModalLayer({ controller }) {
|
|
|
5210
5811
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
5211
5812
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5212
5813
|
React.createElement("div", { className: "ps-modal-title" }, modal.title || "Session Lifecycle"),
|
|
5213
|
-
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" }, "✕"),
|
|
5214
5815
|
),
|
|
5215
5816
|
React.createElement("div", { className: "ps-modal-body", style: { padding: "12px 16px 4px" } },
|
|
5216
5817
|
React.createElement("p", { style: { color: "#94a3b8", margin: 0 } }, bodyText)),
|
|
@@ -5255,7 +5856,7 @@ function ModalLayer({ controller }) {
|
|
|
5255
5856
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
5256
5857
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5257
5858
|
React.createElement("div", { className: "ps-modal-title" }, modalState.artifactUpload.title),
|
|
5258
|
-
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" }, "✕"),
|
|
5259
5860
|
),
|
|
5260
5861
|
React.createElement("input", {
|
|
5261
5862
|
className: "ps-modal-input",
|
|
@@ -5289,7 +5890,7 @@ function ModalLayer({ controller }) {
|
|
|
5289
5890
|
React.createElement("div", { className: "ps-modal is-wide", onClick: (event) => event.stopPropagation() },
|
|
5290
5891
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5291
5892
|
React.createElement("div", { className: "ps-modal-title" }, filterPresentation.title),
|
|
5292
|
-
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" }, "✕"),
|
|
5293
5894
|
),
|
|
5294
5895
|
React.createElement("div", { className: "ps-filter-grid" },
|
|
5295
5896
|
(modal.items || []).map((item, itemIndex) => {
|
|
@@ -5327,6 +5928,75 @@ function ModalLayer({ controller }) {
|
|
|
5327
5928
|
return null;
|
|
5328
5929
|
}
|
|
5329
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
|
+
|
|
5330
6000
|
function useKeyboardShortcuts(controller, mobile) {
|
|
5331
6001
|
React.useEffect(() => {
|
|
5332
6002
|
const handler = (event) => {
|
|
@@ -5595,6 +6265,23 @@ function useKeyboardShortcuts(controller, mobile) {
|
|
|
5595
6265
|
controller.handleCommand(UI_COMMANDS.FOCUS_RIGHT).catch(() => {});
|
|
5596
6266
|
return;
|
|
5597
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
|
+
}
|
|
5598
6285
|
if (!mobile && (event.key === "ArrowUp" || event.key === "k")) {
|
|
5599
6286
|
event.preventDefault();
|
|
5600
6287
|
controller.handleCommand(UI_COMMANDS.SCROLL_UP).catch(() => {});
|
|
@@ -5756,9 +6443,17 @@ function AdminConsolePanel({ controller }) {
|
|
|
5756
6443
|
|
|
5757
6444
|
export function createWebPilotSwarmController({ transport, mode = "remote", branding = null } = {}) {
|
|
5758
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;
|
|
5759
6453
|
const store = createStore(appReducer, createInitialState({
|
|
5760
6454
|
mode,
|
|
5761
6455
|
branding,
|
|
6456
|
+
chatViewMode: isNarrowViewport ? "transcript" : "rich",
|
|
5762
6457
|
}));
|
|
5763
6458
|
return new PilotSwarmUiController({ store, transport });
|
|
5764
6459
|
}
|
|
@@ -5835,10 +6530,15 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5835
6530
|
focusRegion: rootState.ui.focusRegion,
|
|
5836
6531
|
inspectorTab: rootState.ui.inspectorTab,
|
|
5837
6532
|
filesFullscreen: Boolean(rootState.files.fullscreen),
|
|
6533
|
+
selectedArtifactId: rootState.files.selectedArtifactId || null,
|
|
5838
6534
|
adminVisible: Boolean(rootState.admin?.visible),
|
|
5839
6535
|
}), shallowEqualObject);
|
|
5840
6536
|
const profileSettingsHydratedRef = React.useRef(false);
|
|
5841
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);
|
|
5842
6542
|
const profileSettingsSaveTimerRef = React.useRef(null);
|
|
5843
6543
|
const profileSettingsPollTimerRef = React.useRef(null);
|
|
5844
6544
|
const profileSettingsPollInFlightRef = React.useRef(false);
|
|
@@ -5892,13 +6592,21 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5892
6592
|
// the principal is resolved by now (it was likely null at mount),
|
|
5893
6593
|
// so the "no persisted filter" fallback becomes the correct
|
|
5894
6594
|
// principal-scoped default (Me+System) instead of {all:true}.
|
|
5895
|
-
|
|
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
|
+
);
|
|
5896
6602
|
const settings = materializeProfileSettings(
|
|
5897
6603
|
profile?.profileSettings,
|
|
5898
6604
|
defaultProfileSettingsRef.current,
|
|
5899
6605
|
);
|
|
5900
6606
|
const settingsJson = JSON.stringify(settings);
|
|
5901
|
-
const currentSettingsBeforeApply = profileSettingsFromViewState(
|
|
6607
|
+
const currentSettingsBeforeApply = profileSettingsFromViewState(
|
|
6608
|
+
controller.getState(), otherChatViewModeRef.current,
|
|
6609
|
+
);
|
|
5902
6610
|
const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
|
|
5903
6611
|
const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
|
|
5904
6612
|
&& lastProfileSettingsJsonRef.current != null
|
|
@@ -5911,7 +6619,9 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5911
6619
|
appliedProfileSettingsJsonRef.current = settingsJson;
|
|
5912
6620
|
}
|
|
5913
6621
|
|
|
5914
|
-
const currentSettings = profileSettingsFromViewState(
|
|
6622
|
+
const currentSettings = profileSettingsFromViewState(
|
|
6623
|
+
controller.getState(), otherChatViewModeRef.current,
|
|
6624
|
+
);
|
|
5915
6625
|
lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
|
|
5916
6626
|
profileSettingsHydratedRef.current = true;
|
|
5917
6627
|
} catch {
|
|
@@ -5943,7 +6653,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5943
6653
|
|
|
5944
6654
|
React.useEffect(() => {
|
|
5945
6655
|
if (!profileSettingsHydratedRef.current) return undefined;
|
|
5946
|
-
const settings = profileSettingsFromViewState(state);
|
|
6656
|
+
const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current);
|
|
5947
6657
|
const settingsJson = JSON.stringify(settings);
|
|
5948
6658
|
if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
|
|
5949
6659
|
lastProfileSettingsJsonRef.current = settingsJson;
|
|
@@ -5966,7 +6676,19 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5966
6676
|
applyDocumentTheme(state.themeId);
|
|
5967
6677
|
}, [state.themeId]);
|
|
5968
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);
|
|
5969
6687
|
React.useEffect(() => {
|
|
6688
|
+
const previous = lastFocusRef.current;
|
|
6689
|
+
lastFocusRef.current = state.focusRegion;
|
|
6690
|
+
if (previous === null) return;
|
|
6691
|
+
if (previous === state.focusRegion) return;
|
|
5970
6692
|
if (mobile && state.focusRegion !== "prompt") {
|
|
5971
6693
|
setMobilePane(state.focusRegion === "activity"
|
|
5972
6694
|
? "activity"
|
|
@@ -5988,6 +6710,12 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5988
6710
|
[gridViewport, state.activityPaneAdjust, state.paneAdjust, effectivePromptRows, state.sessionPaneAdjust],
|
|
5989
6711
|
);
|
|
5990
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;
|
|
5991
6719
|
|
|
5992
6720
|
React.useEffect(() => {
|
|
5993
6721
|
if (!filesFullscreenActive || !chatFocusMode) return;
|
|
@@ -6077,7 +6805,12 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
6077
6805
|
className: "ps-workspace-pane-slot",
|
|
6078
6806
|
style: { gridRow: "3" },
|
|
6079
6807
|
},
|
|
6080
|
-
|
|
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))));
|
|
6081
6814
|
const chatFocusWorkspace = React.createElement(ChatFocusWorkspace, {
|
|
6082
6815
|
controller,
|
|
6083
6816
|
openPane: chatFocusPane,
|
|
@@ -6089,7 +6822,9 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
6089
6822
|
React.createElement(InspectorPane, { controller, mobile: false }));
|
|
6090
6823
|
|
|
6091
6824
|
let mobileContent = null;
|
|
6092
|
-
|
|
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" },
|
|
6093
6828
|
React.createElement(InspectorPane, { controller, mobile: true }));
|
|
6094
6829
|
else if (mobilePane === "inspector") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
|
|
6095
6830
|
React.createElement(InspectorPane, { controller, mobile: true }));
|
|
@@ -6097,7 +6832,8 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
6097
6832
|
React.createElement(ActivityPane, { controller }));
|
|
6098
6833
|
else mobileContent = React.createElement(MobileWorkspace, { controller });
|
|
6099
6834
|
|
|
6100
|
-
return React.createElement(
|
|
6835
|
+
return React.createElement(ControllerContext.Provider, { value: controller },
|
|
6836
|
+
React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
|
|
6101
6837
|
// Hide the top toolbar on mobile inspector/activity panes (and in
|
|
6102
6838
|
// chat-focus mode). Those panes are pure read-only surfaces;
|
|
6103
6839
|
// session/model/theme actions stay reachable from the Main pane,
|
|
@@ -6122,5 +6858,8 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
6122
6858
|
? chatFocusWorkspace
|
|
6123
6859
|
: (mobile ? mobileContent : desktopWorkspace)))),
|
|
6124
6860
|
mobile && !chatFocusMode ? React.createElement(MobileNav, { activePane: mobilePane, setActivePane: setMobilePane, controller }) : null,
|
|
6125
|
-
|
|
6861
|
+
mobile && filesFullscreenActive
|
|
6862
|
+
? React.createElement(MobileArtifactOverlay, { controller })
|
|
6863
|
+
: null,
|
|
6864
|
+
React.createElement(ModalLayer, { controller })));
|
|
6126
6865
|
}
|