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.
Files changed (79) hide show
  1. package/package.json +5 -3
  2. package/ui/core/src/controller.js +328 -5
  3. package/ui/core/src/formatting.js +46 -1
  4. package/ui/core/src/history.js +29 -0
  5. package/ui/core/src/reducer.js +49 -0
  6. package/ui/core/src/selectors.js +154 -13
  7. package/ui/core/src/state.js +11 -0
  8. package/ui/core/src/themes/github-light.js +5 -1
  9. package/ui/core/src/themes/index.js +4 -0
  10. package/ui/core/src/themes/light-high-contrast.js +4 -1
  11. package/ui/core/src/themes/ms-dos.js +84 -0
  12. package/ui/core/src/themes/win95.js +81 -0
  13. package/ui/react/src/web-app.js +886 -78
  14. package/web/dist/assets/{abnfDiagram-VRR7QNED-BHkTpRkx.js → abnfDiagram-VRR7QNED-C4qRR7va.js} +1 -1
  15. package/web/dist/assets/{arc-BOgZyEWA.js → arc-BQw2V2pL.js} +1 -1
  16. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DeE8fDMy.js → architectureDiagram-ZJ3FMSHR-DMdWnLGG.js} +1 -1
  17. package/web/dist/assets/{blockDiagram-677ZJIJ3-CzhdxVEu.js → blockDiagram-677ZJIJ3-DZnALw8j.js} +1 -1
  18. package/web/dist/assets/{c4Diagram-LMCZKHZV-Cc6p_IKP.js → c4Diagram-LMCZKHZV-BGGZ42iX.js} +1 -1
  19. package/web/dist/assets/channel-CB157dQ5.js +1 -0
  20. package/web/dist/assets/{chunk-2Q5K7J3B-BCo94LTM.js → chunk-2Q5K7J3B-Dastiegd.js} +1 -1
  21. package/web/dist/assets/{chunk-32BRIVSS-DaklJ-6i.js → chunk-32BRIVSS-B4XexRzf.js} +1 -1
  22. package/web/dist/assets/{chunk-5VM5RSS4-BN_wg2pr.js → chunk-5VM5RSS4-D66MNGCN.js} +1 -1
  23. package/web/dist/assets/{chunk-EX3LRPZG-DSFFLUoL.js → chunk-EX3LRPZG-DeqPS4Xm.js} +1 -1
  24. package/web/dist/assets/{chunk-JWPE2WC7-DB4dAjza.js → chunk-JWPE2WC7-DIMWiXpM.js} +1 -1
  25. package/web/dist/assets/{chunk-MOJQB5TN-CRM5eGQS.js → chunk-MOJQB5TN-vTyL0qgx.js} +1 -1
  26. package/web/dist/assets/{chunk-RYQCIY6F-BFlkjazU.js → chunk-RYQCIY6F-C7U9MKGw.js} +1 -1
  27. package/web/dist/assets/{chunk-V7JOEXUC-Bnfh4AAv.js → chunk-V7JOEXUC-BzPwQ7n3.js} +1 -1
  28. package/web/dist/assets/{chunk-VR4S4FIN-CQc4_qVM.js → chunk-VR4S4FIN-CmDsyK3b.js} +1 -1
  29. package/web/dist/assets/{chunk-XXDRQBXY-CZivyoDm.js → chunk-XXDRQBXY-ZJn6BaGL.js} +1 -1
  30. package/web/dist/assets/classDiagram-OUVF2IWQ-B6JwBYlv.js +1 -0
  31. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B6JwBYlv.js +1 -0
  32. package/web/dist/assets/{cose-bilkent-JH36ORCC-BUibhTD-.js → cose-bilkent-JH36ORCC-CAA8JZZM.js} +1 -1
  33. package/web/dist/assets/{cynefin-VYW2F7L2-CRkjg5zF.js → cynefin-VYW2F7L2-B6pX-lUD.js} +1 -1
  34. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BoeQn4Um.js → cynefinDiagram-TSTJHNR4-sKTHU_UH.js} +1 -1
  35. package/web/dist/assets/{dagre-VKFMJZFB-DGo7-PY5.js → dagre-VKFMJZFB-EuakJ578.js} +1 -1
  36. package/web/dist/assets/{diagram-FQU43EPY-DMXd2bdO.js → diagram-FQU43EPY-DR3M_pic.js} +1 -1
  37. package/web/dist/assets/{diagram-G47NLZAW-CkYHblMZ.js → diagram-G47NLZAW-BlS6yM0u.js} +1 -1
  38. package/web/dist/assets/{diagram-NH7WQ7WH-C9iwEe4T.js → diagram-NH7WQ7WH-CQNXe6dc.js} +1 -1
  39. package/web/dist/assets/{diagram-OA4YK3LP-DpYLrfwb.js → diagram-OA4YK3LP-BizBeSQo.js} +1 -1
  40. package/web/dist/assets/{diagram-WEI45ONY-BvrBukHb.js → diagram-WEI45ONY-C-_KM6Gg.js} +1 -1
  41. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DGl1r3zR.js → ebnfDiagram-CCIWWBDH-BapWEdic.js} +1 -1
  42. package/web/dist/assets/{erDiagram-Q63AITRT-CoP5DBSb.js → erDiagram-Q63AITRT-Btijn1Xj.js} +1 -1
  43. package/web/dist/assets/{flowDiagram-23GEKE2U-DVHtyYyr.js → flowDiagram-23GEKE2U-D8zK8HkH.js} +1 -1
  44. package/web/dist/assets/{ganttDiagram-NO4QXBWP-_JyR78yC.js → ganttDiagram-NO4QXBWP-DyjkOIMW.js} +1 -1
  45. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Bj7sh0Db.js → gitGraphDiagram-IHSO6WYX-3r9_QeYN.js} +1 -1
  46. package/web/dist/assets/index-BEhdfIki.css +1 -0
  47. package/web/dist/assets/{index-CB6NfSZb.js → index-m0kBr7aj.js} +2 -2
  48. package/web/dist/assets/{infoDiagram-FWYZ7A6U-BrvMeRmA.js → infoDiagram-FWYZ7A6U-Ccd8ACUz.js} +1 -1
  49. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-lNDujpEC.js → ishikawaDiagram-FXEZZL3T-DPRUC8V2.js} +1 -1
  50. package/web/dist/assets/{journeyDiagram-5HDEW3XC-yoD2QRni.js → journeyDiagram-5HDEW3XC-CkvayMsL.js} +1 -1
  51. package/web/dist/assets/{kanban-definition-HUTT4EX6-DtyMlypg.js → kanban-definition-HUTT4EX6-CAK9Z-ha.js} +1 -1
  52. package/web/dist/assets/{linear-DXPbXF9v.js → linear-B-_ZjN2w.js} +1 -1
  53. package/web/dist/assets/{mermaid.core-DCEXqVUL.js → mermaid.core-BN9c--2r.js} +4 -4
  54. package/web/dist/assets/{mindmap-definition-LN4V7U3C-DpKHDJFg.js → mindmap-definition-LN4V7U3C-DuD_AHRU.js} +1 -1
  55. package/web/dist/assets/{pegDiagram-2B236MQR-mxFI_8qh.js → pegDiagram-2B236MQR-Cc1v58KA.js} +1 -1
  56. package/web/dist/assets/{pieDiagram-ENE6RG2P-sUWLrwMG.js → pieDiagram-ENE6RG2P-Bc32--ur.js} +1 -1
  57. package/web/dist/assets/pilotswarm-BgzdDy09.js +93 -0
  58. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cph1z7ye.js → quadrantDiagram-ABIIQ3AL-DpPTc2EP.js} +1 -1
  59. package/web/dist/assets/{railroadDiagram-RFXS5EU6-BPKkt2va.js → railroadDiagram-RFXS5EU6-DXMzwejw.js} +1 -1
  60. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BB6DNHCy.js → requirementDiagram-TGXJPOKE-B3ImAxw-.js} +1 -1
  61. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-BASvPQk2.js → sankeyDiagram-HTMAVEWB-hmVMffE2.js} +1 -1
  62. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DMrNas01.js → sequenceDiagram-DBY2YBRQ-D53fk6uF.js} +1 -1
  63. package/web/dist/assets/{sizeCapture-X5ZJPWSS-Ddq5yeZZ.js → sizeCapture-X5ZJPWSS-4U-I5d2g.js} +1 -1
  64. package/web/dist/assets/{stateDiagram-2N3HPSRC-DUb_IFST.js → stateDiagram-2N3HPSRC-0Y5TgWhW.js} +1 -1
  65. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DqIcGfAY.js +1 -0
  66. package/web/dist/assets/{swimlanes-5IMT3BWC-Cs9JsGaf.js → swimlanes-5IMT3BWC-Umhy4qUu.js} +2 -2
  67. package/web/dist/assets/swimlanesDiagram-G3AALYLV-DA9txLi6.js +8 -0
  68. package/web/dist/assets/{timeline-definition-FHXFAJF6-xuNF5fTm.js → timeline-definition-FHXFAJF6-D5hOg3Qu.js} +1 -1
  69. package/web/dist/assets/{vennDiagram-L72KCM5P-fsI1GFLy.js → vennDiagram-L72KCM5P-QVIcE3UD.js} +1 -1
  70. package/web/dist/assets/{wardleyDiagram-EHGQE667-DEc-kumC.js → wardleyDiagram-EHGQE667-BEj1M2cB.js} +1 -1
  71. package/web/dist/assets/{xychartDiagram-FW5EYKEG-BCo6Qrfu.js → xychartDiagram-FW5EYKEG-xojRIXIf.js} +1 -1
  72. package/web/dist/index.html +3 -3
  73. package/web/dist/assets/channel-DhhWyKRW.js +0 -1
  74. package/web/dist/assets/classDiagram-OUVF2IWQ-CQBxGpio.js +0 -1
  75. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CQBxGpio.js +0 -1
  76. package/web/dist/assets/index-SmXlZ1eu.css +0 -1
  77. package/web/dist/assets/pilotswarm-CdEkcq2V.js +0 -91
  78. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-C3nsHf5u.js +0 -1
  79. package/web/dist/assets/swimlanesDiagram-G3AALYLV-C2xZA5V6.js +0 -8
