pilotswarm 0.5.23 → 0.5.25
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 +5 -3
- 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 +154 -13
- 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 +886 -78
- package/web/dist/assets/{abnfDiagram-VRR7QNED-BHkTpRkx.js → abnfDiagram-VRR7QNED-C4qRR7va.js} +1 -1
- package/web/dist/assets/{arc-BOgZyEWA.js → arc-BQw2V2pL.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DeE8fDMy.js → architectureDiagram-ZJ3FMSHR-DMdWnLGG.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-CzhdxVEu.js → blockDiagram-677ZJIJ3-DZnALw8j.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-Cc6p_IKP.js → c4Diagram-LMCZKHZV-BGGZ42iX.js} +1 -1
- package/web/dist/assets/channel-CB157dQ5.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-BCo94LTM.js → chunk-2Q5K7J3B-Dastiegd.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-DaklJ-6i.js → chunk-32BRIVSS-B4XexRzf.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-BN_wg2pr.js → chunk-5VM5RSS4-D66MNGCN.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-DSFFLUoL.js → chunk-EX3LRPZG-DeqPS4Xm.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-DB4dAjza.js → chunk-JWPE2WC7-DIMWiXpM.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-CRM5eGQS.js → chunk-MOJQB5TN-vTyL0qgx.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-BFlkjazU.js → chunk-RYQCIY6F-C7U9MKGw.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-Bnfh4AAv.js → chunk-V7JOEXUC-BzPwQ7n3.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-CQc4_qVM.js → chunk-VR4S4FIN-CmDsyK3b.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-CZivyoDm.js → chunk-XXDRQBXY-ZJn6BaGL.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-B6JwBYlv.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-B6JwBYlv.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-BUibhTD-.js → cose-bilkent-JH36ORCC-CAA8JZZM.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CRkjg5zF.js → cynefin-VYW2F7L2-B6pX-lUD.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BoeQn4Um.js → cynefinDiagram-TSTJHNR4-sKTHU_UH.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-DGo7-PY5.js → dagre-VKFMJZFB-EuakJ578.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-DMXd2bdO.js → diagram-FQU43EPY-DR3M_pic.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-CkYHblMZ.js → diagram-G47NLZAW-BlS6yM0u.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-C9iwEe4T.js → diagram-NH7WQ7WH-CQNXe6dc.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-DpYLrfwb.js → diagram-OA4YK3LP-BizBeSQo.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-BvrBukHb.js → diagram-WEI45ONY-C-_KM6Gg.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DGl1r3zR.js → ebnfDiagram-CCIWWBDH-BapWEdic.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-CoP5DBSb.js → erDiagram-Q63AITRT-Btijn1Xj.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-DVHtyYyr.js → flowDiagram-23GEKE2U-D8zK8HkH.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-_JyR78yC.js → ganttDiagram-NO4QXBWP-DyjkOIMW.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Bj7sh0Db.js → gitGraphDiagram-IHSO6WYX-3r9_QeYN.js} +1 -1
- package/web/dist/assets/index-BEhdfIki.css +1 -0
- package/web/dist/assets/{index-CB6NfSZb.js → index-m0kBr7aj.js} +2 -2
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-BrvMeRmA.js → infoDiagram-FWYZ7A6U-Ccd8ACUz.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-lNDujpEC.js → ishikawaDiagram-FXEZZL3T-DPRUC8V2.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-yoD2QRni.js → journeyDiagram-5HDEW3XC-CkvayMsL.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-DtyMlypg.js → kanban-definition-HUTT4EX6-CAK9Z-ha.js} +1 -1
- package/web/dist/assets/{linear-DXPbXF9v.js → linear-B-_ZjN2w.js} +1 -1
- package/web/dist/assets/{mermaid.core-DCEXqVUL.js → mermaid.core-BN9c--2r.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-DpKHDJFg.js → mindmap-definition-LN4V7U3C-DuD_AHRU.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-mxFI_8qh.js → pegDiagram-2B236MQR-Cc1v58KA.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-sUWLrwMG.js → pieDiagram-ENE6RG2P-Bc32--ur.js} +1 -1
- package/web/dist/assets/pilotswarm-BgzdDy09.js +93 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cph1z7ye.js → quadrantDiagram-ABIIQ3AL-DpPTc2EP.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-BPKkt2va.js → railroadDiagram-RFXS5EU6-DXMzwejw.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-BB6DNHCy.js → requirementDiagram-TGXJPOKE-B3ImAxw-.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-BASvPQk2.js → sankeyDiagram-HTMAVEWB-hmVMffE2.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DMrNas01.js → sequenceDiagram-DBY2YBRQ-D53fk6uF.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-Ddq5yeZZ.js → sizeCapture-X5ZJPWSS-4U-I5d2g.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-DUb_IFST.js → stateDiagram-2N3HPSRC-0Y5TgWhW.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DqIcGfAY.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-Cs9JsGaf.js → swimlanes-5IMT3BWC-Umhy4qUu.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-DA9txLi6.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-xuNF5fTm.js → timeline-definition-FHXFAJF6-D5hOg3Qu.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-fsI1GFLy.js → vennDiagram-L72KCM5P-QVIcE3UD.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-DEc-kumC.js → wardleyDiagram-EHGQE667-BEj1M2cB.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-BCo6Qrfu.js → xychartDiagram-FW5EYKEG-xojRIXIf.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) {
|
|
@@ -697,6 +745,10 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
697
745
|
};
|
|
698
746
|
}, [normalizedLines, ref, scrollMode, scrollOffset, stickyBottom, viewportRevision]);
|
|
699
747
|
|
|
748
|
+
const dispatchScrollOffset = useFrameCoalescedCallback((offset) => {
|
|
749
|
+
controller.dispatch({ type: "ui/scroll", pane: paneKey, offset });
|
|
750
|
+
});
|
|
751
|
+
|
|
700
752
|
const onScroll = React.useCallback(() => {
|
|
701
753
|
const node = ref.current;
|
|
702
754
|
if (!node || !paneKey) return;
|
|
@@ -731,15 +783,14 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
|
|
|
731
783
|
? Math.max(0, maxScroll - node.scrollTop)
|
|
732
784
|
: Math.max(0, node.scrollTop);
|
|
733
785
|
userScrollRef.current.lastDispatchedOffset = pixels / SCROLL_ROW_HEIGHT;
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
});
|
|
786
|
+
// One dispatch per frame. A scroll gesture fires far more events than
|
|
787
|
+
// the browser paints, and every dispatch notifies each subscriber —
|
|
788
|
+
// the offset the user actually sees is the last one in the frame.
|
|
789
|
+
dispatchScrollOffset(pixels / SCROLL_ROW_HEIGHT);
|
|
739
790
|
if (paneKey === "chat" && scrollMode === "bottom" && node.scrollTop <= PROGRAMMATIC_SCROLL_TOLERANCE_PX) {
|
|
740
791
|
controller.armChatTopHistoryLoad?.();
|
|
741
792
|
}
|
|
742
|
-
}, [controller, paneKey, ref, scrollMode, stickyBottom]);
|
|
793
|
+
}, [controller, dispatchScrollOffset, paneKey, ref, scrollMode, stickyBottom]);
|
|
743
794
|
|
|
744
795
|
const onWheel = React.useCallback((event) => {
|
|
745
796
|
const node = ref.current;
|
|
@@ -796,8 +847,32 @@ function Runs({ runs, theme }) {
|
|
|
796
847
|
fontWeight: run.bold ? 700 : 400,
|
|
797
848
|
textDecoration: run.underline ? "underline" : "none",
|
|
798
849
|
};
|
|
850
|
+
// A run flagged trailingRule renders its text then a CSS rule that
|
|
851
|
+
// fills the remaining width — see selectors.js for why this is not
|
|
852
|
+
// a repeated box-drawing character.
|
|
853
|
+
if (run?.trailingRule) {
|
|
854
|
+
return React.createElement("span", {
|
|
855
|
+
key: `${index}:rule`,
|
|
856
|
+
className: "ps-line-trailing-rule",
|
|
857
|
+
style,
|
|
858
|
+
}, run.text || "");
|
|
859
|
+
}
|
|
799
860
|
const href = String(run?.href || "").trim();
|
|
800
861
|
const isExternalHref = /^https?:\/\//i.test(href);
|
|
862
|
+
// artifact:// links used to render as an inert span — the only
|
|
863
|
+
// affordance was "press a to download", which is the wrong verb
|
|
864
|
+
// for a patch you want to READ. They now open the Files tab with
|
|
865
|
+
// the artifact selected and previewed.
|
|
866
|
+
const artifactRef = parseArtifactHref(href);
|
|
867
|
+
|
|
868
|
+
if (artifactRef) {
|
|
869
|
+
return React.createElement(ArtifactLink, {
|
|
870
|
+
key: `${index}:${run.text || ""}`,
|
|
871
|
+
label: run.text || "",
|
|
872
|
+
artifactRef,
|
|
873
|
+
style,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
801
876
|
|
|
802
877
|
return isExternalHref
|
|
803
878
|
? React.createElement("a", {
|
|
@@ -830,7 +905,45 @@ function SystemNoticeLine({ line, theme }) {
|
|
|
830
905
|
: null);
|
|
831
906
|
}
|
|
832
907
|
|
|
833
|
-
|
|
908
|
+
/**
|
|
909
|
+
* Coalesce high-frequency dispatches to one per animation frame.
|
|
910
|
+
*
|
|
911
|
+
* pointermove and scroll fire many times per frame, and each dispatch
|
|
912
|
+
* notifies every subscriber, so a loaded transcript re-renders repeatedly
|
|
913
|
+
* between two painted frames — work that is thrown away before it is ever
|
|
914
|
+
* seen. Collapsing to one dispatch per frame keeps the interaction
|
|
915
|
+
* responsive without changing what the state ends up as: the LAST value in
|
|
916
|
+
* the frame wins, which is the one the user is actually looking at.
|
|
917
|
+
*/
|
|
918
|
+
function useFrameCoalescedCallback(fn) {
|
|
919
|
+
const frameRef = React.useRef(0);
|
|
920
|
+
const argsRef = React.useRef(null);
|
|
921
|
+
const fnRef = React.useRef(fn);
|
|
922
|
+
fnRef.current = fn;
|
|
923
|
+
|
|
924
|
+
React.useEffect(() => () => {
|
|
925
|
+
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
|
926
|
+
}, []);
|
|
927
|
+
|
|
928
|
+
return React.useCallback((...args) => {
|
|
929
|
+
argsRef.current = args;
|
|
930
|
+
if (frameRef.current) return;
|
|
931
|
+
frameRef.current = requestAnimationFrame(() => {
|
|
932
|
+
frameRef.current = 0;
|
|
933
|
+
const pending = argsRef.current;
|
|
934
|
+
argsRef.current = null;
|
|
935
|
+
if (pending) fnRef.current(...pending);
|
|
936
|
+
});
|
|
937
|
+
}, []);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Rendering a transcript is proportional to its length, so it must not repeat
|
|
942
|
+
* for state changes that do not alter the lines — scroll offset and pane
|
|
943
|
+
* resize both notify every subscriber. Memoizing on the line object keeps
|
|
944
|
+
* React from reconciling thousands of rows on every scroll event.
|
|
945
|
+
*/
|
|
946
|
+
const Line = React.memo(function Line({ line, theme, className = "" }) {
|
|
834
947
|
const lineClassName = className ? `ps-line ${className}` : "ps-line";
|
|
835
948
|
if (!line) {
|
|
836
949
|
return React.createElement("div", { className: lineClassName }, " ");
|
|
@@ -851,7 +964,7 @@ function Line({ line, theme, className = "" }) {
|
|
|
851
964
|
textDecoration: line.underline ? "underline" : "none",
|
|
852
965
|
},
|
|
853
966
|
}, line.text || " ");
|
|
854
|
-
}
|
|
967
|
+
});
|
|
855
968
|
|
|
856
969
|
function lineText(line) {
|
|
857
970
|
if (!line) return "";
|
|
@@ -865,7 +978,12 @@ function usePanePixelScroll(ref, scrollOffset, paneKey, controller) {
|
|
|
865
978
|
|
|
866
979
|
React.useLayoutEffect(() => {
|
|
867
980
|
const node = ref.current;
|
|
868
|
-
|
|
981
|
+
// No paneKey means nobody is driving this pane's offset, so leave the
|
|
982
|
+
// scroller entirely alone. Writing scrollTop back during a touch fling
|
|
983
|
+
// interrupts momentum every frame — which is why the preview felt
|
|
984
|
+
// stiff next to the transcript — and the SCROLL_ROW_HEIGHT
|
|
985
|
+
// quantization makes it snap as well.
|
|
986
|
+
if (!node || !paneKey) return;
|
|
869
987
|
const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
870
988
|
const nextScrollTop = Math.min(maxScroll, Math.max(0, Number(scrollOffset) || 0) * SCROLL_ROW_HEIGHT);
|
|
871
989
|
if (Math.abs(node.scrollTop - nextScrollTop) > 2) {
|
|
@@ -875,12 +993,19 @@ function usePanePixelScroll(ref, scrollOffset, paneKey, controller) {
|
|
|
875
993
|
programmaticScrollRef.current = false;
|
|
876
994
|
});
|
|
877
995
|
}
|
|
878
|
-
}, [ref, scrollOffset]);
|
|
996
|
+
}, [ref, scrollOffset, paneKey]);
|
|
879
997
|
|
|
880
|
-
return React.useCallback(() => {
|
|
998
|
+
return React.useCallback((event) => {
|
|
881
999
|
if (programmaticScrollRef.current) return;
|
|
882
1000
|
const node = ref.current;
|
|
883
1001
|
if (!node || !paneKey) return;
|
|
1002
|
+
// React simulates bubbling for onScroll, so scrolling a code block or
|
|
1003
|
+
// table INSIDE the document also fires this handler. Acting on it
|
|
1004
|
+
// recorded the pane offset from an inner element's scroll state and
|
|
1005
|
+
// then wrote it back to the outer scroller — horizontally scrolling a
|
|
1006
|
+
// fenced code block snapped the whole markdown preview to the top.
|
|
1007
|
+
// Only the pane's own scroller may drive the pane's offset.
|
|
1008
|
+
if (event && event.target !== node) return;
|
|
884
1009
|
// See useScrollSync — ignore browser auto-clamp during a
|
|
885
1010
|
// transient empty/loading body.
|
|
886
1011
|
if (node.scrollHeight <= node.clientHeight) return;
|
|
@@ -912,6 +1037,163 @@ const CODE_LANGUAGE_ALIASES = {
|
|
|
912
1037
|
css: "css", scss: "css",
|
|
913
1038
|
};
|
|
914
1039
|
|
|
1040
|
+
// ── Diff blocks ───────────────────────────────────────────────────────────
|
|
1041
|
+
// A ```diff fence is line-oriented, not token-oriented: running it through
|
|
1042
|
+
// the generic tokenizer colored Rust/JS keywords inside the payload while
|
|
1043
|
+
// leaving the +/- semantics — the only thing that matters in a diff — as
|
|
1044
|
+
// undifferentiated text. Diffs are classified per line instead.
|
|
1045
|
+
|
|
1046
|
+
const DIFF_LANGUAGES = new Set(["diff", "patch", "udiff"]);
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Diff-shaped artifacts. Recognized by extension AND by content type, because
|
|
1050
|
+
* an agent may write either `.patch`/`.diff` or a text/x-patch blob under some
|
|
1051
|
+
* other name.
|
|
1052
|
+
*/
|
|
1053
|
+
const DIFF_ARTIFACT_RE = /\.(patch|diff)$/i;
|
|
1054
|
+
const IMAGE_CONTENT_TYPE_RE = /^image\/(png|jpe?g|gif|webp|avif|bmp|svg\+xml|x-icon|vnd\.microsoft\.icon)$/i;
|
|
1055
|
+
const IMAGE_FILENAME_RE = /\.(png|jpe?g|gif|webp|avif|bmp|svg|ico)$/i;
|
|
1056
|
+
|
|
1057
|
+
/** Map an artifact filename to a highlighter language, or null for plain text. */
|
|
1058
|
+
const CODE_ARTIFACT_LANGUAGES = {
|
|
1059
|
+
js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript",
|
|
1060
|
+
ts: "typescript", tsx: "typescript", json: "json", jsonl: "json",
|
|
1061
|
+
py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
|
|
1062
|
+
sh: "bash", bash: "bash", zsh: "bash", yml: "yaml", yaml: "yaml",
|
|
1063
|
+
toml: "toml", sql: "sql", css: "css", html: "html", xml: "xml",
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Strip a leading YAML frontmatter block from markdown.
|
|
1068
|
+
*
|
|
1069
|
+
* The renderer has no concept of frontmatter, so `---\ntitle: x\n---` was
|
|
1070
|
+
* collapsed into a paragraph and rendered as prose above the document — which
|
|
1071
|
+
* read as the title being duplicated, since the doc's own H1 follows it.
|
|
1072
|
+
* Document viewers hide frontmatter; only strip a block that starts on line 1
|
|
1073
|
+
* and closes, so a document opening with a thematic break is untouched.
|
|
1074
|
+
*/
|
|
1075
|
+
function stripYamlFrontmatter(content) {
|
|
1076
|
+
const text = String(content || "");
|
|
1077
|
+
if (!/^---[ \t]*\r?\n/.test(text)) return text;
|
|
1078
|
+
const close = /\r?\n---[ \t]*(\r?\n|$)/.exec(text.slice(3));
|
|
1079
|
+
if (!close) return text;
|
|
1080
|
+
return text.slice(3 + close.index + close[0].length);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function codeLanguageForArtifact(filename) {
|
|
1084
|
+
const ext = /\.([A-Za-z0-9]+)$/.exec(String(filename || ""));
|
|
1085
|
+
if (!ext) return null;
|
|
1086
|
+
return CODE_ARTIFACT_LANGUAGES[ext[1].toLowerCase()] || null;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function isDiffArtifact(filename, contentType) {
|
|
1090
|
+
if (DIFF_ARTIFACT_RE.test(String(filename || ""))) return true;
|
|
1091
|
+
return /^text\/x-(patch|diff)$/i.test(String(contentType || "").trim());
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Parse an `artifact://<sessionId>/<filename>` href. */
|
|
1095
|
+
function parseArtifactHref(href) {
|
|
1096
|
+
const match = /^artifact:\/\/([^/]+)\/(.+)$/i.exec(String(href || "").trim());
|
|
1097
|
+
if (!match) return null;
|
|
1098
|
+
try {
|
|
1099
|
+
return { sessionId: match[1], filename: decodeURIComponent(match[2]) };
|
|
1100
|
+
} catch {
|
|
1101
|
+
return { sessionId: match[1], filename: match[2] };
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Controller access for deeply-nested renderers.
|
|
1107
|
+
*
|
|
1108
|
+
* The markdown/run renderers are called from many places and never took a
|
|
1109
|
+
* controller — threading one through every call site to make a single link
|
|
1110
|
+
* type clickable would touch far more code than the feature is worth.
|
|
1111
|
+
*/
|
|
1112
|
+
const ControllerContext = React.createContext(null);
|
|
1113
|
+
|
|
1114
|
+
function ArtifactLink({ label, artifactRef, style }) {
|
|
1115
|
+
const controller = React.useContext(ControllerContext);
|
|
1116
|
+
const diff = isDiffArtifact(artifactRef.filename);
|
|
1117
|
+
const onOpen = React.useCallback((event) => {
|
|
1118
|
+
event.preventDefault();
|
|
1119
|
+
event.stopPropagation();
|
|
1120
|
+
if (!controller?.revealArtifact) return;
|
|
1121
|
+
// On a phone the preview takes the whole pane — a list/detail split has
|
|
1122
|
+
// no room to be useful at 390px.
|
|
1123
|
+
const fullscreen = typeof window !== "undefined"
|
|
1124
|
+
&& typeof window.matchMedia === "function"
|
|
1125
|
+
&& window.matchMedia("(max-width: 920px)").matches;
|
|
1126
|
+
Promise.resolve(controller.revealArtifact(artifactRef.sessionId, artifactRef.filename, { fullscreen }))
|
|
1127
|
+
.catch(() => {});
|
|
1128
|
+
}, [controller, artifactRef.sessionId, artifactRef.filename]);
|
|
1129
|
+
|
|
1130
|
+
return React.createElement("button", {
|
|
1131
|
+
type: "button",
|
|
1132
|
+
className: `ps-artifact-link${diff ? " is-diff" : ""}`,
|
|
1133
|
+
onClick: onOpen,
|
|
1134
|
+
title: diff
|
|
1135
|
+
? `Open ${artifactRef.filename} as a diff`
|
|
1136
|
+
: `Open ${artifactRef.filename} in Files`,
|
|
1137
|
+
style,
|
|
1138
|
+
},
|
|
1139
|
+
diff ? React.createElement("span", { className: "ps-artifact-link-glyph", "aria-hidden": "true" }, "±") : null,
|
|
1140
|
+
React.createElement("span", { className: "ps-artifact-link-label" }, label || artifactRef.filename));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function isDiffLanguage(language) {
|
|
1144
|
+
return DIFF_LANGUAGES.has(String(language || "").trim().toLowerCase());
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// File/meta headers are matched BEFORE +/- so "--- a/x" and "+++ b/x" are not
|
|
1148
|
+
// mistaken for removed/added lines.
|
|
1149
|
+
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|\\)/;
|
|
1150
|
+
|
|
1151
|
+
function classifyDiffLine(line) {
|
|
1152
|
+
if (DIFF_META_PATTERN.test(line)) return "meta";
|
|
1153
|
+
if (line.startsWith("@@")) return "hunk";
|
|
1154
|
+
if (line.startsWith("+")) return "add";
|
|
1155
|
+
if (line.startsWith("-")) return "del";
|
|
1156
|
+
return "context";
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const DIFF_LINE_COLORS = { add: "green", del: "red", hunk: "cyan", meta: "gray" };
|
|
1160
|
+
|
|
1161
|
+
function renderDiffCode(content, theme) {
|
|
1162
|
+
const lines = String(content || "").replace(/\n$/, "").split("\n");
|
|
1163
|
+
return lines.map((line, index) => {
|
|
1164
|
+
const kind = classifyDiffLine(line);
|
|
1165
|
+
const color = DIFF_LINE_COLORS[kind];
|
|
1166
|
+
// Payload lines carry their +/-/space in column 1. Split it off into
|
|
1167
|
+
// a gutter (GitHub/GitLab/VS Code all do this) so the marker stops
|
|
1168
|
+
// colliding with the code, wrapped lines hang under the text column
|
|
1169
|
+
// instead of under the marker, and a copied selection yields clean
|
|
1170
|
+
// code — the gutter is user-select:none. Meta/hunk lines have no
|
|
1171
|
+
// marker but keep the empty gutter so every line stays aligned.
|
|
1172
|
+
// Only strip column 1 when it really IS a marker. "context" is the
|
|
1173
|
+
// fallback for every unrecognized line, so slicing it unconditionally
|
|
1174
|
+
// ate the first real character of anything that is not a unified-diff
|
|
1175
|
+
// body line — GIT binary patch payload ("delta 142", "zcmV;91Ru…"),
|
|
1176
|
+
// format-patch commit prose, mbox headers. Real context lines start
|
|
1177
|
+
// with a space; those still get their marker split off.
|
|
1178
|
+
const hasMarker = kind === "add" || kind === "del"
|
|
1179
|
+
|| (kind === "context" && line.startsWith(" "));
|
|
1180
|
+
const marker = hasMarker ? line.slice(0, 1) : "";
|
|
1181
|
+
const text = hasMarker ? line.slice(1) : line;
|
|
1182
|
+
return React.createElement("span", {
|
|
1183
|
+
key: `d:${index}`,
|
|
1184
|
+
className: `ps-diff-line is-${kind}`,
|
|
1185
|
+
style: color ? { color: resolveColor(theme, color) || undefined } : undefined,
|
|
1186
|
+
},
|
|
1187
|
+
React.createElement("span", {
|
|
1188
|
+
className: "ps-diff-marker",
|
|
1189
|
+
// Decorative: the tint already carries the meaning visually,
|
|
1190
|
+
// and the row is announced by its text.
|
|
1191
|
+
"aria-hidden": "true",
|
|
1192
|
+
}, marker.trim()),
|
|
1193
|
+
React.createElement("span", { className: "ps-diff-text" }, text));
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
915
1197
|
function isMermaidLanguage(language) {
|
|
916
1198
|
return String(language || "").trim().toLowerCase() === "mermaid";
|
|
917
1199
|
}
|
|
@@ -1174,6 +1456,25 @@ function renderInlineMarkdown(source, theme, keyPrefix = "md") {
|
|
|
1174
1456
|
return React.createElement("em", { key, className: "ps-md-em" }, renderInlineMarkdown(token.text, theme, `${key}:em`));
|
|
1175
1457
|
}
|
|
1176
1458
|
if (token.type === "link") {
|
|
1459
|
+
const artifactRef = parseArtifactHref(token.href);
|
|
1460
|
+
if (artifactRef) {
|
|
1461
|
+
return React.createElement(ArtifactLink, {
|
|
1462
|
+
key,
|
|
1463
|
+
label: token.text,
|
|
1464
|
+
artifactRef,
|
|
1465
|
+
style: { color: resolveColor(theme, "cyan") },
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
// Only http(s) becomes a navigable anchor. Anything else — an
|
|
1469
|
+
// unknown scheme, or a malformed href from a bad transform — would
|
|
1470
|
+
// resolve RELATIVE to the portal and navigate the SPA away on
|
|
1471
|
+
// click, losing all session state. Render it as inert text instead.
|
|
1472
|
+
if (!/^https?:\/\//i.test(String(token.href || "").trim())) {
|
|
1473
|
+
return React.createElement("span", {
|
|
1474
|
+
key,
|
|
1475
|
+
style: { color: resolveColor(theme, "cyan") },
|
|
1476
|
+
}, token.text);
|
|
1477
|
+
}
|
|
1177
1478
|
return React.createElement("a", {
|
|
1178
1479
|
key,
|
|
1179
1480
|
className: "ps-md-link",
|
|
@@ -1457,10 +1758,16 @@ function MarkdownPreviewContent({ content, theme }) {
|
|
|
1457
1758
|
}, renderInlineMarkdown(block.text, theme, `heading:${index}`));
|
|
1458
1759
|
}
|
|
1459
1760
|
if (block.type === "code") {
|
|
1460
|
-
const
|
|
1761
|
+
const isDiff = isDiffLanguage(block.language);
|
|
1762
|
+
const codeSection = React.createElement("section", {
|
|
1763
|
+
key: `block:${index}`,
|
|
1764
|
+
className: `ps-md-code-block${isDiff ? " is-diff" : ""}`,
|
|
1765
|
+
},
|
|
1461
1766
|
React.createElement("div", { className: "ps-md-code-header" }, block.language || "text"),
|
|
1462
1767
|
React.createElement("pre", { className: "ps-md-code-pre" },
|
|
1463
|
-
React.createElement("code", null,
|
|
1768
|
+
React.createElement("code", null, isDiff
|
|
1769
|
+
? renderDiffCode(block.content, theme)
|
|
1770
|
+
: renderHighlightedCode(block.content, block.language, theme))));
|
|
1464
1771
|
if (isMermaidLanguage(block.language)) {
|
|
1465
1772
|
return React.createElement(MermaidDiagram, {
|
|
1466
1773
|
key: `block:${index}`,
|
|
@@ -1548,7 +1855,42 @@ function MarkdownPreviewContent({ content, theme }) {
|
|
|
1548
1855
|
}));
|
|
1549
1856
|
}
|
|
1550
1857
|
|
|
1551
|
-
|
|
1858
|
+
/**
|
|
1859
|
+
* Preview panel for pre-rendered content (diff, highlighted code).
|
|
1860
|
+
*
|
|
1861
|
+
* Shares MarkdownPreviewPanel's scroll wiring rather than being a plain
|
|
1862
|
+
* overflow:auto div: without the pane hookup the keyboard scroll commands
|
|
1863
|
+
* have nothing to drive, so these previews could only ever be scrolled with
|
|
1864
|
+
* the mouse — the global key handler swallows the arrows before the browser
|
|
1865
|
+
* would scroll them natively.
|
|
1866
|
+
*/
|
|
1867
|
+
function RenderedPreviewPanel({ controller, title, color, focused, scrollOffset = 0, paneKey, theme, className = "", focusRegion = null, children }) {
|
|
1868
|
+
const ref = React.useRef(null);
|
|
1869
|
+
const onScroll = usePanePixelScroll(ref, scrollOffset, paneKey, controller);
|
|
1870
|
+
// Mouse only. On touch this fired on every tap, and setFocus runs the
|
|
1871
|
+
// region through normalizeFocusRegion, which rewrites a region missing
|
|
1872
|
+
// from the current layout to order[0] — so tapping the chat on a phone
|
|
1873
|
+
// was silently reassigning focus to the inspector and jumping to the
|
|
1874
|
+
// artifact list. Keyboard scroll targeting is meaningless on touch anyway.
|
|
1875
|
+
const claimFocus = React.useCallback(() => {
|
|
1876
|
+
const region = focusRegionForPaneKey(paneKey, focusRegion);
|
|
1877
|
+
if (region && controller?.setFocus) controller.setFocus(region);
|
|
1878
|
+
}, [controller, paneKey, focusRegion]);
|
|
1879
|
+
|
|
1880
|
+
return React.createElement(Panel, { title, color, focused, theme },
|
|
1881
|
+
React.createElement("div", {
|
|
1882
|
+
ref,
|
|
1883
|
+
className: `ps-scroll-panel is-preview ${className}`.trim(),
|
|
1884
|
+
onScroll,
|
|
1885
|
+
onMouseDown: claimFocus,
|
|
1886
|
+
}, children));
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function MarkdownPreviewPanel({ controller, title, color, focused, scrollOffset = 0, paneKey, theme, content, focusRegion = null }) {
|
|
1890
|
+
const claimMarkdownFocus = React.useCallback(() => {
|
|
1891
|
+
const region = focusRegionForPaneKey(paneKey, focusRegion);
|
|
1892
|
+
if (region && controller?.setFocus) controller.setFocus(region);
|
|
1893
|
+
}, [controller, paneKey, focusRegion]);
|
|
1552
1894
|
const ref = React.useRef(null);
|
|
1553
1895
|
const onScroll = usePanePixelScroll(ref, scrollOffset, paneKey, controller);
|
|
1554
1896
|
|
|
@@ -1557,6 +1899,7 @@ function MarkdownPreviewPanel({ controller, title, color, focused, scrollOffset
|
|
|
1557
1899
|
ref,
|
|
1558
1900
|
className: "ps-scroll-panel ps-markdown-scroll",
|
|
1559
1901
|
onScroll,
|
|
1902
|
+
onMouseDown: claimMarkdownFocus,
|
|
1560
1903
|
}, React.createElement(MarkdownPreviewContent, { content, theme })));
|
|
1561
1904
|
}
|
|
1562
1905
|
|
|
@@ -1587,9 +1930,14 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
|
|
|
1587
1930
|
})
|
|
1588
1931
|
: "";
|
|
1589
1932
|
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
|
|
1933
|
+
// Images render inline (authenticated fetch → object URL). Other binary
|
|
1934
|
+
// types keep the download-only card.
|
|
1935
|
+
//
|
|
1936
|
+
// SVG goes through the SAME object-URL <img> path rather than being
|
|
1937
|
+
// inlined as markup: an artifact is untrusted content, and <img> does not
|
|
1938
|
+
// execute script inside an SVG the way inline markup would.
|
|
1939
|
+
const isRasterImage = IMAGE_CONTENT_TYPE_RE.test(String(contentType || ""))
|
|
1940
|
+
|| IMAGE_FILENAME_RE.test(String(filename || ""));
|
|
1593
1941
|
const [imageUrl, setImageUrl] = React.useState(null);
|
|
1594
1942
|
React.useEffect(() => {
|
|
1595
1943
|
setImageUrl(null);
|
|
@@ -2119,10 +2467,16 @@ function StructuredBlockList({ blocks, theme, controller = null }) {
|
|
|
2119
2467
|
}
|
|
2120
2468
|
|
|
2121
2469
|
if (block.type === "code") {
|
|
2122
|
-
const
|
|
2470
|
+
const chatIsDiff = isDiffLanguage(block.language);
|
|
2471
|
+
const chatCodeSection = React.createElement("section", {
|
|
2472
|
+
key: `code:${index}`,
|
|
2473
|
+
className: `ps-md-code-block ps-chat-code-block${chatIsDiff ? " is-diff" : ""}`,
|
|
2474
|
+
},
|
|
2123
2475
|
React.createElement("div", { className: "ps-md-code-header" }, block.language || "text"),
|
|
2124
2476
|
React.createElement("pre", { className: "ps-md-code-pre" },
|
|
2125
|
-
React.createElement("code", null,
|
|
2477
|
+
React.createElement("code", null, chatIsDiff
|
|
2478
|
+
? renderDiffCode(block.content || "", theme)
|
|
2479
|
+
: renderHighlightedCode(block.content || "", block.language, theme))));
|
|
2126
2480
|
if (isMermaidLanguage(block.language)) {
|
|
2127
2481
|
return React.createElement(MermaidDiagram, {
|
|
2128
2482
|
key: `code:${index}`,
|
|
@@ -2232,11 +2586,19 @@ function unbracketTitleRuns(runs) {
|
|
|
2232
2586
|
|
|
2233
2587
|
function Panel({ title, titleRight = null, color = "gray", focused = false, actions = null, children, theme, className = "" }) {
|
|
2234
2588
|
const accent = resolveColor(theme, color);
|
|
2589
|
+
// A panel with nothing to put in its header should not render one. Nested
|
|
2590
|
+
// panels (the artifact list and preview inside Files) were spending a full
|
|
2591
|
+
// header row restating what the enclosing pane already said.
|
|
2592
|
+
const hasHeader = Boolean(
|
|
2593
|
+
(Array.isArray(title) ? title.length > 0 : String(title ?? "").trim())
|
|
2594
|
+
|| titleRight
|
|
2595
|
+
|| actions,
|
|
2596
|
+
);
|
|
2235
2597
|
return React.createElement("section", {
|
|
2236
|
-
className: `ps-panel${focused ? " is-focused" : ""}${className ? ` ${className}` : ""}`,
|
|
2598
|
+
className: `ps-panel${focused ? " is-focused" : ""}${hasHeader ? "" : " is-chromeless"}${className ? ` ${className}` : ""}`,
|
|
2237
2599
|
style: { "--ps-panel-accent": accent || "var(--ps-border)" },
|
|
2238
2600
|
},
|
|
2239
|
-
React.createElement("header", { className: "ps-panel-header" },
|
|
2601
|
+
hasHeader ? React.createElement("header", { className: "ps-panel-header" },
|
|
2240
2602
|
React.createElement("div", { className: "ps-panel-title" },
|
|
2241
2603
|
Array.isArray(title)
|
|
2242
2604
|
? React.createElement(Runs, { runs: title, theme })
|
|
@@ -2252,7 +2614,7 @@ function Panel({ title, titleRight = null, color = "gray", focused = false, acti
|
|
|
2252
2614
|
actions ? React.createElement("div", { className: "ps-panel-actions" }, actions) : null,
|
|
2253
2615
|
)
|
|
2254
2616
|
: null,
|
|
2255
|
-
),
|
|
2617
|
+
) : null,
|
|
2256
2618
|
React.createElement("div", { className: "ps-panel-body" }, children));
|
|
2257
2619
|
}
|
|
2258
2620
|
|
|
@@ -2355,7 +2717,33 @@ function PortalSequenceLines({ lines, theme, completionByTurn }) {
|
|
|
2355
2717
|
}
|
|
2356
2718
|
|
|
2357
2719
|
|
|
2358
|
-
|
|
2720
|
+
/**
|
|
2721
|
+
* Which focus region owns a scrollable pane.
|
|
2722
|
+
*
|
|
2723
|
+
* Keyboard scrolling already routes through focusRegion
|
|
2724
|
+
* (controller.getScrollablePaneForFocus), but nothing set the focus when you
|
|
2725
|
+
* CLICKED a pane — so the scroll keys kept acting on whatever was focused
|
|
2726
|
+
* last. Clicking into a scrollable pane now claims the keys for it.
|
|
2727
|
+
*/
|
|
2728
|
+
const PANE_KEY_FOCUS_REGIONS = {
|
|
2729
|
+
chat: "chat",
|
|
2730
|
+
activity: "activity",
|
|
2731
|
+
inspector: "inspector",
|
|
2732
|
+
// The preview is reached through the inspector's Files tab, and the
|
|
2733
|
+
// controller maps inspector-focus + files-tab back to filePreview.
|
|
2734
|
+
filePreview: "inspector",
|
|
2735
|
+
};
|
|
2736
|
+
|
|
2737
|
+
function focusRegionForPaneKey(paneKey, override = null) {
|
|
2738
|
+
// The DETACHED preview sits in the activity slot, so clicking it must claim
|
|
2739
|
+
// activity focus — that is what distinguishes "clicked the preview" from
|
|
2740
|
+
// "clicked the artifact list", which keeps inspector focus and drives the
|
|
2741
|
+
// selection with the same keys.
|
|
2742
|
+
if (override) return override;
|
|
2743
|
+
return PANE_KEY_FOCUS_REGIONS[String(paneKey || "")] || null;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
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
2747
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
2360
2748
|
const theme = getTheme(themeId);
|
|
2361
2749
|
const ref = React.useRef(null);
|
|
@@ -2366,6 +2754,19 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
2366
2754
|
const normalizedBottomSticky = React.useMemo(() => normalizeLines(bottomStickyLines), [bottomStickyLines]);
|
|
2367
2755
|
const preserveHorizontalScroll = className.includes("is-preserve") && panelClassName.includes("has-preserved-sticky");
|
|
2368
2756
|
|
|
2757
|
+
// Clicking (or touching) a scrollable pane makes the keyboard scroll keys
|
|
2758
|
+
// act on THAT pane. Fires on mousedown rather than click so a drag-select
|
|
2759
|
+
// inside the pane claims focus too.
|
|
2760
|
+
// Mouse only. On touch this fired on every tap, and setFocus runs the
|
|
2761
|
+
// region through normalizeFocusRegion, which rewrites a region missing
|
|
2762
|
+
// from the current layout to order[0] — so tapping the chat on a phone
|
|
2763
|
+
// was silently reassigning focus to the inspector and jumping to the
|
|
2764
|
+
// artifact list. Keyboard scroll targeting is meaningless on touch anyway.
|
|
2765
|
+
const claimFocus = React.useCallback(() => {
|
|
2766
|
+
const region = focusRegionForPaneKey(paneKey, focusRegion);
|
|
2767
|
+
if (region && controller?.setFocus) controller.setFocus(region);
|
|
2768
|
+
}, [controller, paneKey, focusRegion]);
|
|
2769
|
+
|
|
2369
2770
|
const syncScrollLeft = React.useCallback((source, target) => {
|
|
2370
2771
|
if (!source || !target) return;
|
|
2371
2772
|
if (Math.abs((target.scrollLeft || 0) - (source.scrollLeft || 0)) <= 1) return;
|
|
@@ -2413,7 +2814,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
2413
2814
|
normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
|
|
2414
2815
|
)
|
|
2415
2816
|
: null,
|
|
2416
|
-
React.createElement("div", { ref, className: `ps-scroll-panel ${className}${scrollShadow.down ? " is-scrolled-down" : ""}${scrollShadow.up ? " is-scrolled-up" : ""}`.trim(), onScroll: handleBodyScroll,
|
|
2817
|
+
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
2818
|
typeof renderBody === "function"
|
|
2418
2819
|
? renderBody(normalizedLines, theme)
|
|
2419
2820
|
: structuredBlocks
|
|
@@ -2524,6 +2925,36 @@ function SessionRowContent({ row, theme, structured = false }) {
|
|
|
2524
2925
|
: null);
|
|
2525
2926
|
}
|
|
2526
2927
|
|
|
2928
|
+
/**
|
|
2929
|
+
* Reuse the previous value when a freshly computed one is deep-equal.
|
|
2930
|
+
*
|
|
2931
|
+
* The session poll rebuilds sessions.byId whenever ANY session's updatedAt or
|
|
2932
|
+
* iteration count moves, which invalidates every downstream memo and re-renders
|
|
2933
|
+
* the whole list — visibly, every poll — even though the rows that are actually
|
|
2934
|
+
* displayed did not change. Comparing the rendered result and keeping the old
|
|
2935
|
+
* reference stops the churn at the render boundary, without having to chase
|
|
2936
|
+
* which upstream field ticked.
|
|
2937
|
+
*
|
|
2938
|
+
* Sized for lists of tens of rows: the comparison is far cheaper than the
|
|
2939
|
+
* reconciliation it avoids.
|
|
2940
|
+
*/
|
|
2941
|
+
function useStableValue(value) {
|
|
2942
|
+
const ref = React.useRef(value);
|
|
2943
|
+
const previousJson = React.useRef(null);
|
|
2944
|
+
const nextJson = React.useMemo(() => {
|
|
2945
|
+
try {
|
|
2946
|
+
return JSON.stringify(value);
|
|
2947
|
+
} catch {
|
|
2948
|
+
return null; // unserializable: never claim equality
|
|
2949
|
+
}
|
|
2950
|
+
}, [value]);
|
|
2951
|
+
if (nextJson === null || previousJson.current !== nextJson) {
|
|
2952
|
+
previousJson.current = nextJson;
|
|
2953
|
+
ref.current = value;
|
|
2954
|
+
}
|
|
2955
|
+
return ref.current;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2527
2958
|
function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false }) {
|
|
2528
2959
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
2529
2960
|
const theme = getTheme(themeId);
|
|
@@ -2547,7 +2978,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
2547
2978
|
// ever wanted independently.
|
|
2548
2979
|
rich: state.ui.chatViewMode === "rich",
|
|
2549
2980
|
}), shallowEqualObject);
|
|
2550
|
-
const
|
|
2981
|
+
const computedRows = React.useMemo(() => selectSessionRows({
|
|
2551
2982
|
sessions: {
|
|
2552
2983
|
activeSessionId: viewState.activeSessionId,
|
|
2553
2984
|
byId: viewState.sessionsById,
|
|
@@ -2563,6 +2994,8 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
2563
2994
|
mode: viewState.connectionMode,
|
|
2564
2995
|
},
|
|
2565
2996
|
}), [viewState.activeSessionId, viewState.auth, viewState.connectionMode, viewState.filterQuery, viewState.ownerFilter, viewState.pinnedIds, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
|
|
2997
|
+
// Hold the previous rows when a poll produced identical output.
|
|
2998
|
+
const rows = useStableValue(computedRows);
|
|
2566
2999
|
const activeSession = viewState.activeSessionId
|
|
2567
3000
|
? viewState.sessionsById[viewState.activeSessionId] || null
|
|
2568
3001
|
: null;
|
|
@@ -2869,7 +3302,7 @@ function SessionLinkModal({ url, warn, onCopyAgain, onClose }) {
|
|
|
2869
3302
|
React.createElement("div", { className: "ps-link-modal", onClick: stop },
|
|
2870
3303
|
React.createElement("div", { className: "ps-share-modal-head" },
|
|
2871
3304
|
React.createElement("span", null, "Link copied"),
|
|
2872
|
-
React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
|
|
3305
|
+
React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
|
|
2873
3306
|
React.createElement("div", { className: "ps-share-section-sub" },
|
|
2874
3307
|
"Copied to your clipboard — anyone with access can open the session from this link."),
|
|
2875
3308
|
React.createElement("div", { className: "ps-link-row" },
|
|
@@ -3106,7 +3539,7 @@ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel,
|
|
|
3106
3539
|
React.createElement("div", { className: "ps-share-modal", onClick: stop },
|
|
3107
3540
|
React.createElement("div", { className: "ps-share-modal-head" },
|
|
3108
3541
|
React.createElement("span", null, "Manage session"),
|
|
3109
|
-
React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
|
|
3542
|
+
React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
|
|
3110
3543
|
|
|
3111
3544
|
// ── Tab bar ───────────────────────────────────────────────
|
|
3112
3545
|
React.createElement("div", { className: "ps-manage-tabs", role: "tablist" },
|
|
@@ -3393,6 +3826,79 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
|
|
|
3393
3826
|
});
|
|
3394
3827
|
}
|
|
3395
3828
|
|
|
3829
|
+
/**
|
|
3830
|
+
* Mobile artifact viewer — a genuine full-viewport overlay, not a pane.
|
|
3831
|
+
*
|
|
3832
|
+
* Rendered OUTSIDE the workspace and fixed to the viewport, so the portal
|
|
3833
|
+
* header, session list and composer are all covered: opening an artifact on a
|
|
3834
|
+
* phone should feel like pushing a detail screen, the way a native app does,
|
|
3835
|
+
* not like shrinking content into one more box. Desktop never mounts this.
|
|
3836
|
+
*/
|
|
3837
|
+
function MobileArtifactOverlay({ controller }) {
|
|
3838
|
+
const view = useControllerSelector(controller, (state) => {
|
|
3839
|
+
const id = state.files.selectedArtifactId || "";
|
|
3840
|
+
const filename = id ? id.slice(id.indexOf("/") + 1) : "";
|
|
3841
|
+
return {
|
|
3842
|
+
filename,
|
|
3843
|
+
origin: state.files.previewOrigin || null,
|
|
3844
|
+
canPrev: controller.canStepArtifactPreview(-1),
|
|
3845
|
+
canNext: controller.canStepArtifactPreview(1),
|
|
3846
|
+
};
|
|
3847
|
+
}, shallowEqualObject);
|
|
3848
|
+
|
|
3849
|
+
// Wrap toggle applies to CODE only. Markdown always wraps — prose in a
|
|
3850
|
+
// horizontal scroller is unreadable — and its fenced blocks keep their own
|
|
3851
|
+
// behavior. Images have nothing to wrap.
|
|
3852
|
+
const isCode = Boolean(codeLanguageForArtifact(view.filename))
|
|
3853
|
+
|| isDiffArtifact(view.filename);
|
|
3854
|
+
const [contentWidth, setContentWidth] = React.useState(false);
|
|
3855
|
+
React.useEffect(() => { setContentWidth(false); }, [view.filename]);
|
|
3856
|
+
|
|
3857
|
+
const step = React.useCallback((delta) => {
|
|
3858
|
+
controller.stepArtifactPreview(delta).catch(() => {});
|
|
3859
|
+
}, [controller]);
|
|
3860
|
+
|
|
3861
|
+
return React.createElement("div", { className: "ps-artifact-overlay" },
|
|
3862
|
+
React.createElement("header", { className: "ps-artifact-overlay-bar" },
|
|
3863
|
+
React.createElement("button", {
|
|
3864
|
+
type: "button",
|
|
3865
|
+
className: "ps-artifact-overlay-btn",
|
|
3866
|
+
onClick: () => controller.closeArtifactPreview().catch(() => {}),
|
|
3867
|
+
"aria-label": view.origin === "chat" ? "Close, back to chat" : "Close, back to artifacts",
|
|
3868
|
+
}, "✕"),
|
|
3869
|
+
React.createElement("span", { className: "ps-artifact-overlay-title" }, view.filename || "Artifact"),
|
|
3870
|
+
isCode
|
|
3871
|
+
? React.createElement("button", {
|
|
3872
|
+
type: "button",
|
|
3873
|
+
className: `ps-artifact-overlay-btn${contentWidth ? " is-active" : ""}`,
|
|
3874
|
+
onClick: () => setContentWidth((v) => !v),
|
|
3875
|
+
"aria-label": contentWidth ? "Wrap lines" : "Show full lines",
|
|
3876
|
+
title: contentWidth ? "Wrap to screen width" : "Full line width (scroll horizontally)",
|
|
3877
|
+
}, contentWidth ? "⇥" : "↔")
|
|
3878
|
+
: null,
|
|
3879
|
+
React.createElement("button", {
|
|
3880
|
+
type: "button",
|
|
3881
|
+
className: "ps-artifact-overlay-btn",
|
|
3882
|
+
disabled: !view.canPrev,
|
|
3883
|
+
onClick: () => step(-1),
|
|
3884
|
+
"aria-label": "Previous artifact",
|
|
3885
|
+
}, "‹"),
|
|
3886
|
+
React.createElement("button", {
|
|
3887
|
+
type: "button",
|
|
3888
|
+
className: "ps-artifact-overlay-btn",
|
|
3889
|
+
disabled: !view.canNext,
|
|
3890
|
+
onClick: () => step(1),
|
|
3891
|
+
"aria-label": "Next artifact",
|
|
3892
|
+
}, "›")),
|
|
3893
|
+
// No swipe handlers: horizontal drag belongs to the content, so a wide
|
|
3894
|
+
// code line can be scrolled without also changing which file you are on.
|
|
3895
|
+
React.createElement("div", {
|
|
3896
|
+
className: `ps-artifact-overlay-body${contentWidth ? " is-content-width" : ""}`,
|
|
3897
|
+
}, React.createElement(FilesPane, {
|
|
3898
|
+
controller, focused: true, mobile: true, previewOnly: true, nativeScroll: true,
|
|
3899
|
+
})));
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3396
3902
|
function MobileWorkspace({ controller }) {
|
|
3397
3903
|
// The session list is always shown; use the toolbar Focus button to give
|
|
3398
3904
|
// the chat the full screen (the old Show/Hide toggle was redundant with it).
|
|
@@ -3424,7 +3930,11 @@ function InspectorTabs({ activeTab, controller }) {
|
|
|
3424
3930
|
})));
|
|
3425
3931
|
}
|
|
3426
3932
|
|
|
3427
|
-
|
|
3933
|
+
// `previewOnly` renders JUST the artifact preview panel, so the desktop layout
|
|
3934
|
+
// can host it in the activity slot where it gets the existing row resizer.
|
|
3935
|
+
// Detached this way the preview is resizable; nested inside the inspector it
|
|
3936
|
+
// could only ever have half of a pane.
|
|
3937
|
+
function FilesPane({ controller, focused, mobile = false, previewOnly = false, nativeScroll = false }) {
|
|
3428
3938
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
3429
3939
|
const theme = getTheme(themeId);
|
|
3430
3940
|
const fileInputRef = React.useRef(null);
|
|
@@ -3441,6 +3951,8 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3441
3951
|
filesBySessionId: state.files.bySessionId,
|
|
3442
3952
|
filesFilter: state.files.filter,
|
|
3443
3953
|
selectedArtifactId: state.files.selectedArtifactId,
|
|
3954
|
+
markedIds: state.files.markedIds || [],
|
|
3955
|
+
previewOrigin: state.files.previewOrigin || null,
|
|
3444
3956
|
focused,
|
|
3445
3957
|
previewScroll: state.ui.scroll.filePreview,
|
|
3446
3958
|
fullscreen: Boolean(state.files.fullscreen),
|
|
@@ -3502,7 +4014,35 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3502
4014
|
}
|
|
3503
4015
|
}, [controller, viewState.canBrowserUpload, viewState.canPathUpload]);
|
|
3504
4016
|
|
|
4017
|
+
// Declared BEFORE panelActions, which reads it. When this sat further down
|
|
4018
|
+
// the component it was a temporal dead zone violation: panelActions threw
|
|
4019
|
+
// "Cannot access 'markedSet' before initialization" during render, React
|
|
4020
|
+
// unmounted the whole tree, and the portal went blank on opening Artifacts.
|
|
4021
|
+
const markedSet = React.useMemo(() => new Set(viewState.markedIds || []), [viewState.markedIds]);
|
|
4022
|
+
|
|
3505
4023
|
const panelActions = React.createElement(React.Fragment, null,
|
|
4024
|
+
// Bulk actions only appear once something is marked, so the header
|
|
4025
|
+
// stays quiet in the common single-selection case.
|
|
4026
|
+
markedSet.size > 0
|
|
4027
|
+
? React.createElement(React.Fragment, null,
|
|
4028
|
+
React.createElement("span", {
|
|
4029
|
+
className: "ps-mini-button-label",
|
|
4030
|
+
style: { padding: "0 6px", opacity: 0.85 },
|
|
4031
|
+
title: "Cmd/Ctrl-click to mark, Shift-click for a range",
|
|
4032
|
+
}, `${markedSet.size} marked`),
|
|
4033
|
+
viewState.canDeleteArtifacts
|
|
4034
|
+
? React.createElement(IconButton, {
|
|
4035
|
+
icon: "🗑",
|
|
4036
|
+
label: `Delete ${markedSet.size} marked`,
|
|
4037
|
+
onClick: () => controller.deleteMarkedArtifacts().catch(() => {}),
|
|
4038
|
+
})
|
|
4039
|
+
: null,
|
|
4040
|
+
React.createElement(IconButton, {
|
|
4041
|
+
icon: "✕",
|
|
4042
|
+
label: "Clear marks",
|
|
4043
|
+
onClick: () => controller.clearArtifactMarks(),
|
|
4044
|
+
}))
|
|
4045
|
+
: null,
|
|
3506
4046
|
React.createElement("input", {
|
|
3507
4047
|
ref: fileInputRef,
|
|
3508
4048
|
type: "file",
|
|
@@ -3544,12 +4084,26 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3544
4084
|
label: "Filter",
|
|
3545
4085
|
onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {}),
|
|
3546
4086
|
}),
|
|
4087
|
+
// Desktop keeps the plain fullscreen toggle it has always had. The
|
|
4088
|
+
// back affordance belongs to the mobile overlay, which renders its own
|
|
4089
|
+
// top bar — putting it here too would change desktop behavior.
|
|
3547
4090
|
React.createElement(IconButton, {
|
|
3548
4091
|
icon: viewState.fullscreen ? "⇱" : "⛶",
|
|
3549
4092
|
label: viewState.fullscreen ? "Exit fullscreen" : "Fullscreen",
|
|
3550
4093
|
onClick: () => controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {}),
|
|
3551
4094
|
}));
|
|
3552
4095
|
|
|
4096
|
+
// Keyboard selection has to drag the viewport with it, exactly as the
|
|
4097
|
+
// session list does — otherwise j/k walks the selection straight out of
|
|
4098
|
+
// view and the list appears frozen.
|
|
4099
|
+
const selectedItemRef = React.useRef(null);
|
|
4100
|
+
React.useEffect(() => {
|
|
4101
|
+
const node = selectedItemRef.current;
|
|
4102
|
+
if (node && typeof node.scrollIntoView === "function") {
|
|
4103
|
+
node.scrollIntoView({ block: "nearest" });
|
|
4104
|
+
}
|
|
4105
|
+
}, [filesView.selectedIndex, viewState.selectedArtifactId]);
|
|
4106
|
+
|
|
3553
4107
|
const listContent = items.length === 0
|
|
3554
4108
|
? normalizeLines(filesView.listBodyLines || []).map((line, index) => React.createElement(Line, {
|
|
3555
4109
|
key: `empty:${index}`,
|
|
@@ -3559,34 +4113,114 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3559
4113
|
: items.map((item, index) => React.createElement("button", {
|
|
3560
4114
|
key: item.id,
|
|
3561
4115
|
type: "button",
|
|
3562
|
-
|
|
3563
|
-
|
|
4116
|
+
ref: index === filesView.selectedIndex ? selectedItemRef : null,
|
|
4117
|
+
className: `ps-list-button${index === filesView.selectedIndex ? " is-selected" : ""}`
|
|
4118
|
+
+ (markedSet.has(item.id) ? " is-marked" : ""),
|
|
4119
|
+
onClick: (event) => {
|
|
3564
4120
|
controller.setFocus("inspector");
|
|
3565
|
-
|
|
4121
|
+
// Cmd/Ctrl toggles one, Shift extends a run, plain click
|
|
4122
|
+
// selects and drops the marks — the usual list conventions.
|
|
4123
|
+
if (event.metaKey || event.ctrlKey) {
|
|
4124
|
+
controller.toggleArtifactMark(item);
|
|
4125
|
+
return;
|
|
4126
|
+
}
|
|
4127
|
+
if (event.shiftKey) {
|
|
4128
|
+
controller.markArtifactRange(item);
|
|
4129
|
+
return;
|
|
4130
|
+
}
|
|
4131
|
+
controller.clearArtifactMarks();
|
|
4132
|
+
controller.selectFileBrowserItem(item)
|
|
4133
|
+
.then(() => { if (mobile) controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {}); })
|
|
4134
|
+
.catch(() => {});
|
|
3566
4135
|
},
|
|
3567
4136
|
}, React.createElement(Line, {
|
|
3568
4137
|
line: normalizeLines([filesView.listBodyLines?.[index]])[0],
|
|
3569
4138
|
theme,
|
|
3570
4139
|
})));
|
|
3571
4140
|
|
|
3572
|
-
|
|
4141
|
+
// A .patch/.diff artifact is rendered AS a diff — gutter markers, add/remove
|
|
4142
|
+
// tinting — rather than as undifferentiated grey text. This is the whole
|
|
4143
|
+
// point of clicking through from the transcript.
|
|
4144
|
+
const previewReady = !filesView.previewLoading && !filesView.previewError;
|
|
4145
|
+
const previewIsDiff = previewReady
|
|
4146
|
+
&& isDiffArtifact(filesView.selectedFilename, filesView.previewContentType)
|
|
4147
|
+
&& !filesView.previewIsBinary
|
|
4148
|
+
&& Boolean(filesView.previewContent);
|
|
4149
|
+
|
|
4150
|
+
// Images are routed by content type OR extension, and go to the binary
|
|
4151
|
+
// panel even when the payload is text (SVG) — it owns the authenticated
|
|
4152
|
+
// fetch → object URL path that renders an artifact safely.
|
|
4153
|
+
const previewIsImage = previewReady
|
|
4154
|
+
&& (IMAGE_CONTENT_TYPE_RE.test(String(filesView.previewContentType || ""))
|
|
4155
|
+
|| IMAGE_FILENAME_RE.test(String(filesView.selectedFilename || "")));
|
|
4156
|
+
|
|
4157
|
+
// Source files get real syntax highlighting instead of undifferentiated
|
|
4158
|
+
// grey text — the same highlighter the chat code blocks use.
|
|
4159
|
+
const previewCodeLanguage = previewReady
|
|
4160
|
+
&& !filesView.previewIsBinary
|
|
4161
|
+
&& !previewIsDiff
|
|
4162
|
+
&& filesView.previewRenderMode !== "markdown"
|
|
4163
|
+
&& Boolean(filesView.previewContent)
|
|
4164
|
+
? codeLanguageForArtifact(filesView.selectedFilename)
|
|
4165
|
+
: null;
|
|
4166
|
+
|
|
4167
|
+
// Detached (desktop) => the preview is its own pane in the activity slot and
|
|
4168
|
+
// claims activity focus, so arrows/j/k/PgUp scroll IT. Inline (mobile) it is
|
|
4169
|
+
// part of the inspector and keeps inspector focus.
|
|
4170
|
+
const previewFocusRegion = previewOnly ? "activity" : null;
|
|
4171
|
+
// In the mobile overlay the browser owns scrolling outright.
|
|
4172
|
+
const previewPaneKey = nativeScroll ? null : "filePreview";
|
|
4173
|
+
|
|
4174
|
+
const previewPane = previewIsImage
|
|
4175
|
+
? React.createElement(BinaryArtifactPreviewPanel, {
|
|
4176
|
+
title: null,
|
|
4177
|
+
color: "cyan",
|
|
4178
|
+
focused: false,
|
|
4179
|
+
theme,
|
|
4180
|
+
filename: filesView.selectedFilename,
|
|
4181
|
+
contentType: filesView.previewContentType,
|
|
4182
|
+
sizeBytes: filesView.previewSizeBytes,
|
|
4183
|
+
source: filesView.previewSource,
|
|
4184
|
+
uploadedAt: filesView.previewUploadedAt,
|
|
4185
|
+
controller,
|
|
4186
|
+
sessionId: filesView.selectedSessionId,
|
|
4187
|
+
})
|
|
4188
|
+
: previewCodeLanguage
|
|
4189
|
+
? React.createElement(RenderedPreviewPanel, {
|
|
4190
|
+
controller, title: null, color: "cyan", focused: false, theme,
|
|
4191
|
+
paneKey: previewPaneKey, scrollOffset: viewState.previewScroll,
|
|
4192
|
+
className: "ps-diff-preview", focusRegion: previewFocusRegion,
|
|
4193
|
+
},
|
|
4194
|
+
React.createElement("pre", { className: "ps-md-code-pre" },
|
|
4195
|
+
React.createElement("code", null,
|
|
4196
|
+
renderHighlightedCode(filesView.previewContent, previewCodeLanguage, theme))))
|
|
4197
|
+
: previewIsDiff
|
|
4198
|
+
? React.createElement(RenderedPreviewPanel, {
|
|
4199
|
+
controller, title: null, color: "cyan", focused: false, theme,
|
|
4200
|
+
paneKey: previewPaneKey, scrollOffset: viewState.previewScroll,
|
|
4201
|
+
className: "ps-diff-preview", focusRegion: previewFocusRegion,
|
|
4202
|
+
},
|
|
4203
|
+
React.createElement("pre", { className: "ps-md-code-pre is-diff" },
|
|
4204
|
+
React.createElement("code", null, renderDiffCode(filesView.previewContent, theme))))
|
|
4205
|
+
: filesView.previewRenderMode === "markdown"
|
|
3573
4206
|
&& !filesView.previewLoading
|
|
3574
4207
|
&& !filesView.previewError
|
|
3575
4208
|
? React.createElement(MarkdownPreviewPanel, {
|
|
3576
4209
|
controller,
|
|
3577
|
-
title:
|
|
4210
|
+
title: null,
|
|
3578
4211
|
color: "cyan",
|
|
3579
4212
|
focused: false,
|
|
3580
4213
|
scrollOffset: viewState.previewScroll,
|
|
3581
|
-
paneKey:
|
|
4214
|
+
paneKey: previewPaneKey,
|
|
3582
4215
|
theme,
|
|
3583
|
-
|
|
4216
|
+
focusRegion: previewFocusRegion,
|
|
4217
|
+
content: stripYamlFrontmatter(filesView.previewContent || ""),
|
|
3584
4218
|
})
|
|
3585
4219
|
: filesView.previewIsBinary
|
|
3586
4220
|
&& !filesView.previewLoading
|
|
3587
4221
|
&& !filesView.previewError
|
|
3588
4222
|
? React.createElement(BinaryArtifactPreviewPanel, {
|
|
3589
|
-
title:
|
|
4223
|
+
title: null,
|
|
3590
4224
|
color: "cyan",
|
|
3591
4225
|
focused: false,
|
|
3592
4226
|
theme,
|
|
@@ -3600,17 +4234,20 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3600
4234
|
})
|
|
3601
4235
|
: React.createElement(ScrollLinesPanel, {
|
|
3602
4236
|
controller,
|
|
3603
|
-
title:
|
|
4237
|
+
title: null,
|
|
3604
4238
|
color: "cyan",
|
|
3605
4239
|
focused: false,
|
|
3606
4240
|
lines: filesView.previewLines,
|
|
3607
4241
|
scrollOffset: viewState.previewScroll,
|
|
3608
4242
|
scrollMode: "top",
|
|
3609
|
-
paneKey:
|
|
4243
|
+
paneKey: previewPaneKey,
|
|
4244
|
+
focusRegion: previewFocusRegion,
|
|
3610
4245
|
className: "is-preview is-wrapped",
|
|
3611
4246
|
});
|
|
3612
4247
|
const view = viewState;
|
|
3613
4248
|
|
|
4249
|
+
if (previewOnly) return previewPane;
|
|
4250
|
+
|
|
3614
4251
|
return React.createElement(Panel, {
|
|
3615
4252
|
title: view.fullscreen
|
|
3616
4253
|
? filesView.fullscreenTitle
|
|
@@ -3626,10 +4263,16 @@ function FilesPane({ controller, focused, mobile = false }) {
|
|
|
3626
4263
|
// Fullscreen files mode shows only the preview pane; the list stays hidden.
|
|
3627
4264
|
view.fullscreen
|
|
3628
4265
|
? previewPane
|
|
3629
|
-
: React.createElement("div", {
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
4266
|
+
: React.createElement("div", {
|
|
4267
|
+
// The pane is a LIST, full stop. Desktop detaches the preview into
|
|
4268
|
+
// the activity slot; mobile opens the full-screen overlay on tap.
|
|
4269
|
+
// Neither wants a cramped preview stacked under the list.
|
|
4270
|
+
className: "ps-files-grid is-list-only",
|
|
4271
|
+
},
|
|
4272
|
+
// No nested panel: the enclosing Files pane is already the box, and
|
|
4273
|
+
// a second border + header inside it only ate vertical space to
|
|
4274
|
+
// restate what the pane title says.
|
|
4275
|
+
React.createElement("div", { className: "ps-action-list ps-files-list" }, listContent),
|
|
3633
4276
|
));
|
|
3634
4277
|
}
|
|
3635
4278
|
|
|
@@ -3868,9 +4511,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
|
|
|
3868
4511
|
structuredRows: mobile,
|
|
3869
4512
|
actions: React.createElement("button", {
|
|
3870
4513
|
type: "button",
|
|
3871
|
-
className: "ps-mini-button",
|
|
4514
|
+
className: "ps-mini-button ps-overlay-close",
|
|
3872
4515
|
onClick: onClose,
|
|
3873
|
-
|
|
4516
|
+
"aria-label": "Close",
|
|
4517
|
+
title: "Close",
|
|
4518
|
+
}, "✕"),
|
|
3874
4519
|
});
|
|
3875
4520
|
} else if (pane === "inspector") {
|
|
3876
4521
|
content = React.createElement(InspectorPane, {
|
|
@@ -3879,9 +4524,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
|
|
|
3879
4524
|
panelClassName: "ps-chat-focus-pane",
|
|
3880
4525
|
extraActions: React.createElement("button", {
|
|
3881
4526
|
type: "button",
|
|
3882
|
-
className: "ps-mini-button",
|
|
4527
|
+
className: "ps-mini-button ps-overlay-close",
|
|
3883
4528
|
onClick: onClose,
|
|
3884
|
-
|
|
4529
|
+
"aria-label": "Close",
|
|
4530
|
+
title: "Close",
|
|
4531
|
+
}, "✕"),
|
|
3885
4532
|
});
|
|
3886
4533
|
} else if (pane === "activity") {
|
|
3887
4534
|
content = React.createElement(ActivityPane, {
|
|
@@ -3889,9 +4536,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
|
|
|
3889
4536
|
panelClassName: "ps-chat-focus-pane",
|
|
3890
4537
|
extraActions: React.createElement("button", {
|
|
3891
4538
|
type: "button",
|
|
3892
|
-
className: "ps-mini-button",
|
|
4539
|
+
className: "ps-mini-button ps-overlay-close",
|
|
3893
4540
|
onClick: onClose,
|
|
3894
|
-
|
|
4541
|
+
"aria-label": "Close",
|
|
4542
|
+
title: "Close",
|
|
4543
|
+
}, "✕"),
|
|
3895
4544
|
});
|
|
3896
4545
|
}
|
|
3897
4546
|
|
|
@@ -4422,7 +5071,11 @@ function Toolbar({ controller, mobile, chatFocusMode = false, onToggleChatFocus
|
|
|
4422
5071
|
const [headerSlot, setHeaderSlot] = React.useState(null);
|
|
4423
5072
|
React.useEffect(() => {
|
|
4424
5073
|
if (typeof document === "undefined") return;
|
|
4425
|
-
|
|
5074
|
+
// Desktop centres the toolbar in the portal header regardless of chat
|
|
5075
|
+
// view — the terminal view was spending a whole strip on it while the
|
|
5076
|
+
// rich view had already handed that row back to the panes. Mobile keeps
|
|
5077
|
+
// the inline strip; there is no header room for it on a phone.
|
|
5078
|
+
setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
|
|
4426
5079
|
}, [richUi, mobile]);
|
|
4427
5080
|
const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
|
|
4428
5081
|
const chatView = useControllerSelector(controller, (state) => ({
|
|
@@ -4539,21 +5192,35 @@ function ColumnResizeHandle({ controller, paneAdjust = 0 }) {
|
|
|
4539
5192
|
document.body.classList.remove("is-resizing-pane-x");
|
|
4540
5193
|
};
|
|
4541
5194
|
|
|
4542
|
-
|
|
5195
|
+
// Apply at most one adjustment per frame. A drag emits pointermove far
|
|
5196
|
+
// faster than the browser paints, and each adjust re-renders every
|
|
5197
|
+
// pane — so most of that work was discarded before being displayed,
|
|
5198
|
+
// which is what made dragging a loaded layout feel heavy.
|
|
5199
|
+
let movePending = 0;
|
|
5200
|
+
let pendingClientX = 0;
|
|
5201
|
+
const applyMove = () => {
|
|
5202
|
+
movePending = 0;
|
|
4543
5203
|
const dragState = dragStateRef.current;
|
|
4544
5204
|
if (!dragState) return;
|
|
4545
|
-
const deltaCells = Math.round((
|
|
5205
|
+
const deltaCells = Math.round((pendingClientX - dragState.startX) / GRID_CELL_WIDTH);
|
|
4546
5206
|
const deltaIncrement = deltaCells - dragState.appliedCells;
|
|
4547
5207
|
if (!deltaIncrement) return;
|
|
4548
5208
|
controller.adjustPaneSplit(deltaIncrement);
|
|
4549
5209
|
dragState.appliedCells = deltaCells;
|
|
4550
5210
|
};
|
|
5211
|
+
const onPointerMove = (event) => {
|
|
5212
|
+
if (!dragStateRef.current) return;
|
|
5213
|
+
pendingClientX = event.clientX;
|
|
5214
|
+
if (movePending) return;
|
|
5215
|
+
movePending = requestAnimationFrame(applyMove);
|
|
5216
|
+
};
|
|
4551
5217
|
|
|
4552
5218
|
window.addEventListener("pointermove", onPointerMove);
|
|
4553
5219
|
window.addEventListener("pointerup", stopDragging);
|
|
4554
5220
|
window.addEventListener("pointercancel", stopDragging);
|
|
4555
5221
|
|
|
4556
5222
|
return () => {
|
|
5223
|
+
if (movePending) { cancelAnimationFrame(movePending); movePending = 0; }
|
|
4557
5224
|
window.removeEventListener("pointermove", onPointerMove);
|
|
4558
5225
|
window.removeEventListener("pointerup", stopDragging);
|
|
4559
5226
|
window.removeEventListener("pointercancel", stopDragging);
|
|
@@ -4997,7 +5664,7 @@ function ModalLayer({ controller }) {
|
|
|
4997
5664
|
React.createElement("div", { className: `ps-modal${modal.type === "themePicker" ? " is-theme-picker" : ""}`, onClick: (event) => event.stopPropagation() },
|
|
4998
5665
|
React.createElement("div", { className: "ps-modal-header" },
|
|
4999
5666
|
React.createElement("div", { className: "ps-modal-title" }, presentation.title),
|
|
5000
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5667
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
5001
5668
|
),
|
|
5002
5669
|
React.createElement("div", { className: "ps-modal-grid" },
|
|
5003
5670
|
React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
|
|
@@ -5029,7 +5696,7 @@ function ModalLayer({ controller }) {
|
|
|
5029
5696
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
5030
5697
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5031
5698
|
React.createElement("div", { className: "ps-modal-title" }, modalState.confirm.title),
|
|
5032
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5699
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
5033
5700
|
),
|
|
5034
5701
|
React.createElement("div", { className: "ps-modal-body", style: { padding: "16px 20px" } },
|
|
5035
5702
|
React.createElement("p", { style: { color: "#94a3b8", margin: 0 } }, modalState.confirm.message),
|
|
@@ -5048,6 +5715,9 @@ function ModalLayer({ controller }) {
|
|
|
5048
5715
|
React.createElement("option", { value: "deterministic" }, "Fast (no LLM — tail + pointers)"),
|
|
5049
5716
|
),
|
|
5050
5717
|
),
|
|
5718
|
+
(extras.distillMode || "llm") === "llm"
|
|
5719
|
+
? React.createElement(DistillerModelPickers, { controller, extras })
|
|
5720
|
+
: null,
|
|
5051
5721
|
(extras.distillMode || "llm") === "llm"
|
|
5052
5722
|
? React.createElement("textarea", {
|
|
5053
5723
|
className: "ps-modal-input",
|
|
@@ -5100,7 +5770,7 @@ function ModalLayer({ controller }) {
|
|
|
5100
5770
|
React.createElement("div", { className: "ps-modal", onClick: (event) => event.stopPropagation() },
|
|
5101
5771
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5102
5772
|
React.createElement("div", { className: "ps-modal-title" }, presentation.title),
|
|
5103
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5773
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
5104
5774
|
),
|
|
5105
5775
|
React.createElement("div", { className: "ps-modal-grid" },
|
|
5106
5776
|
React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
|
|
@@ -5132,7 +5802,7 @@ function ModalLayer({ controller }) {
|
|
|
5132
5802
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
5133
5803
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5134
5804
|
React.createElement("div", { className: "ps-modal-title" }, modalState.renameSession.title),
|
|
5135
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5805
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
5136
5806
|
),
|
|
5137
5807
|
React.createElement("input", {
|
|
5138
5808
|
ref: renameInputRef,
|
|
@@ -5165,7 +5835,7 @@ function ModalLayer({ controller }) {
|
|
|
5165
5835
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
5166
5836
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5167
5837
|
React.createElement("div", { className: "ps-modal-title" }, modalState.sessionGroupName.title),
|
|
5168
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5838
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
5169
5839
|
),
|
|
5170
5840
|
React.createElement("input", {
|
|
5171
5841
|
ref: groupNameInputRef,
|
|
@@ -5210,7 +5880,7 @@ function ModalLayer({ controller }) {
|
|
|
5210
5880
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
5211
5881
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5212
5882
|
React.createElement("div", { className: "ps-modal-title" }, modal.title || "Session Lifecycle"),
|
|
5213
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5883
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
5214
5884
|
),
|
|
5215
5885
|
React.createElement("div", { className: "ps-modal-body", style: { padding: "12px 16px 4px" } },
|
|
5216
5886
|
React.createElement("p", { style: { color: "#94a3b8", margin: 0 } }, bodyText)),
|
|
@@ -5255,7 +5925,7 @@ function ModalLayer({ controller }) {
|
|
|
5255
5925
|
React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
|
|
5256
5926
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5257
5927
|
React.createElement("div", { className: "ps-modal-title" }, modalState.artifactUpload.title),
|
|
5258
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5928
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
5259
5929
|
),
|
|
5260
5930
|
React.createElement("input", {
|
|
5261
5931
|
className: "ps-modal-input",
|
|
@@ -5289,7 +5959,7 @@ function ModalLayer({ controller }) {
|
|
|
5289
5959
|
React.createElement("div", { className: "ps-modal is-wide", onClick: (event) => event.stopPropagation() },
|
|
5290
5960
|
React.createElement("div", { className: "ps-modal-header" },
|
|
5291
5961
|
React.createElement("div", { className: "ps-modal-title" }, filterPresentation.title),
|
|
5292
|
-
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close
|
|
5962
|
+
React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
|
|
5293
5963
|
),
|
|
5294
5964
|
React.createElement("div", { className: "ps-filter-grid" },
|
|
5295
5965
|
(modal.items || []).map((item, itemIndex) => {
|
|
@@ -5327,6 +5997,75 @@ function ModalLayer({ controller }) {
|
|
|
5327
5997
|
return null;
|
|
5328
5998
|
}
|
|
5329
5999
|
|
|
6000
|
+
// Distiller model / effort / context-tier pickers for the regenerate confirm.
|
|
6001
|
+
// The distiller runs on deployment machinery config rather than the served
|
|
6002
|
+
// session's model, so these are explicit choices; empty means "deployment
|
|
6003
|
+
// default". Effort and tier lists follow the SELECTED model, so an
|
|
6004
|
+
// unsupported combination cannot be submitted.
|
|
6005
|
+
function DistillerModelPickers({ controller, extras }) {
|
|
6006
|
+
const options = Array.isArray(extras.distillerModelOptions) ? extras.distillerModelOptions : [];
|
|
6007
|
+
const selected = options.find((m) => m.qualifiedName === extras.distillerModel) || null;
|
|
6008
|
+
const efforts = selected?.supportedReasoningEfforts || [];
|
|
6009
|
+
const tiers = selected?.supportedContextTiers || [];
|
|
6010
|
+
const labelStyle = { color: "#94a3b8", fontSize: "12px", display: "flex", alignItems: "center", gap: 8 };
|
|
6011
|
+
const selectStyle = { flex: "1", padding: "4px 8px" };
|
|
6012
|
+
|
|
6013
|
+
const pick = (patch) => controller.updateConfirmExtras(patch);
|
|
6014
|
+
|
|
6015
|
+
return React.createElement(React.Fragment, null,
|
|
6016
|
+
React.createElement("label", { style: labelStyle },
|
|
6017
|
+
"Distiller model",
|
|
6018
|
+
React.createElement("select", {
|
|
6019
|
+
className: "ps-modal-input",
|
|
6020
|
+
style: selectStyle,
|
|
6021
|
+
value: extras.distillerModel || "",
|
|
6022
|
+
// Switching model clears effort/tier: the previous values may
|
|
6023
|
+
// not exist on the new model.
|
|
6024
|
+
onChange: (event) => pick({
|
|
6025
|
+
distillerModel: event.currentTarget.value,
|
|
6026
|
+
distillerEffort: "",
|
|
6027
|
+
distillerContextTier: "",
|
|
6028
|
+
}),
|
|
6029
|
+
},
|
|
6030
|
+
React.createElement("option", { value: "" }, "Deployment default"),
|
|
6031
|
+
options.map((m) => React.createElement("option", {
|
|
6032
|
+
key: m.qualifiedName,
|
|
6033
|
+
value: m.qualifiedName,
|
|
6034
|
+
}, m.qualifiedName)),
|
|
6035
|
+
),
|
|
6036
|
+
),
|
|
6037
|
+
efforts.length > 0
|
|
6038
|
+
? React.createElement("label", { style: labelStyle },
|
|
6039
|
+
"Reasoning",
|
|
6040
|
+
React.createElement("select", {
|
|
6041
|
+
className: "ps-modal-input",
|
|
6042
|
+
style: selectStyle,
|
|
6043
|
+
value: extras.distillerEffort || "",
|
|
6044
|
+
onChange: (event) => pick({ distillerEffort: event.currentTarget.value }),
|
|
6045
|
+
},
|
|
6046
|
+
React.createElement("option", { value: "" }, "Model default"),
|
|
6047
|
+
efforts.map((e) => React.createElement("option", { key: e, value: e }, e)),
|
|
6048
|
+
),
|
|
6049
|
+
)
|
|
6050
|
+
: null,
|
|
6051
|
+
tiers.length > 0
|
|
6052
|
+
? React.createElement("label", { style: labelStyle },
|
|
6053
|
+
"Context",
|
|
6054
|
+
React.createElement("select", {
|
|
6055
|
+
className: "ps-modal-input",
|
|
6056
|
+
style: selectStyle,
|
|
6057
|
+
value: extras.distillerContextTier || "",
|
|
6058
|
+
onChange: (event) => pick({ distillerContextTier: event.currentTarget.value }),
|
|
6059
|
+
},
|
|
6060
|
+
React.createElement("option", { value: "" }, "Model default"),
|
|
6061
|
+
tiers.map((t) => React.createElement("option", { key: t, value: t },
|
|
6062
|
+
t === "long_context" ? "long context (fits a larger transcript)" : t)),
|
|
6063
|
+
),
|
|
6064
|
+
)
|
|
6065
|
+
: null,
|
|
6066
|
+
);
|
|
6067
|
+
}
|
|
6068
|
+
|
|
5330
6069
|
function useKeyboardShortcuts(controller, mobile) {
|
|
5331
6070
|
React.useEffect(() => {
|
|
5332
6071
|
const handler = (event) => {
|
|
@@ -5595,6 +6334,23 @@ function useKeyboardShortcuts(controller, mobile) {
|
|
|
5595
6334
|
controller.handleCommand(UI_COMMANDS.FOCUS_RIGHT).catch(() => {});
|
|
5596
6335
|
return;
|
|
5597
6336
|
}
|
|
6337
|
+
// The Files tab is a LIST, so arrows/j/k must move the selection
|
|
6338
|
+
// there the same way they do in the session list — otherwise they
|
|
6339
|
+
// fall through to the generic scroll below and the artifact list
|
|
6340
|
+
// cannot be driven from the keyboard at all. Must precede the
|
|
6341
|
+
// scroll fallback.
|
|
6342
|
+
if (!mobile && focusRegion === "inspector" && currentInspectorTab === "files"
|
|
6343
|
+
&& (event.key === "ArrowUp" || event.key === "k")) {
|
|
6344
|
+
event.preventDefault();
|
|
6345
|
+
controller.handleCommand(UI_COMMANDS.MOVE_FILE_UP).catch(() => {});
|
|
6346
|
+
return;
|
|
6347
|
+
}
|
|
6348
|
+
if (!mobile && focusRegion === "inspector" && currentInspectorTab === "files"
|
|
6349
|
+
&& (event.key === "ArrowDown" || event.key === "j")) {
|
|
6350
|
+
event.preventDefault();
|
|
6351
|
+
controller.handleCommand(UI_COMMANDS.MOVE_FILE_DOWN).catch(() => {});
|
|
6352
|
+
return;
|
|
6353
|
+
}
|
|
5598
6354
|
if (!mobile && (event.key === "ArrowUp" || event.key === "k")) {
|
|
5599
6355
|
event.preventDefault();
|
|
5600
6356
|
controller.handleCommand(UI_COMMANDS.SCROLL_UP).catch(() => {});
|
|
@@ -5756,9 +6512,17 @@ function AdminConsolePanel({ controller }) {
|
|
|
5756
6512
|
|
|
5757
6513
|
export function createWebPilotSwarmController({ transport, mode = "remote", branding = null } = {}) {
|
|
5758
6514
|
clearBrowserPreferenceCache();
|
|
6515
|
+
// Rich prose is the right default on a desktop transcript; on a phone the
|
|
6516
|
+
// terminal view fits far more per screen and does not reflow wide content.
|
|
6517
|
+
// This is only the DEFAULT — a stored profile preference still wins once
|
|
6518
|
+
// settings load, so an explicit choice is never overridden.
|
|
6519
|
+
const isNarrowViewport = typeof window !== "undefined"
|
|
6520
|
+
&& typeof window.matchMedia === "function"
|
|
6521
|
+
&& window.matchMedia("(max-width: 920px)").matches;
|
|
5759
6522
|
const store = createStore(appReducer, createInitialState({
|
|
5760
6523
|
mode,
|
|
5761
6524
|
branding,
|
|
6525
|
+
chatViewMode: isNarrowViewport ? "transcript" : "rich",
|
|
5762
6526
|
}));
|
|
5763
6527
|
return new PilotSwarmUiController({ store, transport });
|
|
5764
6528
|
}
|
|
@@ -5835,10 +6599,15 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5835
6599
|
focusRegion: rootState.ui.focusRegion,
|
|
5836
6600
|
inspectorTab: rootState.ui.inspectorTab,
|
|
5837
6601
|
filesFullscreen: Boolean(rootState.files.fullscreen),
|
|
6602
|
+
selectedArtifactId: rootState.files.selectedArtifactId || null,
|
|
5838
6603
|
adminVisible: Boolean(rootState.admin?.visible),
|
|
5839
6604
|
}), shallowEqualObject);
|
|
5840
6605
|
const profileSettingsHydratedRef = React.useRef(false);
|
|
5841
6606
|
const lastProfileSettingsJsonRef = React.useRef(null);
|
|
6607
|
+
// Last-known value of the OTHER device class's chat-view slot. The save
|
|
6608
|
+
// endpoint replaces the whole settings object, so this is what keeps a
|
|
6609
|
+
// desktop save from erasing the phone's preference.
|
|
6610
|
+
const otherChatViewModeRef = React.useRef(null);
|
|
5842
6611
|
const profileSettingsSaveTimerRef = React.useRef(null);
|
|
5843
6612
|
const profileSettingsPollTimerRef = React.useRef(null);
|
|
5844
6613
|
const profileSettingsPollInFlightRef = React.useRef(false);
|
|
@@ -5892,13 +6661,21 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5892
6661
|
// the principal is resolved by now (it was likely null at mount),
|
|
5893
6662
|
// so the "no persisted filter" fallback becomes the correct
|
|
5894
6663
|
// principal-scoped default (Me+System) instead of {all:true}.
|
|
5895
|
-
|
|
6664
|
+
const remoteNormalized = normalizeProfileSettings(profile?.profileSettings);
|
|
6665
|
+
if (isChatViewMode(remoteNormalized[otherChatViewModeKey()])) {
|
|
6666
|
+
otherChatViewModeRef.current = remoteNormalized[otherChatViewModeKey()];
|
|
6667
|
+
}
|
|
6668
|
+
defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
|
|
6669
|
+
controller.getState(), otherChatViewModeRef.current,
|
|
6670
|
+
);
|
|
5896
6671
|
const settings = materializeProfileSettings(
|
|
5897
6672
|
profile?.profileSettings,
|
|
5898
6673
|
defaultProfileSettingsRef.current,
|
|
5899
6674
|
);
|
|
5900
6675
|
const settingsJson = JSON.stringify(settings);
|
|
5901
|
-
const currentSettingsBeforeApply = profileSettingsFromViewState(
|
|
6676
|
+
const currentSettingsBeforeApply = profileSettingsFromViewState(
|
|
6677
|
+
controller.getState(), otherChatViewModeRef.current,
|
|
6678
|
+
);
|
|
5902
6679
|
const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
|
|
5903
6680
|
const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
|
|
5904
6681
|
&& lastProfileSettingsJsonRef.current != null
|
|
@@ -5911,7 +6688,9 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5911
6688
|
appliedProfileSettingsJsonRef.current = settingsJson;
|
|
5912
6689
|
}
|
|
5913
6690
|
|
|
5914
|
-
const currentSettings = profileSettingsFromViewState(
|
|
6691
|
+
const currentSettings = profileSettingsFromViewState(
|
|
6692
|
+
controller.getState(), otherChatViewModeRef.current,
|
|
6693
|
+
);
|
|
5915
6694
|
lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
|
|
5916
6695
|
profileSettingsHydratedRef.current = true;
|
|
5917
6696
|
} catch {
|
|
@@ -5943,7 +6722,7 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5943
6722
|
|
|
5944
6723
|
React.useEffect(() => {
|
|
5945
6724
|
if (!profileSettingsHydratedRef.current) return undefined;
|
|
5946
|
-
const settings = profileSettingsFromViewState(state);
|
|
6725
|
+
const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current);
|
|
5947
6726
|
const settingsJson = JSON.stringify(settings);
|
|
5948
6727
|
if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
|
|
5949
6728
|
lastProfileSettingsJsonRef.current = settingsJson;
|
|
@@ -5966,7 +6745,19 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5966
6745
|
applyDocumentTheme(state.themeId);
|
|
5967
6746
|
}, [state.themeId]);
|
|
5968
6747
|
|
|
6748
|
+
// Follow focus only on an actual TRANSITION, never on the first run.
|
|
6749
|
+
//
|
|
6750
|
+
// The default focus is "sessions", but the mobile layout has no sessions
|
|
6751
|
+
// region, so normalizeFocusRegion rewrites it to order[0] — the inspector.
|
|
6752
|
+
// Syncing that initial value made a plain page load open the inspector on
|
|
6753
|
+
// its default (sequence) tab instead of the chat. A real focus change, e.g.
|
|
6754
|
+
// tapping the mobile nav, still switches panes.
|
|
6755
|
+
const lastFocusRef = React.useRef(null);
|
|
5969
6756
|
React.useEffect(() => {
|
|
6757
|
+
const previous = lastFocusRef.current;
|
|
6758
|
+
lastFocusRef.current = state.focusRegion;
|
|
6759
|
+
if (previous === null) return;
|
|
6760
|
+
if (previous === state.focusRegion) return;
|
|
5970
6761
|
if (mobile && state.focusRegion !== "prompt") {
|
|
5971
6762
|
setMobilePane(state.focusRegion === "activity"
|
|
5972
6763
|
? "activity"
|
|
@@ -5988,6 +6779,12 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
5988
6779
|
[gridViewport, state.activityPaneAdjust, state.paneAdjust, effectivePromptRows, state.sessionPaneAdjust],
|
|
5989
6780
|
);
|
|
5990
6781
|
const filesFullscreenActive = state.filesFullscreen && state.inspectorTab === "files";
|
|
6782
|
+
// Preview detaches into the activity slot only while the Files tab is open
|
|
6783
|
+
// AND something is selected — otherwise Activity keeps the slot. Fullscreen
|
|
6784
|
+
// is excluded because it already shows the preview alone.
|
|
6785
|
+
const artifactPreviewDetached = state.inspectorTab === "files"
|
|
6786
|
+
&& Boolean(state.selectedArtifactId)
|
|
6787
|
+
&& !filesFullscreenActive;
|
|
5991
6788
|
|
|
5992
6789
|
React.useEffect(() => {
|
|
5993
6790
|
if (!filesFullscreenActive || !chatFocusMode) return;
|
|
@@ -6077,7 +6874,12 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
6077
6874
|
className: "ps-workspace-pane-slot",
|
|
6078
6875
|
style: { gridRow: "3" },
|
|
6079
6876
|
},
|
|
6080
|
-
|
|
6877
|
+
// The artifact preview takes over the activity slot while an artifact
|
|
6878
|
+
// is selected, so it inherits the row resizer and can be sized freely.
|
|
6879
|
+
// It yields back to Activity the moment the selection clears.
|
|
6880
|
+
artifactPreviewDetached
|
|
6881
|
+
? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
|
|
6882
|
+
: (!layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null))));
|
|
6081
6883
|
const chatFocusWorkspace = React.createElement(ChatFocusWorkspace, {
|
|
6082
6884
|
controller,
|
|
6083
6885
|
openPane: chatFocusPane,
|
|
@@ -6089,7 +6891,9 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
6089
6891
|
React.createElement(InspectorPane, { controller, mobile: false }));
|
|
6090
6892
|
|
|
6091
6893
|
let mobileContent = null;
|
|
6092
|
-
|
|
6894
|
+
// Mobile gets the overlay below instead of an in-pane fullscreen view; the
|
|
6895
|
+
// workspace keeps rendering underneath so backing out restores it intact.
|
|
6896
|
+
if (filesFullscreenActive && !mobile) mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
|
|
6093
6897
|
React.createElement(InspectorPane, { controller, mobile: true }));
|
|
6094
6898
|
else if (mobilePane === "inspector") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
|
|
6095
6899
|
React.createElement(InspectorPane, { controller, mobile: true }));
|
|
@@ -6097,7 +6901,8 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
6097
6901
|
React.createElement(ActivityPane, { controller }));
|
|
6098
6902
|
else mobileContent = React.createElement(MobileWorkspace, { controller });
|
|
6099
6903
|
|
|
6100
|
-
return React.createElement(
|
|
6904
|
+
return React.createElement(ControllerContext.Provider, { value: controller },
|
|
6905
|
+
React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
|
|
6101
6906
|
// Hide the top toolbar on mobile inspector/activity panes (and in
|
|
6102
6907
|
// chat-focus mode). Those panes are pure read-only surfaces;
|
|
6103
6908
|
// session/model/theme actions stay reachable from the Main pane,
|
|
@@ -6122,5 +6927,8 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
6122
6927
|
? chatFocusWorkspace
|
|
6123
6928
|
: (mobile ? mobileContent : desktopWorkspace)))),
|
|
6124
6929
|
mobile && !chatFocusMode ? React.createElement(MobileNav, { activePane: mobilePane, setActivePane: setMobilePane, controller }) : null,
|
|
6125
|
-
|
|
6930
|
+
mobile && filesFullscreenActive
|
|
6931
|
+
? React.createElement(MobileArtifactOverlay, { controller })
|
|
6932
|
+
: null,
|
|
6933
|
+
React.createElement(ModalLayer, { controller })));
|
|
6126
6934
|
}
|