pilotswarm 0.5.23 → 0.5.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +1 -1
  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 +147 -10
  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 +806 -67
  14. package/web/dist/assets/{abnfDiagram-VRR7QNED-BHkTpRkx.js → abnfDiagram-VRR7QNED-Bjz_3uNq.js} +1 -1
  15. package/web/dist/assets/{arc-BOgZyEWA.js → arc-a3km8Cfi.js} +1 -1
  16. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DeE8fDMy.js → architectureDiagram-ZJ3FMSHR-4twPAKdk.js} +1 -1
  17. package/web/dist/assets/{blockDiagram-677ZJIJ3-CzhdxVEu.js → blockDiagram-677ZJIJ3-FnFRjWxr.js} +1 -1
  18. package/web/dist/assets/{c4Diagram-LMCZKHZV-Cc6p_IKP.js → c4Diagram-LMCZKHZV-B8R4iXqh.js} +1 -1
  19. package/web/dist/assets/channel-BQwFhqIW.js +1 -0
  20. package/web/dist/assets/{chunk-2Q5K7J3B-BCo94LTM.js → chunk-2Q5K7J3B-C7dcVQSj.js} +1 -1
  21. package/web/dist/assets/{chunk-32BRIVSS-DaklJ-6i.js → chunk-32BRIVSS-DQQ_NOog.js} +1 -1
  22. package/web/dist/assets/{chunk-5VM5RSS4-BN_wg2pr.js → chunk-5VM5RSS4-BRR5_oWJ.js} +1 -1
  23. package/web/dist/assets/{chunk-EX3LRPZG-DSFFLUoL.js → chunk-EX3LRPZG-C0Jg_k6w.js} +1 -1
  24. package/web/dist/assets/{chunk-JWPE2WC7-DB4dAjza.js → chunk-JWPE2WC7-CoHkckoP.js} +1 -1
  25. package/web/dist/assets/{chunk-MOJQB5TN-CRM5eGQS.js → chunk-MOJQB5TN-CTIj5Opw.js} +1 -1
  26. package/web/dist/assets/{chunk-RYQCIY6F-BFlkjazU.js → chunk-RYQCIY6F-BsP4OCo4.js} +1 -1
  27. package/web/dist/assets/{chunk-V7JOEXUC-Bnfh4AAv.js → chunk-V7JOEXUC-B5KS36_O.js} +1 -1
  28. package/web/dist/assets/{chunk-VR4S4FIN-CQc4_qVM.js → chunk-VR4S4FIN-Dm92_7ut.js} +1 -1
  29. package/web/dist/assets/{chunk-XXDRQBXY-CZivyoDm.js → chunk-XXDRQBXY-BGivbXmv.js} +1 -1
  30. package/web/dist/assets/classDiagram-OUVF2IWQ-C12RKvcF.js +1 -0
  31. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C12RKvcF.js +1 -0
  32. package/web/dist/assets/{cose-bilkent-JH36ORCC-BUibhTD-.js → cose-bilkent-JH36ORCC-DVjdL84y.js} +1 -1
  33. package/web/dist/assets/{cynefin-VYW2F7L2-CRkjg5zF.js → cynefin-VYW2F7L2-pcKhk42O.js} +1 -1
  34. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BoeQn4Um.js → cynefinDiagram-TSTJHNR4-CdHQXyCH.js} +1 -1
  35. package/web/dist/assets/{dagre-VKFMJZFB-DGo7-PY5.js → dagre-VKFMJZFB-Okt50Noy.js} +1 -1
  36. package/web/dist/assets/{diagram-FQU43EPY-DMXd2bdO.js → diagram-FQU43EPY-D52R5ow3.js} +1 -1
  37. package/web/dist/assets/{diagram-G47NLZAW-CkYHblMZ.js → diagram-G47NLZAW-KiJTa2OF.js} +1 -1
  38. package/web/dist/assets/{diagram-NH7WQ7WH-C9iwEe4T.js → diagram-NH7WQ7WH-DZ-NFYLH.js} +1 -1
  39. package/web/dist/assets/{diagram-OA4YK3LP-DpYLrfwb.js → diagram-OA4YK3LP-et-Lf7yH.js} +1 -1
  40. package/web/dist/assets/{diagram-WEI45ONY-BvrBukHb.js → diagram-WEI45ONY-eXhZ7Ksl.js} +1 -1
  41. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DGl1r3zR.js → ebnfDiagram-CCIWWBDH-CTWYusI0.js} +1 -1
  42. package/web/dist/assets/{erDiagram-Q63AITRT-CoP5DBSb.js → erDiagram-Q63AITRT-DwTN2EqF.js} +1 -1
  43. package/web/dist/assets/{flowDiagram-23GEKE2U-DVHtyYyr.js → flowDiagram-23GEKE2U-CmxOIpNk.js} +1 -1
  44. package/web/dist/assets/{ganttDiagram-NO4QXBWP-_JyR78yC.js → ganttDiagram-NO4QXBWP-TsPnDFPN.js} +1 -1
  45. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Bj7sh0Db.js → gitGraphDiagram-IHSO6WYX-B_IVM0u8.js} +1 -1
  46. package/web/dist/assets/{index-CB6NfSZb.js → index-CB16o5aO.js} +2 -2
  47. package/web/dist/assets/index-CN_7NnbR.css +1 -0
  48. package/web/dist/assets/{infoDiagram-FWYZ7A6U-BrvMeRmA.js → infoDiagram-FWYZ7A6U-BFjk6921.js} +1 -1
  49. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-lNDujpEC.js → ishikawaDiagram-FXEZZL3T-BiTzJYKW.js} +1 -1
  50. package/web/dist/assets/{journeyDiagram-5HDEW3XC-yoD2QRni.js → journeyDiagram-5HDEW3XC-eeyGUQSj.js} +1 -1
  51. package/web/dist/assets/{kanban-definition-HUTT4EX6-DtyMlypg.js → kanban-definition-HUTT4EX6-DPYZ3frK.js} +1 -1
  52. package/web/dist/assets/{linear-DXPbXF9v.js → linear-Buu9IOJQ.js} +1 -1
  53. package/web/dist/assets/{mermaid.core-DCEXqVUL.js → mermaid.core-xO1oSQS3.js} +4 -4
  54. package/web/dist/assets/{mindmap-definition-LN4V7U3C-DpKHDJFg.js → mindmap-definition-LN4V7U3C-TasB4KZ4.js} +1 -1
  55. package/web/dist/assets/{pegDiagram-2B236MQR-mxFI_8qh.js → pegDiagram-2B236MQR-Btr2u_Xy.js} +1 -1
  56. package/web/dist/assets/{pieDiagram-ENE6RG2P-sUWLrwMG.js → pieDiagram-ENE6RG2P-0iBpCJcu.js} +1 -1
  57. package/web/dist/assets/pilotswarm-v4IdVngK.js +93 -0
  58. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cph1z7ye.js → quadrantDiagram-ABIIQ3AL-CbVQOzUs.js} +1 -1
  59. package/web/dist/assets/{railroadDiagram-RFXS5EU6-BPKkt2va.js → railroadDiagram-RFXS5EU6-BkwzDhMx.js} +1 -1
  60. package/web/dist/assets/{requirementDiagram-TGXJPOKE-BB6DNHCy.js → requirementDiagram-TGXJPOKE-DpF9yvmD.js} +1 -1
  61. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-BASvPQk2.js → sankeyDiagram-HTMAVEWB-CjvbElms.js} +1 -1
  62. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-DMrNas01.js → sequenceDiagram-DBY2YBRQ-DRj2K28H.js} +1 -1
  63. package/web/dist/assets/{sizeCapture-X5ZJPWSS-Ddq5yeZZ.js → sizeCapture-X5ZJPWSS-Bi3gxz8F.js} +1 -1
  64. package/web/dist/assets/{stateDiagram-2N3HPSRC-DUb_IFST.js → stateDiagram-2N3HPSRC-_INdmyGU.js} +1 -1
  65. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-A5HcuE43.js +1 -0
  66. package/web/dist/assets/{swimlanes-5IMT3BWC-Cs9JsGaf.js → swimlanes-5IMT3BWC-D1pFtL4Y.js} +2 -2
  67. package/web/dist/assets/swimlanesDiagram-G3AALYLV-Bf_Rn9Zk.js +8 -0
  68. package/web/dist/assets/{timeline-definition-FHXFAJF6-xuNF5fTm.js → timeline-definition-FHXFAJF6-PYQ9uRSp.js} +1 -1
  69. package/web/dist/assets/{vennDiagram-L72KCM5P-fsI1GFLy.js → vennDiagram-L72KCM5P-xCF6u31z.js} +1 -1
  70. package/web/dist/assets/{wardleyDiagram-EHGQE667-DEc-kumC.js → wardleyDiagram-EHGQE667-DPcm9Msn.js} +1 -1
  71. package/web/dist/assets/{xychartDiagram-FW5EYKEG-BCo6Qrfu.js → xychartDiagram-FW5EYKEG-D9ZNU0hG.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) {
@@ -798,6 +846,20 @@ function Runs({ runs, theme }) {
798
846
  };
799
847
  const href = String(run?.href || "").trim();
800
848
  const isExternalHref = /^https?:\/\//i.test(href);
849
+ // artifact:// links used to render as an inert span — the only
850
+ // affordance was "press a to download", which is the wrong verb
851
+ // for a patch you want to READ. They now open the Files tab with
852
+ // the artifact selected and previewed.
853
+ const artifactRef = parseArtifactHref(href);
854
+
855
+ if (artifactRef) {
856
+ return React.createElement(ArtifactLink, {
857
+ key: `${index}:${run.text || ""}`,
858
+ label: run.text || "",
859
+ artifactRef,
860
+ style,
861
+ });
862
+ }
801
863
 
802
864
  return isExternalHref
803
865
  ? React.createElement("a", {
@@ -865,7 +927,12 @@ function usePanePixelScroll(ref, scrollOffset, paneKey, controller) {
865
927
 
866
928
  React.useLayoutEffect(() => {
867
929
  const node = ref.current;
868
- if (!node) return;
930
+ // No paneKey means nobody is driving this pane's offset, so leave the
931
+ // scroller entirely alone. Writing scrollTop back during a touch fling
932
+ // interrupts momentum every frame — which is why the preview felt
933
+ // stiff next to the transcript — and the SCROLL_ROW_HEIGHT
934
+ // quantization makes it snap as well.
935
+ if (!node || !paneKey) return;
869
936
  const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
870
937
  const nextScrollTop = Math.min(maxScroll, Math.max(0, Number(scrollOffset) || 0) * SCROLL_ROW_HEIGHT);
871
938
  if (Math.abs(node.scrollTop - nextScrollTop) > 2) {
@@ -875,12 +942,19 @@ function usePanePixelScroll(ref, scrollOffset, paneKey, controller) {
875
942
  programmaticScrollRef.current = false;
876
943
  });
877
944
  }
878
- }, [ref, scrollOffset]);
945
+ }, [ref, scrollOffset, paneKey]);
879
946
 
880
- return React.useCallback(() => {
947
+ return React.useCallback((event) => {
881
948
  if (programmaticScrollRef.current) return;
882
949
  const node = ref.current;
883
950
  if (!node || !paneKey) return;
951
+ // React simulates bubbling for onScroll, so scrolling a code block or
952
+ // table INSIDE the document also fires this handler. Acting on it
953
+ // recorded the pane offset from an inner element's scroll state and
954
+ // then wrote it back to the outer scroller — horizontally scrolling a
955
+ // fenced code block snapped the whole markdown preview to the top.
956
+ // Only the pane's own scroller may drive the pane's offset.
957
+ if (event && event.target !== node) return;
884
958
  // See useScrollSync — ignore browser auto-clamp during a
885
959
  // transient empty/loading body.
886
960
  if (node.scrollHeight <= node.clientHeight) return;
@@ -912,6 +986,163 @@ const CODE_LANGUAGE_ALIASES = {
912
986
  css: "css", scss: "css",
913
987
  };
914
988
 
989
+ // ── Diff blocks ───────────────────────────────────────────────────────────
990
+ // A ```diff fence is line-oriented, not token-oriented: running it through
991
+ // the generic tokenizer colored Rust/JS keywords inside the payload while
992
+ // leaving the +/- semantics — the only thing that matters in a diff — as
993
+ // undifferentiated text. Diffs are classified per line instead.
994
+
995
+ const DIFF_LANGUAGES = new Set(["diff", "patch", "udiff"]);
996
+
997
+ /**
998
+ * Diff-shaped artifacts. Recognized by extension AND by content type, because
999
+ * an agent may write either `.patch`/`.diff` or a text/x-patch blob under some
1000
+ * other name.
1001
+ */
1002
+ const DIFF_ARTIFACT_RE = /\.(patch|diff)$/i;
1003
+ const IMAGE_CONTENT_TYPE_RE = /^image\/(png|jpe?g|gif|webp|avif|bmp|svg\+xml|x-icon|vnd\.microsoft\.icon)$/i;
1004
+ const IMAGE_FILENAME_RE = /\.(png|jpe?g|gif|webp|avif|bmp|svg|ico)$/i;
1005
+
1006
+ /** Map an artifact filename to a highlighter language, or null for plain text. */
1007
+ const CODE_ARTIFACT_LANGUAGES = {
1008
+ js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript",
1009
+ ts: "typescript", tsx: "typescript", json: "json", jsonl: "json",
1010
+ py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
1011
+ sh: "bash", bash: "bash", zsh: "bash", yml: "yaml", yaml: "yaml",
1012
+ toml: "toml", sql: "sql", css: "css", html: "html", xml: "xml",
1013
+ };
1014
+
1015
+ /**
1016
+ * Strip a leading YAML frontmatter block from markdown.
1017
+ *
1018
+ * The renderer has no concept of frontmatter, so `---\ntitle: x\n---` was
1019
+ * collapsed into a paragraph and rendered as prose above the document — which
1020
+ * read as the title being duplicated, since the doc's own H1 follows it.
1021
+ * Document viewers hide frontmatter; only strip a block that starts on line 1
1022
+ * and closes, so a document opening with a thematic break is untouched.
1023
+ */
1024
+ function stripYamlFrontmatter(content) {
1025
+ const text = String(content || "");
1026
+ if (!/^---[ \t]*\r?\n/.test(text)) return text;
1027
+ const close = /\r?\n---[ \t]*(\r?\n|$)/.exec(text.slice(3));
1028
+ if (!close) return text;
1029
+ return text.slice(3 + close.index + close[0].length);
1030
+ }
1031
+
1032
+ function codeLanguageForArtifact(filename) {
1033
+ const ext = /\.([A-Za-z0-9]+)$/.exec(String(filename || ""));
1034
+ if (!ext) return null;
1035
+ return CODE_ARTIFACT_LANGUAGES[ext[1].toLowerCase()] || null;
1036
+ }
1037
+
1038
+ function isDiffArtifact(filename, contentType) {
1039
+ if (DIFF_ARTIFACT_RE.test(String(filename || ""))) return true;
1040
+ return /^text\/x-(patch|diff)$/i.test(String(contentType || "").trim());
1041
+ }
1042
+
1043
+ /** Parse an `artifact://<sessionId>/<filename>` href. */
1044
+ function parseArtifactHref(href) {
1045
+ const match = /^artifact:\/\/([^/]+)\/(.+)$/i.exec(String(href || "").trim());
1046
+ if (!match) return null;
1047
+ try {
1048
+ return { sessionId: match[1], filename: decodeURIComponent(match[2]) };
1049
+ } catch {
1050
+ return { sessionId: match[1], filename: match[2] };
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * Controller access for deeply-nested renderers.
1056
+ *
1057
+ * The markdown/run renderers are called from many places and never took a
1058
+ * controller — threading one through every call site to make a single link
1059
+ * type clickable would touch far more code than the feature is worth.
1060
+ */
1061
+ const ControllerContext = React.createContext(null);
1062
+
1063
+ function ArtifactLink({ label, artifactRef, style }) {
1064
+ const controller = React.useContext(ControllerContext);
1065
+ const diff = isDiffArtifact(artifactRef.filename);
1066
+ const onOpen = React.useCallback((event) => {
1067
+ event.preventDefault();
1068
+ event.stopPropagation();
1069
+ if (!controller?.revealArtifact) return;
1070
+ // On a phone the preview takes the whole pane — a list/detail split has
1071
+ // no room to be useful at 390px.
1072
+ const fullscreen = typeof window !== "undefined"
1073
+ && typeof window.matchMedia === "function"
1074
+ && window.matchMedia("(max-width: 920px)").matches;
1075
+ Promise.resolve(controller.revealArtifact(artifactRef.sessionId, artifactRef.filename, { fullscreen }))
1076
+ .catch(() => {});
1077
+ }, [controller, artifactRef.sessionId, artifactRef.filename]);
1078
+
1079
+ return React.createElement("button", {
1080
+ type: "button",
1081
+ className: `ps-artifact-link${diff ? " is-diff" : ""}`,
1082
+ onClick: onOpen,
1083
+ title: diff
1084
+ ? `Open ${artifactRef.filename} as a diff`
1085
+ : `Open ${artifactRef.filename} in Files`,
1086
+ style,
1087
+ },
1088
+ diff ? React.createElement("span", { className: "ps-artifact-link-glyph", "aria-hidden": "true" }, "±") : null,
1089
+ React.createElement("span", { className: "ps-artifact-link-label" }, label || artifactRef.filename));
1090
+ }
1091
+
1092
+ function isDiffLanguage(language) {
1093
+ return DIFF_LANGUAGES.has(String(language || "").trim().toLowerCase());
1094
+ }
1095
+
1096
+ // File/meta headers are matched BEFORE +/- so "--- a/x" and "+++ b/x" are not
1097
+ // mistaken for removed/added lines.
1098
+ const DIFF_META_PATTERN = /^(?:diff --git |index |--- |\+\+\+ |new file mode|deleted file mode|old mode|new mode|similarity index|dissimilarity index|rename (?:from|to) |copy (?:from|to) |Binary files |GIT binary patch|\\)/;
1099
+
1100
+ function classifyDiffLine(line) {
1101
+ if (DIFF_META_PATTERN.test(line)) return "meta";
1102
+ if (line.startsWith("@@")) return "hunk";
1103
+ if (line.startsWith("+")) return "add";
1104
+ if (line.startsWith("-")) return "del";
1105
+ return "context";
1106
+ }
1107
+
1108
+ const DIFF_LINE_COLORS = { add: "green", del: "red", hunk: "cyan", meta: "gray" };
1109
+
1110
+ function renderDiffCode(content, theme) {
1111
+ const lines = String(content || "").replace(/\n$/, "").split("\n");
1112
+ return lines.map((line, index) => {
1113
+ const kind = classifyDiffLine(line);
1114
+ const color = DIFF_LINE_COLORS[kind];
1115
+ // Payload lines carry their +/-/space in column 1. Split it off into
1116
+ // a gutter (GitHub/GitLab/VS Code all do this) so the marker stops
1117
+ // colliding with the code, wrapped lines hang under the text column
1118
+ // instead of under the marker, and a copied selection yields clean
1119
+ // code — the gutter is user-select:none. Meta/hunk lines have no
1120
+ // marker but keep the empty gutter so every line stays aligned.
1121
+ // Only strip column 1 when it really IS a marker. "context" is the
1122
+ // fallback for every unrecognized line, so slicing it unconditionally
1123
+ // ate the first real character of anything that is not a unified-diff
1124
+ // body line — GIT binary patch payload ("delta 142", "zcmV;91Ru…"),
1125
+ // format-patch commit prose, mbox headers. Real context lines start
1126
+ // with a space; those still get their marker split off.
1127
+ const hasMarker = kind === "add" || kind === "del"
1128
+ || (kind === "context" && line.startsWith(" "));
1129
+ const marker = hasMarker ? line.slice(0, 1) : "";
1130
+ const text = hasMarker ? line.slice(1) : line;
1131
+ return React.createElement("span", {
1132
+ key: `d:${index}`,
1133
+ className: `ps-diff-line is-${kind}`,
1134
+ style: color ? { color: resolveColor(theme, color) || undefined } : undefined,
1135
+ },
1136
+ React.createElement("span", {
1137
+ className: "ps-diff-marker",
1138
+ // Decorative: the tint already carries the meaning visually,
1139
+ // and the row is announced by its text.
1140
+ "aria-hidden": "true",
1141
+ }, marker.trim()),
1142
+ React.createElement("span", { className: "ps-diff-text" }, text));
1143
+ });
1144
+ }
1145
+
915
1146
  function isMermaidLanguage(language) {
916
1147
  return String(language || "").trim().toLowerCase() === "mermaid";
917
1148
  }
@@ -1174,6 +1405,25 @@ function renderInlineMarkdown(source, theme, keyPrefix = "md") {
1174
1405
  return React.createElement("em", { key, className: "ps-md-em" }, renderInlineMarkdown(token.text, theme, `${key}:em`));
1175
1406
  }
1176
1407
  if (token.type === "link") {
1408
+ const artifactRef = parseArtifactHref(token.href);
1409
+ if (artifactRef) {
1410
+ return React.createElement(ArtifactLink, {
1411
+ key,
1412
+ label: token.text,
1413
+ artifactRef,
1414
+ style: { color: resolveColor(theme, "cyan") },
1415
+ });
1416
+ }
1417
+ // Only http(s) becomes a navigable anchor. Anything else — an
1418
+ // unknown scheme, or a malformed href from a bad transform — would
1419
+ // resolve RELATIVE to the portal and navigate the SPA away on
1420
+ // click, losing all session state. Render it as inert text instead.
1421
+ if (!/^https?:\/\//i.test(String(token.href || "").trim())) {
1422
+ return React.createElement("span", {
1423
+ key,
1424
+ style: { color: resolveColor(theme, "cyan") },
1425
+ }, token.text);
1426
+ }
1177
1427
  return React.createElement("a", {
1178
1428
  key,
1179
1429
  className: "ps-md-link",
@@ -1457,10 +1707,16 @@ function MarkdownPreviewContent({ content, theme }) {
1457
1707
  }, renderInlineMarkdown(block.text, theme, `heading:${index}`));
1458
1708
  }
1459
1709
  if (block.type === "code") {
1460
- const codeSection = React.createElement("section", { key: `block:${index}`, className: "ps-md-code-block" },
1710
+ const isDiff = isDiffLanguage(block.language);
1711
+ const codeSection = React.createElement("section", {
1712
+ key: `block:${index}`,
1713
+ className: `ps-md-code-block${isDiff ? " is-diff" : ""}`,
1714
+ },
1461
1715
  React.createElement("div", { className: "ps-md-code-header" }, block.language || "text"),
1462
1716
  React.createElement("pre", { className: "ps-md-code-pre" },
1463
- React.createElement("code", null, renderHighlightedCode(block.content, block.language, theme))));
1717
+ React.createElement("code", null, isDiff
1718
+ ? renderDiffCode(block.content, theme)
1719
+ : renderHighlightedCode(block.content, block.language, theme))));
1464
1720
  if (isMermaidLanguage(block.language)) {
1465
1721
  return React.createElement(MermaidDiagram, {
1466
1722
  key: `block:${index}`,
@@ -1548,7 +1804,42 @@ function MarkdownPreviewContent({ content, theme }) {
1548
1804
  }));
1549
1805
  }
1550
1806
 
1551
- function MarkdownPreviewPanel({ controller, title, color, focused, scrollOffset = 0, paneKey, theme, content }) {
1807
+ /**
1808
+ * Preview panel for pre-rendered content (diff, highlighted code).
1809
+ *
1810
+ * Shares MarkdownPreviewPanel's scroll wiring rather than being a plain
1811
+ * overflow:auto div: without the pane hookup the keyboard scroll commands
1812
+ * have nothing to drive, so these previews could only ever be scrolled with
1813
+ * the mouse — the global key handler swallows the arrows before the browser
1814
+ * would scroll them natively.
1815
+ */
1816
+ function RenderedPreviewPanel({ controller, title, color, focused, scrollOffset = 0, paneKey, theme, className = "", focusRegion = null, children }) {
1817
+ const ref = React.useRef(null);
1818
+ const onScroll = usePanePixelScroll(ref, scrollOffset, paneKey, controller);
1819
+ // Mouse only. On touch this fired on every tap, and setFocus runs the
1820
+ // region through normalizeFocusRegion, which rewrites a region missing
1821
+ // from the current layout to order[0] — so tapping the chat on a phone
1822
+ // was silently reassigning focus to the inspector and jumping to the
1823
+ // artifact list. Keyboard scroll targeting is meaningless on touch anyway.
1824
+ const claimFocus = React.useCallback(() => {
1825
+ const region = focusRegionForPaneKey(paneKey, focusRegion);
1826
+ if (region && controller?.setFocus) controller.setFocus(region);
1827
+ }, [controller, paneKey, focusRegion]);
1828
+
1829
+ return React.createElement(Panel, { title, color, focused, theme },
1830
+ React.createElement("div", {
1831
+ ref,
1832
+ className: `ps-scroll-panel is-preview ${className}`.trim(),
1833
+ onScroll,
1834
+ onMouseDown: claimFocus,
1835
+ }, children));
1836
+ }
1837
+
1838
+ function MarkdownPreviewPanel({ controller, title, color, focused, scrollOffset = 0, paneKey, theme, content, focusRegion = null }) {
1839
+ const claimMarkdownFocus = React.useCallback(() => {
1840
+ const region = focusRegionForPaneKey(paneKey, focusRegion);
1841
+ if (region && controller?.setFocus) controller.setFocus(region);
1842
+ }, [controller, paneKey, focusRegion]);
1552
1843
  const ref = React.useRef(null);
1553
1844
  const onScroll = usePanePixelScroll(ref, scrollOffset, paneKey, controller);
1554
1845
 
@@ -1557,6 +1848,7 @@ function MarkdownPreviewPanel({ controller, title, color, focused, scrollOffset
1557
1848
  ref,
1558
1849
  className: "ps-scroll-panel ps-markdown-scroll",
1559
1850
  onScroll,
1851
+ onMouseDown: claimMarkdownFocus,
1560
1852
  }, React.createElement(MarkdownPreviewContent, { content, theme })));
1561
1853
  }
1562
1854
 
@@ -1587,9 +1879,14 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
1587
1879
  })
1588
1880
  : "";
1589
1881
 
1590
- // 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 || ""));
1882
+ // Images render inline (authenticated fetch → object URL). Other binary
1883
+ // types keep the download-only card.
1884
+ //
1885
+ // SVG goes through the SAME object-URL <img> path rather than being
1886
+ // inlined as markup: an artifact is untrusted content, and <img> does not
1887
+ // execute script inside an SVG the way inline markup would.
1888
+ const isRasterImage = IMAGE_CONTENT_TYPE_RE.test(String(contentType || ""))
1889
+ || IMAGE_FILENAME_RE.test(String(filename || ""));
1593
1890
  const [imageUrl, setImageUrl] = React.useState(null);
1594
1891
  React.useEffect(() => {
1595
1892
  setImageUrl(null);
@@ -2119,10 +2416,16 @@ function StructuredBlockList({ blocks, theme, controller = null }) {
2119
2416
  }
2120
2417
 
2121
2418
  if (block.type === "code") {
2122
- const chatCodeSection = React.createElement("section", { key: `code:${index}`, className: "ps-md-code-block ps-chat-code-block" },
2419
+ const chatIsDiff = isDiffLanguage(block.language);
2420
+ const chatCodeSection = React.createElement("section", {
2421
+ key: `code:${index}`,
2422
+ className: `ps-md-code-block ps-chat-code-block${chatIsDiff ? " is-diff" : ""}`,
2423
+ },
2123
2424
  React.createElement("div", { className: "ps-md-code-header" }, block.language || "text"),
2124
2425
  React.createElement("pre", { className: "ps-md-code-pre" },
2125
- React.createElement("code", null, renderHighlightedCode(block.content || "", block.language, theme))));
2426
+ React.createElement("code", null, chatIsDiff
2427
+ ? renderDiffCode(block.content || "", theme)
2428
+ : renderHighlightedCode(block.content || "", block.language, theme))));
2126
2429
  if (isMermaidLanguage(block.language)) {
2127
2430
  return React.createElement(MermaidDiagram, {
2128
2431
  key: `code:${index}`,
@@ -2232,11 +2535,19 @@ function unbracketTitleRuns(runs) {
2232
2535
 
2233
2536
  function Panel({ title, titleRight = null, color = "gray", focused = false, actions = null, children, theme, className = "" }) {
2234
2537
  const accent = resolveColor(theme, color);
2538
+ // A panel with nothing to put in its header should not render one. Nested
2539
+ // panels (the artifact list and preview inside Files) were spending a full
2540
+ // header row restating what the enclosing pane already said.
2541
+ const hasHeader = Boolean(
2542
+ (Array.isArray(title) ? title.length > 0 : String(title ?? "").trim())
2543
+ || titleRight
2544
+ || actions,
2545
+ );
2235
2546
  return React.createElement("section", {
2236
- className: `ps-panel${focused ? " is-focused" : ""}${className ? ` ${className}` : ""}`,
2547
+ className: `ps-panel${focused ? " is-focused" : ""}${hasHeader ? "" : " is-chromeless"}${className ? ` ${className}` : ""}`,
2237
2548
  style: { "--ps-panel-accent": accent || "var(--ps-border)" },
2238
2549
  },
2239
- React.createElement("header", { className: "ps-panel-header" },
2550
+ hasHeader ? React.createElement("header", { className: "ps-panel-header" },
2240
2551
  React.createElement("div", { className: "ps-panel-title" },
2241
2552
  Array.isArray(title)
2242
2553
  ? React.createElement(Runs, { runs: title, theme })
@@ -2252,7 +2563,7 @@ function Panel({ title, titleRight = null, color = "gray", focused = false, acti
2252
2563
  actions ? React.createElement("div", { className: "ps-panel-actions" }, actions) : null,
2253
2564
  )
2254
2565
  : null,
2255
- ),
2566
+ ) : null,
2256
2567
  React.createElement("div", { className: "ps-panel-body" }, children));
2257
2568
  }
2258
2569
 
@@ -2355,7 +2666,33 @@ function PortalSequenceLines({ lines, theme, completionByTurn }) {
2355
2666
  }
2356
2667
 
2357
2668
 
2358
- 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 }) {
2669
+ /**
2670
+ * Which focus region owns a scrollable pane.
2671
+ *
2672
+ * Keyboard scrolling already routes through focusRegion
2673
+ * (controller.getScrollablePaneForFocus), but nothing set the focus when you
2674
+ * CLICKED a pane — so the scroll keys kept acting on whatever was focused
2675
+ * last. Clicking into a scrollable pane now claims the keys for it.
2676
+ */
2677
+ const PANE_KEY_FOCUS_REGIONS = {
2678
+ chat: "chat",
2679
+ activity: "activity",
2680
+ inspector: "inspector",
2681
+ // The preview is reached through the inspector's Files tab, and the
2682
+ // controller maps inspector-focus + files-tab back to filePreview.
2683
+ filePreview: "inspector",
2684
+ };
2685
+
2686
+ function focusRegionForPaneKey(paneKey, override = null) {
2687
+ // The DETACHED preview sits in the activity slot, so clicking it must claim
2688
+ // activity focus — that is what distinguishes "clicked the preview" from
2689
+ // "clicked the artifact list", which keeps inspector focus and drives the
2690
+ // selection with the same keys.
2691
+ if (override) return override;
2692
+ return PANE_KEY_FOCUS_REGIONS[String(paneKey || "")] || null;
2693
+ }
2694
+
2695
+ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, lines, stickyLines = [], bottomStickyLines = [], scrollOffset = 0, scrollMode = "top", paneKey, controller, className = "", panelClassName = "", topContent = null, bottomContent = null, structuredBlocks = false, stickyBottom = false, renderBody = null, focusRegion = null }) {
2359
2696
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
2360
2697
  const theme = getTheme(themeId);
2361
2698
  const ref = React.useRef(null);
@@ -2366,6 +2703,19 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
2366
2703
  const normalizedBottomSticky = React.useMemo(() => normalizeLines(bottomStickyLines), [bottomStickyLines]);
2367
2704
  const preserveHorizontalScroll = className.includes("is-preserve") && panelClassName.includes("has-preserved-sticky");
2368
2705
 
2706
+ // Clicking (or touching) a scrollable pane makes the keyboard scroll keys
2707
+ // act on THAT pane. Fires on mousedown rather than click so a drag-select
2708
+ // inside the pane claims focus too.
2709
+ // Mouse only. On touch this fired on every tap, and setFocus runs the
2710
+ // region through normalizeFocusRegion, which rewrites a region missing
2711
+ // from the current layout to order[0] — so tapping the chat on a phone
2712
+ // was silently reassigning focus to the inspector and jumping to the
2713
+ // artifact list. Keyboard scroll targeting is meaningless on touch anyway.
2714
+ const claimFocus = React.useCallback(() => {
2715
+ const region = focusRegionForPaneKey(paneKey, focusRegion);
2716
+ if (region && controller?.setFocus) controller.setFocus(region);
2717
+ }, [controller, paneKey, focusRegion]);
2718
+
2369
2719
  const syncScrollLeft = React.useCallback((source, target) => {
2370
2720
  if (!source || !target) return;
2371
2721
  if (Math.abs((target.scrollLeft || 0) - (source.scrollLeft || 0)) <= 1) return;
@@ -2413,7 +2763,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
2413
2763
  normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
2414
2764
  )
2415
2765
  : null,
2416
- React.createElement("div", { ref, className: `ps-scroll-panel ${className}${scrollShadow.down ? " is-scrolled-down" : ""}${scrollShadow.up ? " is-scrolled-up" : ""}`.trim(), onScroll: handleBodyScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
2766
+ React.createElement("div", { ref, className: `ps-scroll-panel ${className}${scrollShadow.down ? " is-scrolled-down" : ""}${scrollShadow.up ? " is-scrolled-up" : ""}`.trim(), onScroll: handleBodyScroll, onMouseDown: claimFocus, onTouchStart, onWheel, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
2417
2767
  typeof renderBody === "function"
2418
2768
  ? renderBody(normalizedLines, theme)
2419
2769
  : structuredBlocks
@@ -2524,6 +2874,36 @@ function SessionRowContent({ row, theme, structured = false }) {
2524
2874
  : null);
2525
2875
  }
2526
2876
 
2877
+ /**
2878
+ * Reuse the previous value when a freshly computed one is deep-equal.
2879
+ *
2880
+ * The session poll rebuilds sessions.byId whenever ANY session's updatedAt or
2881
+ * iteration count moves, which invalidates every downstream memo and re-renders
2882
+ * the whole list — visibly, every poll — even though the rows that are actually
2883
+ * displayed did not change. Comparing the rendered result and keeping the old
2884
+ * reference stops the churn at the render boundary, without having to chase
2885
+ * which upstream field ticked.
2886
+ *
2887
+ * Sized for lists of tens of rows: the comparison is far cheaper than the
2888
+ * reconciliation it avoids.
2889
+ */
2890
+ function useStableValue(value) {
2891
+ const ref = React.useRef(value);
2892
+ const previousJson = React.useRef(null);
2893
+ const nextJson = React.useMemo(() => {
2894
+ try {
2895
+ return JSON.stringify(value);
2896
+ } catch {
2897
+ return null; // unserializable: never claim equality
2898
+ }
2899
+ }, [value]);
2900
+ if (nextJson === null || previousJson.current !== nextJson) {
2901
+ previousJson.current = nextJson;
2902
+ ref.current = value;
2903
+ }
2904
+ return ref.current;
2905
+ }
2906
+
2527
2907
  function SessionPane({ controller, actions = null, panelClassName = "", structuredRows = false }) {
2528
2908
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
2529
2909
  const theme = getTheme(themeId);
@@ -2547,7 +2927,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
2547
2927
  // ever wanted independently.
2548
2928
  rich: state.ui.chatViewMode === "rich",
2549
2929
  }), shallowEqualObject);
2550
- const rows = React.useMemo(() => selectSessionRows({
2930
+ const computedRows = React.useMemo(() => selectSessionRows({
2551
2931
  sessions: {
2552
2932
  activeSessionId: viewState.activeSessionId,
2553
2933
  byId: viewState.sessionsById,
@@ -2563,6 +2943,8 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
2563
2943
  mode: viewState.connectionMode,
2564
2944
  },
2565
2945
  }), [viewState.activeSessionId, viewState.auth, viewState.connectionMode, viewState.filterQuery, viewState.ownerFilter, viewState.pinnedIds, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
2946
+ // Hold the previous rows when a poll produced identical output.
2947
+ const rows = useStableValue(computedRows);
2566
2948
  const activeSession = viewState.activeSessionId
2567
2949
  ? viewState.sessionsById[viewState.activeSessionId] || null
2568
2950
  : null;
@@ -2869,7 +3251,7 @@ function SessionLinkModal({ url, warn, onCopyAgain, onClose }) {
2869
3251
  React.createElement("div", { className: "ps-link-modal", onClick: stop },
2870
3252
  React.createElement("div", { className: "ps-share-modal-head" },
2871
3253
  React.createElement("span", null, "Link copied"),
2872
- React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
3254
+ React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
2873
3255
  React.createElement("div", { className: "ps-share-section-sub" },
2874
3256
  "Copied to your clipboard — anyone with access can open the session from this link."),
2875
3257
  React.createElement("div", { className: "ps-link-row" },
@@ -3106,7 +3488,7 @@ function SessionModifyModal({ controller, sessionId, initialTitle, currentModel,
3106
3488
  React.createElement("div", { className: "ps-share-modal", onClick: stop },
3107
3489
  React.createElement("div", { className: "ps-share-modal-head" },
3108
3490
  React.createElement("span", null, "Manage session"),
3109
- React.createElement("button", { className: "ps-modal-close", onClick: onClose }, "✕")),
3491
+ React.createElement("button", { className: "ps-modal-close", onClick: onClose, "aria-label": "Close", title: "Close" }, "✕")),
3110
3492
 
3111
3493
  // ── Tab bar ───────────────────────────────────────────────
3112
3494
  React.createElement("div", { className: "ps-manage-tabs", role: "tablist" },
@@ -3393,6 +3775,79 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
3393
3775
  });
3394
3776
  }
3395
3777
 
3778
+ /**
3779
+ * Mobile artifact viewer — a genuine full-viewport overlay, not a pane.
3780
+ *
3781
+ * Rendered OUTSIDE the workspace and fixed to the viewport, so the portal
3782
+ * header, session list and composer are all covered: opening an artifact on a
3783
+ * phone should feel like pushing a detail screen, the way a native app does,
3784
+ * not like shrinking content into one more box. Desktop never mounts this.
3785
+ */
3786
+ function MobileArtifactOverlay({ controller }) {
3787
+ const view = useControllerSelector(controller, (state) => {
3788
+ const id = state.files.selectedArtifactId || "";
3789
+ const filename = id ? id.slice(id.indexOf("/") + 1) : "";
3790
+ return {
3791
+ filename,
3792
+ origin: state.files.previewOrigin || null,
3793
+ canPrev: controller.canStepArtifactPreview(-1),
3794
+ canNext: controller.canStepArtifactPreview(1),
3795
+ };
3796
+ }, shallowEqualObject);
3797
+
3798
+ // Wrap toggle applies to CODE only. Markdown always wraps — prose in a
3799
+ // horizontal scroller is unreadable — and its fenced blocks keep their own
3800
+ // behavior. Images have nothing to wrap.
3801
+ const isCode = Boolean(codeLanguageForArtifact(view.filename))
3802
+ || isDiffArtifact(view.filename);
3803
+ const [contentWidth, setContentWidth] = React.useState(false);
3804
+ React.useEffect(() => { setContentWidth(false); }, [view.filename]);
3805
+
3806
+ const step = React.useCallback((delta) => {
3807
+ controller.stepArtifactPreview(delta).catch(() => {});
3808
+ }, [controller]);
3809
+
3810
+ return React.createElement("div", { className: "ps-artifact-overlay" },
3811
+ React.createElement("header", { className: "ps-artifact-overlay-bar" },
3812
+ React.createElement("button", {
3813
+ type: "button",
3814
+ className: "ps-artifact-overlay-btn",
3815
+ onClick: () => controller.closeArtifactPreview().catch(() => {}),
3816
+ "aria-label": view.origin === "chat" ? "Close, back to chat" : "Close, back to artifacts",
3817
+ }, "✕"),
3818
+ React.createElement("span", { className: "ps-artifact-overlay-title" }, view.filename || "Artifact"),
3819
+ isCode
3820
+ ? React.createElement("button", {
3821
+ type: "button",
3822
+ className: `ps-artifact-overlay-btn${contentWidth ? " is-active" : ""}`,
3823
+ onClick: () => setContentWidth((v) => !v),
3824
+ "aria-label": contentWidth ? "Wrap lines" : "Show full lines",
3825
+ title: contentWidth ? "Wrap to screen width" : "Full line width (scroll horizontally)",
3826
+ }, contentWidth ? "⇥" : "↔")
3827
+ : null,
3828
+ React.createElement("button", {
3829
+ type: "button",
3830
+ className: "ps-artifact-overlay-btn",
3831
+ disabled: !view.canPrev,
3832
+ onClick: () => step(-1),
3833
+ "aria-label": "Previous artifact",
3834
+ }, "‹"),
3835
+ React.createElement("button", {
3836
+ type: "button",
3837
+ className: "ps-artifact-overlay-btn",
3838
+ disabled: !view.canNext,
3839
+ onClick: () => step(1),
3840
+ "aria-label": "Next artifact",
3841
+ }, "›")),
3842
+ // No swipe handlers: horizontal drag belongs to the content, so a wide
3843
+ // code line can be scrolled without also changing which file you are on.
3844
+ React.createElement("div", {
3845
+ className: `ps-artifact-overlay-body${contentWidth ? " is-content-width" : ""}`,
3846
+ }, React.createElement(FilesPane, {
3847
+ controller, focused: true, mobile: true, previewOnly: true, nativeScroll: true,
3848
+ })));
3849
+ }
3850
+
3396
3851
  function MobileWorkspace({ controller }) {
3397
3852
  // The session list is always shown; use the toolbar Focus button to give
3398
3853
  // the chat the full screen (the old Show/Hide toggle was redundant with it).
@@ -3424,7 +3879,11 @@ function InspectorTabs({ activeTab, controller }) {
3424
3879
  })));
3425
3880
  }
3426
3881
 
3427
- function FilesPane({ controller, focused, mobile = false }) {
3882
+ // `previewOnly` renders JUST the artifact preview panel, so the desktop layout
3883
+ // can host it in the activity slot where it gets the existing row resizer.
3884
+ // Detached this way the preview is resizable; nested inside the inspector it
3885
+ // could only ever have half of a pane.
3886
+ function FilesPane({ controller, focused, mobile = false, previewOnly = false, nativeScroll = false }) {
3428
3887
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
3429
3888
  const theme = getTheme(themeId);
3430
3889
  const fileInputRef = React.useRef(null);
@@ -3441,6 +3900,8 @@ function FilesPane({ controller, focused, mobile = false }) {
3441
3900
  filesBySessionId: state.files.bySessionId,
3442
3901
  filesFilter: state.files.filter,
3443
3902
  selectedArtifactId: state.files.selectedArtifactId,
3903
+ markedIds: state.files.markedIds || [],
3904
+ previewOrigin: state.files.previewOrigin || null,
3444
3905
  focused,
3445
3906
  previewScroll: state.ui.scroll.filePreview,
3446
3907
  fullscreen: Boolean(state.files.fullscreen),
@@ -3502,7 +3963,35 @@ function FilesPane({ controller, focused, mobile = false }) {
3502
3963
  }
3503
3964
  }, [controller, viewState.canBrowserUpload, viewState.canPathUpload]);
3504
3965
 
3966
+ // Declared BEFORE panelActions, which reads it. When this sat further down
3967
+ // the component it was a temporal dead zone violation: panelActions threw
3968
+ // "Cannot access 'markedSet' before initialization" during render, React
3969
+ // unmounted the whole tree, and the portal went blank on opening Artifacts.
3970
+ const markedSet = React.useMemo(() => new Set(viewState.markedIds || []), [viewState.markedIds]);
3971
+
3505
3972
  const panelActions = React.createElement(React.Fragment, null,
3973
+ // Bulk actions only appear once something is marked, so the header
3974
+ // stays quiet in the common single-selection case.
3975
+ markedSet.size > 0
3976
+ ? React.createElement(React.Fragment, null,
3977
+ React.createElement("span", {
3978
+ className: "ps-mini-button-label",
3979
+ style: { padding: "0 6px", opacity: 0.85 },
3980
+ title: "Cmd/Ctrl-click to mark, Shift-click for a range",
3981
+ }, `${markedSet.size} marked`),
3982
+ viewState.canDeleteArtifacts
3983
+ ? React.createElement(IconButton, {
3984
+ icon: "🗑",
3985
+ label: `Delete ${markedSet.size} marked`,
3986
+ onClick: () => controller.deleteMarkedArtifacts().catch(() => {}),
3987
+ })
3988
+ : null,
3989
+ React.createElement(IconButton, {
3990
+ icon: "✕",
3991
+ label: "Clear marks",
3992
+ onClick: () => controller.clearArtifactMarks(),
3993
+ }))
3994
+ : null,
3506
3995
  React.createElement("input", {
3507
3996
  ref: fileInputRef,
3508
3997
  type: "file",
@@ -3544,12 +4033,26 @@ function FilesPane({ controller, focused, mobile = false }) {
3544
4033
  label: "Filter",
3545
4034
  onClick: () => controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {}),
3546
4035
  }),
4036
+ // Desktop keeps the plain fullscreen toggle it has always had. The
4037
+ // back affordance belongs to the mobile overlay, which renders its own
4038
+ // top bar — putting it here too would change desktop behavior.
3547
4039
  React.createElement(IconButton, {
3548
4040
  icon: viewState.fullscreen ? "⇱" : "⛶",
3549
4041
  label: viewState.fullscreen ? "Exit fullscreen" : "Fullscreen",
3550
4042
  onClick: () => controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {}),
3551
4043
  }));
3552
4044
 
4045
+ // Keyboard selection has to drag the viewport with it, exactly as the
4046
+ // session list does — otherwise j/k walks the selection straight out of
4047
+ // view and the list appears frozen.
4048
+ const selectedItemRef = React.useRef(null);
4049
+ React.useEffect(() => {
4050
+ const node = selectedItemRef.current;
4051
+ if (node && typeof node.scrollIntoView === "function") {
4052
+ node.scrollIntoView({ block: "nearest" });
4053
+ }
4054
+ }, [filesView.selectedIndex, viewState.selectedArtifactId]);
4055
+
3553
4056
  const listContent = items.length === 0
3554
4057
  ? normalizeLines(filesView.listBodyLines || []).map((line, index) => React.createElement(Line, {
3555
4058
  key: `empty:${index}`,
@@ -3559,34 +4062,114 @@ function FilesPane({ controller, focused, mobile = false }) {
3559
4062
  : items.map((item, index) => React.createElement("button", {
3560
4063
  key: item.id,
3561
4064
  type: "button",
3562
- className: `ps-list-button${index === filesView.selectedIndex ? " is-selected" : ""}`,
3563
- onClick: () => {
4065
+ ref: index === filesView.selectedIndex ? selectedItemRef : null,
4066
+ className: `ps-list-button${index === filesView.selectedIndex ? " is-selected" : ""}`
4067
+ + (markedSet.has(item.id) ? " is-marked" : ""),
4068
+ onClick: (event) => {
3564
4069
  controller.setFocus("inspector");
3565
- controller.selectFileBrowserItem(item).catch(() => {});
4070
+ // Cmd/Ctrl toggles one, Shift extends a run, plain click
4071
+ // selects and drops the marks — the usual list conventions.
4072
+ if (event.metaKey || event.ctrlKey) {
4073
+ controller.toggleArtifactMark(item);
4074
+ return;
4075
+ }
4076
+ if (event.shiftKey) {
4077
+ controller.markArtifactRange(item);
4078
+ return;
4079
+ }
4080
+ controller.clearArtifactMarks();
4081
+ controller.selectFileBrowserItem(item)
4082
+ .then(() => { if (mobile) controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {}); })
4083
+ .catch(() => {});
3566
4084
  },
3567
4085
  }, React.createElement(Line, {
3568
4086
  line: normalizeLines([filesView.listBodyLines?.[index]])[0],
3569
4087
  theme,
3570
4088
  })));
3571
4089
 
3572
- const previewPane = filesView.previewRenderMode === "markdown"
4090
+ // A .patch/.diff artifact is rendered AS a diff — gutter markers, add/remove
4091
+ // tinting — rather than as undifferentiated grey text. This is the whole
4092
+ // point of clicking through from the transcript.
4093
+ const previewReady = !filesView.previewLoading && !filesView.previewError;
4094
+ const previewIsDiff = previewReady
4095
+ && isDiffArtifact(filesView.selectedFilename, filesView.previewContentType)
4096
+ && !filesView.previewIsBinary
4097
+ && Boolean(filesView.previewContent);
4098
+
4099
+ // Images are routed by content type OR extension, and go to the binary
4100
+ // panel even when the payload is text (SVG) — it owns the authenticated
4101
+ // fetch → object URL path that renders an artifact safely.
4102
+ const previewIsImage = previewReady
4103
+ && (IMAGE_CONTENT_TYPE_RE.test(String(filesView.previewContentType || ""))
4104
+ || IMAGE_FILENAME_RE.test(String(filesView.selectedFilename || "")));
4105
+
4106
+ // Source files get real syntax highlighting instead of undifferentiated
4107
+ // grey text — the same highlighter the chat code blocks use.
4108
+ const previewCodeLanguage = previewReady
4109
+ && !filesView.previewIsBinary
4110
+ && !previewIsDiff
4111
+ && filesView.previewRenderMode !== "markdown"
4112
+ && Boolean(filesView.previewContent)
4113
+ ? codeLanguageForArtifact(filesView.selectedFilename)
4114
+ : null;
4115
+
4116
+ // Detached (desktop) => the preview is its own pane in the activity slot and
4117
+ // claims activity focus, so arrows/j/k/PgUp scroll IT. Inline (mobile) it is
4118
+ // part of the inspector and keeps inspector focus.
4119
+ const previewFocusRegion = previewOnly ? "activity" : null;
4120
+ // In the mobile overlay the browser owns scrolling outright.
4121
+ const previewPaneKey = nativeScroll ? null : "filePreview";
4122
+
4123
+ const previewPane = previewIsImage
4124
+ ? React.createElement(BinaryArtifactPreviewPanel, {
4125
+ title: null,
4126
+ color: "cyan",
4127
+ focused: false,
4128
+ theme,
4129
+ filename: filesView.selectedFilename,
4130
+ contentType: filesView.previewContentType,
4131
+ sizeBytes: filesView.previewSizeBytes,
4132
+ source: filesView.previewSource,
4133
+ uploadedAt: filesView.previewUploadedAt,
4134
+ controller,
4135
+ sessionId: filesView.selectedSessionId,
4136
+ })
4137
+ : previewCodeLanguage
4138
+ ? React.createElement(RenderedPreviewPanel, {
4139
+ controller, title: null, color: "cyan", focused: false, theme,
4140
+ paneKey: previewPaneKey, scrollOffset: viewState.previewScroll,
4141
+ className: "ps-diff-preview", focusRegion: previewFocusRegion,
4142
+ },
4143
+ React.createElement("pre", { className: "ps-md-code-pre" },
4144
+ React.createElement("code", null,
4145
+ renderHighlightedCode(filesView.previewContent, previewCodeLanguage, theme))))
4146
+ : previewIsDiff
4147
+ ? React.createElement(RenderedPreviewPanel, {
4148
+ controller, title: null, color: "cyan", focused: false, theme,
4149
+ paneKey: previewPaneKey, scrollOffset: viewState.previewScroll,
4150
+ className: "ps-diff-preview", focusRegion: previewFocusRegion,
4151
+ },
4152
+ React.createElement("pre", { className: "ps-md-code-pre is-diff" },
4153
+ React.createElement("code", null, renderDiffCode(filesView.previewContent, theme))))
4154
+ : filesView.previewRenderMode === "markdown"
3573
4155
  && !filesView.previewLoading
3574
4156
  && !filesView.previewError
3575
4157
  ? React.createElement(MarkdownPreviewPanel, {
3576
4158
  controller,
3577
- title: filesView.previewTitle,
4159
+ title: null,
3578
4160
  color: "cyan",
3579
4161
  focused: false,
3580
4162
  scrollOffset: viewState.previewScroll,
3581
- paneKey: "filePreview",
4163
+ paneKey: previewPaneKey,
3582
4164
  theme,
3583
- content: filesView.previewContent || "",
4165
+ focusRegion: previewFocusRegion,
4166
+ content: stripYamlFrontmatter(filesView.previewContent || ""),
3584
4167
  })
3585
4168
  : filesView.previewIsBinary
3586
4169
  && !filesView.previewLoading
3587
4170
  && !filesView.previewError
3588
4171
  ? React.createElement(BinaryArtifactPreviewPanel, {
3589
- title: filesView.previewTitle,
4172
+ title: null,
3590
4173
  color: "cyan",
3591
4174
  focused: false,
3592
4175
  theme,
@@ -3600,17 +4183,20 @@ function FilesPane({ controller, focused, mobile = false }) {
3600
4183
  })
3601
4184
  : React.createElement(ScrollLinesPanel, {
3602
4185
  controller,
3603
- title: filesView.previewTitle,
4186
+ title: null,
3604
4187
  color: "cyan",
3605
4188
  focused: false,
3606
4189
  lines: filesView.previewLines,
3607
4190
  scrollOffset: viewState.previewScroll,
3608
4191
  scrollMode: "top",
3609
- paneKey: "filePreview",
4192
+ paneKey: previewPaneKey,
4193
+ focusRegion: previewFocusRegion,
3610
4194
  className: "is-preview is-wrapped",
3611
4195
  });
3612
4196
  const view = viewState;
3613
4197
 
4198
+ if (previewOnly) return previewPane;
4199
+
3614
4200
  return React.createElement(Panel, {
3615
4201
  title: view.fullscreen
3616
4202
  ? filesView.fullscreenTitle
@@ -3626,10 +4212,16 @@ function FilesPane({ controller, focused, mobile = false }) {
3626
4212
  // Fullscreen files mode shows only the preview pane; the list stays hidden.
3627
4213
  view.fullscreen
3628
4214
  ? previewPane
3629
- : React.createElement("div", { 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,
4215
+ : React.createElement("div", {
4216
+ // The pane is a LIST, full stop. Desktop detaches the preview into
4217
+ // the activity slot; mobile opens the full-screen overlay on tap.
4218
+ // Neither wants a cramped preview stacked under the list.
4219
+ className: "ps-files-grid is-list-only",
4220
+ },
4221
+ // No nested panel: the enclosing Files pane is already the box, and
4222
+ // a second border + header inside it only ate vertical space to
4223
+ // restate what the pane title says.
4224
+ React.createElement("div", { className: "ps-action-list ps-files-list" }, listContent),
3633
4225
  ));
3634
4226
  }
3635
4227
 
@@ -3868,9 +4460,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
3868
4460
  structuredRows: mobile,
3869
4461
  actions: React.createElement("button", {
3870
4462
  type: "button",
3871
- className: "ps-mini-button",
4463
+ className: "ps-mini-button ps-overlay-close",
3872
4464
  onClick: onClose,
3873
- }, "Close"),
4465
+ "aria-label": "Close",
4466
+ title: "Close",
4467
+ }, "✕"),
3874
4468
  });
3875
4469
  } else if (pane === "inspector") {
3876
4470
  content = React.createElement(InspectorPane, {
@@ -3879,9 +4473,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
3879
4473
  panelClassName: "ps-chat-focus-pane",
3880
4474
  extraActions: React.createElement("button", {
3881
4475
  type: "button",
3882
- className: "ps-mini-button",
4476
+ className: "ps-mini-button ps-overlay-close",
3883
4477
  onClick: onClose,
3884
- }, "Close"),
4478
+ "aria-label": "Close",
4479
+ title: "Close",
4480
+ }, "✕"),
3885
4481
  });
3886
4482
  } else if (pane === "activity") {
3887
4483
  content = React.createElement(ActivityPane, {
@@ -3889,9 +4485,11 @@ function ChatFocusOverlay({ controller, pane, onClose, mobile = false }) {
3889
4485
  panelClassName: "ps-chat-focus-pane",
3890
4486
  extraActions: React.createElement("button", {
3891
4487
  type: "button",
3892
- className: "ps-mini-button",
4488
+ className: "ps-mini-button ps-overlay-close",
3893
4489
  onClick: onClose,
3894
- }, "Close"),
4490
+ "aria-label": "Close",
4491
+ title: "Close",
4492
+ }, "✕"),
3895
4493
  });
3896
4494
  }
3897
4495
 
@@ -4997,7 +5595,7 @@ function ModalLayer({ controller }) {
4997
5595
  React.createElement("div", { className: `ps-modal${modal.type === "themePicker" ? " is-theme-picker" : ""}`, onClick: (event) => event.stopPropagation() },
4998
5596
  React.createElement("div", { className: "ps-modal-header" },
4999
5597
  React.createElement("div", { className: "ps-modal-title" }, presentation.title),
5000
- React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close }, "Close"),
5598
+ React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
5001
5599
  ),
5002
5600
  React.createElement("div", { className: "ps-modal-grid" },
5003
5601
  React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
@@ -5029,7 +5627,7 @@ function ModalLayer({ controller }) {
5029
5627
  React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
5030
5628
  React.createElement("div", { className: "ps-modal-header" },
5031
5629
  React.createElement("div", { className: "ps-modal-title" }, modalState.confirm.title),
5032
- React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close }, "Close"),
5630
+ React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
5033
5631
  ),
5034
5632
  React.createElement("div", { className: "ps-modal-body", style: { padding: "16px 20px" } },
5035
5633
  React.createElement("p", { style: { color: "#94a3b8", margin: 0 } }, modalState.confirm.message),
@@ -5048,6 +5646,9 @@ function ModalLayer({ controller }) {
5048
5646
  React.createElement("option", { value: "deterministic" }, "Fast (no LLM — tail + pointers)"),
5049
5647
  ),
5050
5648
  ),
5649
+ (extras.distillMode || "llm") === "llm"
5650
+ ? React.createElement(DistillerModelPickers, { controller, extras })
5651
+ : null,
5051
5652
  (extras.distillMode || "llm") === "llm"
5052
5653
  ? React.createElement("textarea", {
5053
5654
  className: "ps-modal-input",
@@ -5100,7 +5701,7 @@ function ModalLayer({ controller }) {
5100
5701
  React.createElement("div", { className: "ps-modal", onClick: (event) => event.stopPropagation() },
5101
5702
  React.createElement("div", { className: "ps-modal-header" },
5102
5703
  React.createElement("div", { className: "ps-modal-title" }, presentation.title),
5103
- React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close }, "Close"),
5704
+ React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
5104
5705
  ),
5105
5706
  React.createElement("div", { className: "ps-modal-grid" },
5106
5707
  React.createElement("div", { ref: listModalRef, className: "ps-modal-list" },
@@ -5132,7 +5733,7 @@ function ModalLayer({ controller }) {
5132
5733
  React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
5133
5734
  React.createElement("div", { className: "ps-modal-header" },
5134
5735
  React.createElement("div", { className: "ps-modal-title" }, modalState.renameSession.title),
5135
- React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close }, "Close"),
5736
+ React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
5136
5737
  ),
5137
5738
  React.createElement("input", {
5138
5739
  ref: renameInputRef,
@@ -5165,7 +5766,7 @@ function ModalLayer({ controller }) {
5165
5766
  React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
5166
5767
  React.createElement("div", { className: "ps-modal-header" },
5167
5768
  React.createElement("div", { className: "ps-modal-title" }, modalState.sessionGroupName.title),
5168
- React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close }, "Close"),
5769
+ React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
5169
5770
  ),
5170
5771
  React.createElement("input", {
5171
5772
  ref: groupNameInputRef,
@@ -5210,7 +5811,7 @@ function ModalLayer({ controller }) {
5210
5811
  React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
5211
5812
  React.createElement("div", { className: "ps-modal-header" },
5212
5813
  React.createElement("div", { className: "ps-modal-title" }, modal.title || "Session Lifecycle"),
5213
- React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close }, "Close"),
5814
+ React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
5214
5815
  ),
5215
5816
  React.createElement("div", { className: "ps-modal-body", style: { padding: "12px 16px 4px" } },
5216
5817
  React.createElement("p", { style: { color: "#94a3b8", margin: 0 } }, bodyText)),
@@ -5255,7 +5856,7 @@ function ModalLayer({ controller }) {
5255
5856
  React.createElement("div", { className: "ps-modal is-narrow", onClick: (event) => event.stopPropagation() },
5256
5857
  React.createElement("div", { className: "ps-modal-header" },
5257
5858
  React.createElement("div", { className: "ps-modal-title" }, modalState.artifactUpload.title),
5258
- React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close }, "Close"),
5859
+ React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
5259
5860
  ),
5260
5861
  React.createElement("input", {
5261
5862
  className: "ps-modal-input",
@@ -5289,7 +5890,7 @@ function ModalLayer({ controller }) {
5289
5890
  React.createElement("div", { className: "ps-modal is-wide", onClick: (event) => event.stopPropagation() },
5290
5891
  React.createElement("div", { className: "ps-modal-header" },
5291
5892
  React.createElement("div", { className: "ps-modal-title" }, filterPresentation.title),
5292
- React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close }, "Close"),
5893
+ React.createElement("button", { type: "button", className: "ps-modal-close", onClick: close, "aria-label": "Close", title: "Close" }, "✕"),
5293
5894
  ),
5294
5895
  React.createElement("div", { className: "ps-filter-grid" },
5295
5896
  (modal.items || []).map((item, itemIndex) => {
@@ -5327,6 +5928,75 @@ function ModalLayer({ controller }) {
5327
5928
  return null;
5328
5929
  }
5329
5930
 
5931
+ // Distiller model / effort / context-tier pickers for the regenerate confirm.
5932
+ // The distiller runs on deployment machinery config rather than the served
5933
+ // session's model, so these are explicit choices; empty means "deployment
5934
+ // default". Effort and tier lists follow the SELECTED model, so an
5935
+ // unsupported combination cannot be submitted.
5936
+ function DistillerModelPickers({ controller, extras }) {
5937
+ const options = Array.isArray(extras.distillerModelOptions) ? extras.distillerModelOptions : [];
5938
+ const selected = options.find((m) => m.qualifiedName === extras.distillerModel) || null;
5939
+ const efforts = selected?.supportedReasoningEfforts || [];
5940
+ const tiers = selected?.supportedContextTiers || [];
5941
+ const labelStyle = { color: "#94a3b8", fontSize: "12px", display: "flex", alignItems: "center", gap: 8 };
5942
+ const selectStyle = { flex: "1", padding: "4px 8px" };
5943
+
5944
+ const pick = (patch) => controller.updateConfirmExtras(patch);
5945
+
5946
+ return React.createElement(React.Fragment, null,
5947
+ React.createElement("label", { style: labelStyle },
5948
+ "Distiller model",
5949
+ React.createElement("select", {
5950
+ className: "ps-modal-input",
5951
+ style: selectStyle,
5952
+ value: extras.distillerModel || "",
5953
+ // Switching model clears effort/tier: the previous values may
5954
+ // not exist on the new model.
5955
+ onChange: (event) => pick({
5956
+ distillerModel: event.currentTarget.value,
5957
+ distillerEffort: "",
5958
+ distillerContextTier: "",
5959
+ }),
5960
+ },
5961
+ React.createElement("option", { value: "" }, "Deployment default"),
5962
+ options.map((m) => React.createElement("option", {
5963
+ key: m.qualifiedName,
5964
+ value: m.qualifiedName,
5965
+ }, m.qualifiedName)),
5966
+ ),
5967
+ ),
5968
+ efforts.length > 0
5969
+ ? React.createElement("label", { style: labelStyle },
5970
+ "Reasoning",
5971
+ React.createElement("select", {
5972
+ className: "ps-modal-input",
5973
+ style: selectStyle,
5974
+ value: extras.distillerEffort || "",
5975
+ onChange: (event) => pick({ distillerEffort: event.currentTarget.value }),
5976
+ },
5977
+ React.createElement("option", { value: "" }, "Model default"),
5978
+ efforts.map((e) => React.createElement("option", { key: e, value: e }, e)),
5979
+ ),
5980
+ )
5981
+ : null,
5982
+ tiers.length > 0
5983
+ ? React.createElement("label", { style: labelStyle },
5984
+ "Context",
5985
+ React.createElement("select", {
5986
+ className: "ps-modal-input",
5987
+ style: selectStyle,
5988
+ value: extras.distillerContextTier || "",
5989
+ onChange: (event) => pick({ distillerContextTier: event.currentTarget.value }),
5990
+ },
5991
+ React.createElement("option", { value: "" }, "Model default"),
5992
+ tiers.map((t) => React.createElement("option", { key: t, value: t },
5993
+ t === "long_context" ? "long context (fits a larger transcript)" : t)),
5994
+ ),
5995
+ )
5996
+ : null,
5997
+ );
5998
+ }
5999
+
5330
6000
  function useKeyboardShortcuts(controller, mobile) {
5331
6001
  React.useEffect(() => {
5332
6002
  const handler = (event) => {
@@ -5595,6 +6265,23 @@ function useKeyboardShortcuts(controller, mobile) {
5595
6265
  controller.handleCommand(UI_COMMANDS.FOCUS_RIGHT).catch(() => {});
5596
6266
  return;
5597
6267
  }
6268
+ // The Files tab is a LIST, so arrows/j/k must move the selection
6269
+ // there the same way they do in the session list — otherwise they
6270
+ // fall through to the generic scroll below and the artifact list
6271
+ // cannot be driven from the keyboard at all. Must precede the
6272
+ // scroll fallback.
6273
+ if (!mobile && focusRegion === "inspector" && currentInspectorTab === "files"
6274
+ && (event.key === "ArrowUp" || event.key === "k")) {
6275
+ event.preventDefault();
6276
+ controller.handleCommand(UI_COMMANDS.MOVE_FILE_UP).catch(() => {});
6277
+ return;
6278
+ }
6279
+ if (!mobile && focusRegion === "inspector" && currentInspectorTab === "files"
6280
+ && (event.key === "ArrowDown" || event.key === "j")) {
6281
+ event.preventDefault();
6282
+ controller.handleCommand(UI_COMMANDS.MOVE_FILE_DOWN).catch(() => {});
6283
+ return;
6284
+ }
5598
6285
  if (!mobile && (event.key === "ArrowUp" || event.key === "k")) {
5599
6286
  event.preventDefault();
5600
6287
  controller.handleCommand(UI_COMMANDS.SCROLL_UP).catch(() => {});
@@ -5756,9 +6443,17 @@ function AdminConsolePanel({ controller }) {
5756
6443
 
5757
6444
  export function createWebPilotSwarmController({ transport, mode = "remote", branding = null } = {}) {
5758
6445
  clearBrowserPreferenceCache();
6446
+ // Rich prose is the right default on a desktop transcript; on a phone the
6447
+ // terminal view fits far more per screen and does not reflow wide content.
6448
+ // This is only the DEFAULT — a stored profile preference still wins once
6449
+ // settings load, so an explicit choice is never overridden.
6450
+ const isNarrowViewport = typeof window !== "undefined"
6451
+ && typeof window.matchMedia === "function"
6452
+ && window.matchMedia("(max-width: 920px)").matches;
5759
6453
  const store = createStore(appReducer, createInitialState({
5760
6454
  mode,
5761
6455
  branding,
6456
+ chatViewMode: isNarrowViewport ? "transcript" : "rich",
5762
6457
  }));
5763
6458
  return new PilotSwarmUiController({ store, transport });
5764
6459
  }
@@ -5835,10 +6530,15 @@ export function PilotSwarmWebApp({ controller }) {
5835
6530
  focusRegion: rootState.ui.focusRegion,
5836
6531
  inspectorTab: rootState.ui.inspectorTab,
5837
6532
  filesFullscreen: Boolean(rootState.files.fullscreen),
6533
+ selectedArtifactId: rootState.files.selectedArtifactId || null,
5838
6534
  adminVisible: Boolean(rootState.admin?.visible),
5839
6535
  }), shallowEqualObject);
5840
6536
  const profileSettingsHydratedRef = React.useRef(false);
5841
6537
  const lastProfileSettingsJsonRef = React.useRef(null);
6538
+ // Last-known value of the OTHER device class's chat-view slot. The save
6539
+ // endpoint replaces the whole settings object, so this is what keeps a
6540
+ // desktop save from erasing the phone's preference.
6541
+ const otherChatViewModeRef = React.useRef(null);
5842
6542
  const profileSettingsSaveTimerRef = React.useRef(null);
5843
6543
  const profileSettingsPollTimerRef = React.useRef(null);
5844
6544
  const profileSettingsPollInFlightRef = React.useRef(false);
@@ -5892,13 +6592,21 @@ export function PilotSwarmWebApp({ controller }) {
5892
6592
  // the principal is resolved by now (it was likely null at mount),
5893
6593
  // so the "no persisted filter" fallback becomes the correct
5894
6594
  // principal-scoped default (Me+System) instead of {all:true}.
5895
- defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(controller.getState());
6595
+ const remoteNormalized = normalizeProfileSettings(profile?.profileSettings);
6596
+ if (isChatViewMode(remoteNormalized[otherChatViewModeKey()])) {
6597
+ otherChatViewModeRef.current = remoteNormalized[otherChatViewModeKey()];
6598
+ }
6599
+ defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
6600
+ controller.getState(), otherChatViewModeRef.current,
6601
+ );
5896
6602
  const settings = materializeProfileSettings(
5897
6603
  profile?.profileSettings,
5898
6604
  defaultProfileSettingsRef.current,
5899
6605
  );
5900
6606
  const settingsJson = JSON.stringify(settings);
5901
- const currentSettingsBeforeApply = profileSettingsFromViewState(controller.getState());
6607
+ const currentSettingsBeforeApply = profileSettingsFromViewState(
6608
+ controller.getState(), otherChatViewModeRef.current,
6609
+ );
5902
6610
  const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
5903
6611
  const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
5904
6612
  && lastProfileSettingsJsonRef.current != null
@@ -5911,7 +6619,9 @@ export function PilotSwarmWebApp({ controller }) {
5911
6619
  appliedProfileSettingsJsonRef.current = settingsJson;
5912
6620
  }
5913
6621
 
5914
- const currentSettings = profileSettingsFromViewState(controller.getState());
6622
+ const currentSettings = profileSettingsFromViewState(
6623
+ controller.getState(), otherChatViewModeRef.current,
6624
+ );
5915
6625
  lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
5916
6626
  profileSettingsHydratedRef.current = true;
5917
6627
  } catch {
@@ -5943,7 +6653,7 @@ export function PilotSwarmWebApp({ controller }) {
5943
6653
 
5944
6654
  React.useEffect(() => {
5945
6655
  if (!profileSettingsHydratedRef.current) return undefined;
5946
- const settings = profileSettingsFromViewState(state);
6656
+ const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current);
5947
6657
  const settingsJson = JSON.stringify(settings);
5948
6658
  if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
5949
6659
  lastProfileSettingsJsonRef.current = settingsJson;
@@ -5966,7 +6676,19 @@ export function PilotSwarmWebApp({ controller }) {
5966
6676
  applyDocumentTheme(state.themeId);
5967
6677
  }, [state.themeId]);
5968
6678
 
6679
+ // Follow focus only on an actual TRANSITION, never on the first run.
6680
+ //
6681
+ // The default focus is "sessions", but the mobile layout has no sessions
6682
+ // region, so normalizeFocusRegion rewrites it to order[0] — the inspector.
6683
+ // Syncing that initial value made a plain page load open the inspector on
6684
+ // its default (sequence) tab instead of the chat. A real focus change, e.g.
6685
+ // tapping the mobile nav, still switches panes.
6686
+ const lastFocusRef = React.useRef(null);
5969
6687
  React.useEffect(() => {
6688
+ const previous = lastFocusRef.current;
6689
+ lastFocusRef.current = state.focusRegion;
6690
+ if (previous === null) return;
6691
+ if (previous === state.focusRegion) return;
5970
6692
  if (mobile && state.focusRegion !== "prompt") {
5971
6693
  setMobilePane(state.focusRegion === "activity"
5972
6694
  ? "activity"
@@ -5988,6 +6710,12 @@ export function PilotSwarmWebApp({ controller }) {
5988
6710
  [gridViewport, state.activityPaneAdjust, state.paneAdjust, effectivePromptRows, state.sessionPaneAdjust],
5989
6711
  );
5990
6712
  const filesFullscreenActive = state.filesFullscreen && state.inspectorTab === "files";
6713
+ // Preview detaches into the activity slot only while the Files tab is open
6714
+ // AND something is selected — otherwise Activity keeps the slot. Fullscreen
6715
+ // is excluded because it already shows the preview alone.
6716
+ const artifactPreviewDetached = state.inspectorTab === "files"
6717
+ && Boolean(state.selectedArtifactId)
6718
+ && !filesFullscreenActive;
5991
6719
 
5992
6720
  React.useEffect(() => {
5993
6721
  if (!filesFullscreenActive || !chatFocusMode) return;
@@ -6077,7 +6805,12 @@ export function PilotSwarmWebApp({ controller }) {
6077
6805
  className: "ps-workspace-pane-slot",
6078
6806
  style: { gridRow: "3" },
6079
6807
  },
6080
- !layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null)));
6808
+ // The artifact preview takes over the activity slot while an artifact
6809
+ // is selected, so it inherits the row resizer and can be sized freely.
6810
+ // It yields back to Activity the moment the selection clears.
6811
+ artifactPreviewDetached
6812
+ ? React.createElement(FilesPane, { controller, focused: false, previewOnly: true })
6813
+ : (!layout.activityHidden ? React.createElement(ActivityPane, { controller }) : null))));
6081
6814
  const chatFocusWorkspace = React.createElement(ChatFocusWorkspace, {
6082
6815
  controller,
6083
6816
  openPane: chatFocusPane,
@@ -6089,7 +6822,9 @@ export function PilotSwarmWebApp({ controller }) {
6089
6822
  React.createElement(InspectorPane, { controller, mobile: false }));
6090
6823
 
6091
6824
  let mobileContent = null;
6092
- if (filesFullscreenActive) mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
6825
+ // Mobile gets the overlay below instead of an in-pane fullscreen view; the
6826
+ // workspace keeps rendering underneath so backing out restores it intact.
6827
+ if (filesFullscreenActive && !mobile) mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
6093
6828
  React.createElement(InspectorPane, { controller, mobile: true }));
6094
6829
  else if (mobilePane === "inspector") mobileContent = React.createElement("div", { className: "ps-mobile-pane-fill" },
6095
6830
  React.createElement(InspectorPane, { controller, mobile: true }));
@@ -6097,7 +6832,8 @@ export function PilotSwarmWebApp({ controller }) {
6097
6832
  React.createElement(ActivityPane, { controller }));
6098
6833
  else mobileContent = React.createElement(MobileWorkspace, { controller });
6099
6834
 
6100
- return React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
6835
+ return React.createElement(ControllerContext.Provider, { value: controller },
6836
+ React.createElement("div", { ref: viewportRef, className: "ps-web-shell" },
6101
6837
  // Hide the top toolbar on mobile inspector/activity panes (and in
6102
6838
  // chat-focus mode). Those panes are pure read-only surfaces;
6103
6839
  // session/model/theme actions stay reachable from the Main pane,
@@ -6122,5 +6858,8 @@ export function PilotSwarmWebApp({ controller }) {
6122
6858
  ? chatFocusWorkspace
6123
6859
  : (mobile ? mobileContent : desktopWorkspace)))),
6124
6860
  mobile && !chatFocusMode ? React.createElement(MobileNav, { activePane: mobilePane, setActivePane: setMobilePane, controller }) : null,
6125
- React.createElement(ModalLayer, { controller }));
6861
+ mobile && filesFullscreenActive
6862
+ ? React.createElement(MobileArtifactOverlay, { controller })
6863
+ : null,
6864
+ React.createElement(ModalLayer, { controller })));
6126
6865
  }