pilotswarm 0.5.22 → 0.5.24

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