@@ -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 === "summary" || candidate.chatViewMode === "transcript" || candidate.chatViewMode === "rich") {
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
- chatViewMode: state.chatViewMode,
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
- chatViewMode: state?.ui?.chatViewMode,
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
- ...(hasOwn(normalizedRemote, "chatViewMode")
311
- ? { chatViewMode: normalizedRemote.chatViewMode }
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
- // Sequence uses preserved-width lines inside padded, scroll-synced containers.
393
- // Reserve a few extra columns so content that fits visually does not trip a cosmetic x-scrollbar.
394
- const overflowGuardColumns = inspectorTab === "sequence" ? 3 : 0;
395
- return Math.max(20, paneWidth - 4 - overflowGuardColumns);
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
- controller.dispatch({
735
- type: "ui/scroll",
736
- pane: paneKey,
737
- offset: pixels / SCROLL_ROW_HEIGHT,
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
- function Line({ line, theme, className = "" }) {
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
- if (!node) return;
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 codeSection = React.createElement("section", { key: `block:${index}`, className: "ps-md-code-block" },
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, renderHighlightedCode(block.content, block.language, theme))));
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
- function MarkdownPreviewPanel({ controller, title, color, focused, scrollOffset = 0, paneKey, theme, content }) {
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
- // Raster images render inline (authenticated fetch → object URL). Other
1591
- // binary types keep the download-only card.
1592
- const isRasterImage = /^image\/(png|jpeg|gif|webp)$/i.test(String(contentType || ""));
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 chatCodeSection = React.createElement("section", { key: `code:${index}`, className: "ps-md-code-block ps-chat-code-block" },
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, renderHighlightedCode(block.content || "", block.language, theme))));
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
- 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 }) {
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, onWheel, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
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 rows = React.useMemo(() => selectSessionRows({
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
- function FilesPane({ controller, focused, mobile = false }) {
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
- className: `ps-list-button${index === filesView.selectedIndex ? " is-selected" : ""}`,
3563
- onClick: () => {
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
- controller.selectFileBrowserItem(item).catch(() => {});
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
- const previewPane = filesView.previewRenderMode === "markdown"
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: filesView.previewTitle,
4210
+ title: null,
3578
4211
  color: "cyan",
3579
4212
  focused: false,
3580
4213
  scrollOffset: viewState.previewScroll,
3581
- paneKey: "filePreview",
4214
+ paneKey: previewPaneKey,
3582
4215
  theme,
3583
- content: filesView.previewContent || "",
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: filesView.previewTitle,
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: filesView.previewTitle,
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: "filePreview",
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", { className: "ps-files-grid" },
3630
- React.createElement(Panel, { title: filesView.listTitle, color: "cyan", theme },
3631
- React.createElement("div", { className: "ps-action-list" }, listContent)),
3632
- previewPane,
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
- }, "Close"),
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
- }, "Close"),
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
- }, "Close"),
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
- setHeaderSlot(richUi && !mobile ? document.getElementById("ps-header-toolbar-slot") : null);
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
- const onPointerMove = (event) => {
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((event.clientX - dragState.startX) / GRID_CELL_WIDTH);
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 }, "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 }, "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 }, "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 }, "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 }, "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 }, "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 }, "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 }, "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
- defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(controller.getState());
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(controller.getState());
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(controller.getState());
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
- !layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null)));
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
- if (filesFullscreenActive) mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
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("div", { ref: viewportRef, className: "ps-web-shell" },
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
- React.createElement(ModalLayer, { controller }));
6930
+ mobile && filesFullscreenActive
6931
+ ? React.createElement(MobileArtifactOverlay, { controller })
6932
+ : null,
6933
+ React.createElement(ModalLayer, { controller })));
6126
6934
  }