pilotswarm 0.5.34 → 0.5.35

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 (77) hide show
  1. package/package.json +2 -2
  2. package/ui/core/src/controller.js +152 -12
  3. package/ui/core/src/formatting.js +13 -1
  4. package/ui/core/src/history.js +8 -0
  5. package/ui/core/src/reducer.js +47 -0
  6. package/ui/core/src/selectors.js +10 -5
  7. package/ui/core/src/state.js +28 -0
  8. package/ui/core/src/themes/duroxide.js +136 -0
  9. package/ui/core/src/themes/index.js +2 -0
  10. package/ui/react/src/web-app.js +946 -46
  11. package/web/dist/assets/{abnfDiagram-VRR7QNED-BFOimVWK.js → abnfDiagram-VRR7QNED-BwJEohLG.js} +1 -1
  12. package/web/dist/assets/{arc-uUHjvwEo.js → arc-CWUJ_S5s.js} +1 -1
  13. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CxeX_vea.js → architectureDiagram-ZJ3FMSHR-i6mrLxuE.js} +1 -1
  14. package/web/dist/assets/{blockDiagram-677ZJIJ3-Cno8kltG.js → blockDiagram-677ZJIJ3-C_KG-qPQ.js} +1 -1
  15. package/web/dist/assets/{c4Diagram-LMCZKHZV-viONVybf.js → c4Diagram-LMCZKHZV-BBJH7hbB.js} +1 -1
  16. package/web/dist/assets/channel-5Ddqi9ib.js +1 -0
  17. package/web/dist/assets/{chunk-2Q5K7J3B-CKfICIGD.js → chunk-2Q5K7J3B-CXwaFcaM.js} +1 -1
  18. package/web/dist/assets/{chunk-32BRIVSS-CxmwEY56.js → chunk-32BRIVSS-C8ujjkK-.js} +1 -1
  19. package/web/dist/assets/{chunk-5VM5RSS4-_eVYLGll.js → chunk-5VM5RSS4-M9rz7YYk.js} +1 -1
  20. package/web/dist/assets/{chunk-EX3LRPZG-C4aqbjrf.js → chunk-EX3LRPZG-bk6tRxOA.js} +1 -1
  21. package/web/dist/assets/{chunk-JWPE2WC7-Bh8ZxrI8.js → chunk-JWPE2WC7-DZ4WSHGH.js} +1 -1
  22. package/web/dist/assets/{chunk-MOJQB5TN-BXgLlWVP.js → chunk-MOJQB5TN-B5-y6tMF.js} +1 -1
  23. package/web/dist/assets/{chunk-RYQCIY6F-B1rMIdfF.js → chunk-RYQCIY6F-yKcnHxm0.js} +1 -1
  24. package/web/dist/assets/{chunk-V7JOEXUC-C3AnSHXk.js → chunk-V7JOEXUC-CpoNcATD.js} +1 -1
  25. package/web/dist/assets/{chunk-VR4S4FIN-CeoMJIBs.js → chunk-VR4S4FIN-Bf1yD2fn.js} +1 -1
  26. package/web/dist/assets/{chunk-XXDRQBXY-DzaxQ9iP.js → chunk-XXDRQBXY-EnPvDVhP.js} +1 -1
  27. package/web/dist/assets/classDiagram-OUVF2IWQ-B8u1fZiB.js +1 -0
  28. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B8u1fZiB.js +1 -0
  29. package/web/dist/assets/{cose-bilkent-JH36ORCC-G46fvUOx.js → cose-bilkent-JH36ORCC-qhKoA2qj.js} +1 -1
  30. package/web/dist/assets/{cynefin-VYW2F7L2-DXwqVhWA.js → cynefin-VYW2F7L2-jcyYelJS.js} +1 -1
  31. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-DNNFmaXb.js → cynefinDiagram-TSTJHNR4-D4RLufLP.js} +1 -1
  32. package/web/dist/assets/{dagre-VKFMJZFB-BCfh-h3o.js → dagre-VKFMJZFB-C9BA3GTO.js} +1 -1
  33. package/web/dist/assets/{diagram-FQU43EPY-CyG9b6bj.js → diagram-FQU43EPY-DbSSrVUJ.js} +1 -1
  34. package/web/dist/assets/{diagram-G47NLZAW-DdxDahK3.js → diagram-G47NLZAW-3egISQXQ.js} +1 -1
  35. package/web/dist/assets/{diagram-NH7WQ7WH-CjrZ_eja.js → diagram-NH7WQ7WH-8IKEMGRg.js} +1 -1
  36. package/web/dist/assets/{diagram-OA4YK3LP-5NAjQpk2.js → diagram-OA4YK3LP-VnaogqtK.js} +1 -1
  37. package/web/dist/assets/{diagram-WEI45ONY-fLuDb54-.js → diagram-WEI45ONY-DxigXH0T.js} +1 -1
  38. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-DL9qnZpa.js → ebnfDiagram-CCIWWBDH-BSkNwJ9x.js} +1 -1
  39. package/web/dist/assets/{erDiagram-Q63AITRT-C_ynfrWY.js → erDiagram-Q63AITRT-CKN8JXl6.js} +1 -1
  40. package/web/dist/assets/{flowDiagram-23GEKE2U-dYuH8HYv.js → flowDiagram-23GEKE2U-DDaD5ExP.js} +1 -1
  41. package/web/dist/assets/{ganttDiagram-NO4QXBWP-e8Meh3f3.js → ganttDiagram-NO4QXBWP-tOrBOuoI.js} +1 -1
  42. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-D_tZ4VL9.js → gitGraphDiagram-IHSO6WYX-Cdp867ON.js} +1 -1
  43. package/web/dist/assets/{index-DcQes9h8.js → index-BmwU43_u.js} +6 -6
  44. package/web/dist/assets/index-D-nA7gUe.css +1 -0
  45. package/web/dist/assets/{infoDiagram-FWYZ7A6U-Dffhqp73.js → infoDiagram-FWYZ7A6U-DwcKHD2h.js} +1 -1
  46. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-DYt9H7kU.js → ishikawaDiagram-FXEZZL3T-CWffK5kz.js} +1 -1
  47. package/web/dist/assets/{journeyDiagram-5HDEW3XC-Qv1vVu0F.js → journeyDiagram-5HDEW3XC-BEAgS-EZ.js} +1 -1
  48. package/web/dist/assets/{kanban-definition-HUTT4EX6-CTAUo40G.js → kanban-definition-HUTT4EX6-xDm-mRRj.js} +1 -1
  49. package/web/dist/assets/{linear-CqLsWFLj.js → linear-oP9MA0as.js} +1 -1
  50. package/web/dist/assets/{mermaid.core-okRCGPQQ.js → mermaid.core-DgXXt1OS.js} +4 -4
  51. package/web/dist/assets/{mindmap-definition-LN4V7U3C-DVOOrIwD.js → mindmap-definition-LN4V7U3C-BBQgVrYC.js} +1 -1
  52. package/web/dist/assets/{papaparse.min-Ddflu8oC.js → papaparse.min-CR6JmBWm.js} +1 -1
  53. package/web/dist/assets/{pegDiagram-2B236MQR-BLJlC0MZ.js → pegDiagram-2B236MQR-C2O0cf3j.js} +1 -1
  54. package/web/dist/assets/{pieDiagram-ENE6RG2P-DdOwICOg.js → pieDiagram-ENE6RG2P-D08pUtYU.js} +1 -1
  55. package/web/dist/assets/pilotswarm-D7NLCbuc.js +99 -0
  56. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-B0Th1YO5.js → quadrantDiagram-ABIIQ3AL-D-Z7YoEK.js} +1 -1
  57. package/web/dist/assets/{railroadDiagram-RFXS5EU6-BF2XKMKf.js → railroadDiagram-RFXS5EU6-ClkdveLI.js} +1 -1
  58. package/web/dist/assets/{requirementDiagram-TGXJPOKE-Mr1iSwbS.js → requirementDiagram-TGXJPOKE-IY4xbn-Q.js} +1 -1
  59. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-CrOjVZyg.js → sankeyDiagram-HTMAVEWB-Cpt5i7KM.js} +1 -1
  60. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-H0X4Lydk.js → sequenceDiagram-DBY2YBRQ-c07lALcz.js} +1 -1
  61. package/web/dist/assets/{sizeCapture-X5ZJPWSS-Dus1QEqh.js → sizeCapture-X5ZJPWSS-CmCDiNbI.js} +1 -1
  62. package/web/dist/assets/{stateDiagram-2N3HPSRC-BOBUSwdR.js → stateDiagram-2N3HPSRC-Dueu17zX.js} +1 -1
  63. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-AGpIV7wW.js +1 -0
  64. package/web/dist/assets/{swimlanes-5IMT3BWC-BoqT-Lh7.js → swimlanes-5IMT3BWC-Csc2Fup8.js} +2 -2
  65. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTGZSV0N.js +8 -0
  66. package/web/dist/assets/{timeline-definition-FHXFAJF6-BOrqUQHX.js → timeline-definition-FHXFAJF6-4-lf702e.js} +1 -1
  67. package/web/dist/assets/{vennDiagram-L72KCM5P-DDkNL-Oo.js → vennDiagram-L72KCM5P-CmlaKpLI.js} +1 -1
  68. package/web/dist/assets/{wardleyDiagram-EHGQE667-XUAJQ2X0.js → wardleyDiagram-EHGQE667-B9Qe44pi.js} +1 -1
  69. package/web/dist/assets/{xychartDiagram-FW5EYKEG-ph69PV9y.js → xychartDiagram-FW5EYKEG-BFcc0CLn.js} +1 -1
  70. package/web/dist/index.html +3 -3
  71. package/web/dist/assets/channel-IURmSsjn.js +0 -1
  72. package/web/dist/assets/classDiagram-OUVF2IWQ-FsfpM3iu.js +0 -1
  73. package/web/dist/assets/classDiagram-v2-EOCWNBFH-FsfpM3iu.js +0 -1
  74. package/web/dist/assets/index-Ct7SQv1M.css +0 -1
  75. package/web/dist/assets/pilotswarm-CKh2MxfL.js +0 -99
  76. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DABTDwFh.js +0 -1
  77. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BF5mFN4t.js +0 -8
@@ -7,6 +7,7 @@ import { appendAnimatedDotsToRuns, useAnimatedDots, useSpinnerFrame } from "./ch
7
7
  import {
8
8
  UI_COMMANDS,
9
9
  INSPECTOR_TABS,
10
+ ARTIFACT_DOWNLOAD_HINT,
10
11
  appReducer,
11
12
  canStopSessionTurn,
12
13
  computeLegacyLayout,
@@ -144,6 +145,7 @@ function supportsLocalFileOpen(controller) {
144
145
  }
145
146
 
146
147
  const SESSION_LINK_COPIED_STATUS = "Session link copied to clipboard";
148
+ const ARTIFACT_LINK_COPIED_STATUS = "Artifact link copied to clipboard";
147
149
  // Mirrors SESSION_NAV_SETTLE_MS in the controller: the access probe rides the
148
150
  // same "wait for the selection to stop moving" rule as the session fetch.
149
151
  const SESSION_ACCESS_SETTLE_MS = 140;
@@ -154,6 +156,21 @@ function buildSessionLinkUrl(sessionId) {
154
156
  return `${window.location.origin}${window.location.pathname}?session=${encodeURIComponent(sessionId)}`;
155
157
  }
156
158
 
159
+ /**
160
+ * Deep link straight to one artifact's preview: `?session=…&artifact=…&view=full`.
161
+ *
162
+ * This is also what "open in a new tab" navigates to. It deliberately does NOT
163
+ * open the artifact's blob URL directly — a blob: document opened at top level
164
+ * runs at the PORTAL's origin, which would hand untrusted artifact markup the
165
+ * signed-in user's bearer token. Routing through the app keeps every byte of
166
+ * artifact HTML inside the sandboxed iframe.
167
+ */
168
+ function buildArtifactLinkUrl(sessionId, filename, { fullscreen = true } = {}) {
169
+ const base = buildSessionLinkUrl(sessionId);
170
+ if (!base || !filename) return base;
171
+ return `${base}&artifact=${encodeURIComponent(filename)}${fullscreen ? "&view=full" : ""}`;
172
+ }
173
+
157
174
  function copySessionLinkText(url) {
158
175
  if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(url).catch(() => {});
159
176
  }
@@ -202,6 +219,9 @@ function normalizeProfileSettings(settings) {
202
219
  if (typeof candidate.touchScale === "boolean") {
203
220
  normalized.touchScale = candidate.touchScale;
204
221
  }
222
+ if (typeof candidate.touchScaleMobile === "boolean") {
223
+ normalized.touchScaleMobile = candidate.touchScaleMobile;
224
+ }
205
225
  if (hasOwn(candidate, "sessionOwnerFilter") && candidate.sessionOwnerFilter && typeof candidate.sessionOwnerFilter === "object") {
206
226
  const storedFilter = candidate.sessionOwnerFilter;
207
227
  // Profiles saved before the "Shared with me" bucket existed have no
@@ -260,6 +280,25 @@ function otherChatViewModeKey() {
260
280
  return isNarrowViewport() ? "chatViewMode" : "chatViewModeMobile";
261
281
  }
262
282
 
283
+ /**
284
+ * The "Mobile" (touch-scale) preference is per DEVICE CLASS, like chatViewMode:
285
+ * it describes how big the type and hit targets should be on the screen you are
286
+ * actually looking at, so turning it on from a phone must not scale up the
287
+ * desktop. It is otherwise orthogonal to the theme — it lives in the theme
288
+ * picker's footer only because that is the "how this looks" surface.
289
+ *
290
+ * Note the desktop slot keeps the legacy name `touchScale`, so profiles written
291
+ * before the split keep working on desktop with no migration.
292
+ */
293
+ function touchScaleKey() {
294
+ return isNarrowViewport() ? "touchScaleMobile" : "touchScale";
295
+ }
296
+
297
+ /** The touch-scale key belonging to the OTHER device class. */
298
+ function otherTouchScaleKey() {
299
+ return isNarrowViewport() ? "touchScale" : "touchScaleMobile";
300
+ }
301
+
263
302
  function normalizeStoredCollapsedSessionIdsToArray(value) {
264
303
  if (value instanceof Set) {
265
304
  const out = [];
@@ -287,10 +326,10 @@ function hasOwn(value, key) {
287
326
  return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, key));
288
327
  }
289
328
 
290
- function profileSettingsFromViewState(state, preservedOtherChatViewMode = null) {
329
+ function profileSettingsFromViewState(state, preservedOtherChatViewMode = null, preservedOtherTouchScale = null) {
291
330
  return normalizeProfileSettings({
292
331
  themeId: state.themeId,
293
- touchScale: state.touchScale,
332
+ [touchScaleKey()]: state.touchScale,
294
333
  sessionOwnerFilter: state.ownerFilter,
295
334
  layoutAdjustments: {
296
335
  paneAdjust: state.paneAdjust,
@@ -309,13 +348,16 @@ function profileSettingsFromViewState(state, preservedOtherChatViewMode = null)
309
348
  ...(isChatViewMode(preservedOtherChatViewMode)
310
349
  ? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
311
350
  : {}),
351
+ ...(typeof preservedOtherTouchScale === "boolean"
352
+ ? { [otherTouchScaleKey()]: preservedOtherTouchScale }
353
+ : {}),
312
354
  });
313
355
  }
314
356
 
315
- function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode = null) {
357
+ function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode = null, preservedOtherTouchScale = null) {
316
358
  return normalizeProfileSettings({
317
359
  themeId: state?.ui?.themeId,
318
- touchScale: state?.ui?.touchScale,
360
+ [touchScaleKey()]: state?.ui?.touchScale,
319
361
  // Derive the owner-filter default from the RESOLVED principal, never a
320
362
  // snapshot of state.sessions.ownerFilter — at mount that snapshot is
321
363
  // still {all:true} (principal not yet applied), and using it as the
@@ -332,6 +374,9 @@ function buildDefaultProfileSettingsFromState(state, preservedOtherChatViewMode
332
374
  ...(isChatViewMode(preservedOtherChatViewMode)
333
375
  ? { [otherChatViewModeKey()]: preservedOtherChatViewMode }
334
376
  : {}),
377
+ ...(typeof preservedOtherTouchScale === "boolean"
378
+ ? { [otherTouchScaleKey()]: preservedOtherTouchScale }
379
+ : {}),
335
380
  });
336
381
  }
337
382
 
@@ -377,6 +422,19 @@ function materializeProfileSettings(remoteSettings, defaults) {
377
422
  ...(hasOwn(normalizedRemote, chatViewModeKey())
378
423
  ? { chatViewMode: normalizedRemote[chatViewModeKey()] }
379
424
  : {}),
425
+ // touchScale was missing from this merge entirely, which is why the
426
+ // "Mobile" checkbox never survived a reload: the save path wrote it,
427
+ // but nothing ever read it back, and the reducer's hasTouchScale guard
428
+ // then preserved the default. Same no-defaults rule as chatViewMode —
429
+ // synthesizing one here would let a poll clobber a fresh local toggle.
430
+ ...(hasOwn(normalizedRemote, touchScaleKey())
431
+ ? { touchScale: normalizedRemote[touchScaleKey()] }
432
+ // Profiles written before the per-device split carry a single
433
+ // `touchScale`, which IS the desktop key — so only a phone needs to
434
+ // inherit it, and only until it saves a slot of its own.
435
+ : (isNarrowViewport() && hasOwn(normalizedRemote, "touchScale")
436
+ ? { touchScale: normalizedRemote.touchScale }
437
+ : {})),
380
438
  });
381
439
  }
382
440
 
@@ -885,7 +943,9 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
885
943
 
886
944
  function Runs({ runs, theme }) {
887
945
  return React.createElement(React.Fragment, null,
888
- (runs || []).map((run, index) => {
946
+ (runs || []).map((run, index, all) => {
947
+ // The download hint trails the artifact link as its own run.
948
+ const followsArtifactLink = index > 0 && Boolean(parseArtifactHref(all[index - 1]?.href));
889
949
  const style = {
890
950
  color: resolveColor(theme, run.color),
891
951
  backgroundColor: resolveColor(theme, run.backgroundColor),
@@ -906,19 +966,23 @@ function Runs({ runs, theme }) {
906
966
  const isExternalHref = /^https?:\/\//i.test(href);
907
967
  // artifact:// links used to render as an inert span — the only
908
968
  // affordance was "press a to download", which is the wrong verb
909
- // for a patch you want to READ. They now open the Files tab with
910
- // the artifact selected and previewed.
969
+ // for a patch you want to READ. They now render as a card that
970
+ // opens the artifact reader.
911
971
  const artifactRef = parseArtifactHref(href);
912
972
 
913
973
  if (artifactRef) {
914
974
  return React.createElement(ArtifactLink, {
915
975
  key: `${index}:${run.text || ""}`,
916
- label: run.text || "",
917
976
  artifactRef,
918
977
  style,
919
978
  });
920
979
  }
921
980
 
981
+ const text = followsArtifactLink
982
+ ? stripArtifactDownloadHint(run.text || "")
983
+ : (run.text || "");
984
+ if (followsArtifactLink && !text) return null;
985
+
922
986
  return isExternalHref
923
987
  ? React.createElement("a", {
924
988
  key: `${index}:${run.text || ""}`,
@@ -927,11 +991,11 @@ function Runs({ runs, theme }) {
927
991
  target: "_blank",
928
992
  rel: "noreferrer",
929
993
  style,
930
- }, run.text || "")
994
+ }, text)
931
995
  : React.createElement("span", {
932
996
  key: `${index}:${run.text || ""}`,
933
997
  style,
934
- }, run.text || "");
998
+ }, text);
935
999
  }),
936
1000
  );
937
1001
  }
@@ -1098,6 +1162,26 @@ const DIFF_LANGUAGES = new Set(["diff", "patch", "udiff"]);
1098
1162
  const DIFF_ARTIFACT_RE = /\.(patch|diff)$/i;
1099
1163
  const IMAGE_CONTENT_TYPE_RE = /^image\/(png|jpe?g|gif|webp|avif|bmp|svg\+xml|x-icon|vnd\.microsoft\.icon)$/i;
1100
1164
  const IMAGE_FILENAME_RE = /\.(png|jpe?g|gif|webp|avif|bmp|svg|ico)$/i;
1165
+ // Artifacts are stored with whatever content type the uploader declared, and
1166
+ // agents routinely write .svg as text/plain. A blob: URL carries its type
1167
+ // straight to <img>, which renders nothing for a non-image type — so the
1168
+ // extension is the fallback source of truth when the stored type is not one.
1169
+ const IMAGE_MIME_BY_EXTENSION = {
1170
+ png: "image/png",
1171
+ jpg: "image/jpeg",
1172
+ jpeg: "image/jpeg",
1173
+ gif: "image/gif",
1174
+ webp: "image/webp",
1175
+ avif: "image/avif",
1176
+ bmp: "image/bmp",
1177
+ svg: "image/svg+xml",
1178
+ ico: "image/x-icon",
1179
+ };
1180
+
1181
+ function imageMimeFromFilename(filename) {
1182
+ const match = /\.([a-z0-9]+)$/i.exec(String(filename || ""));
1183
+ return match ? (IMAGE_MIME_BY_EXTENSION[match[1].toLowerCase()] || null) : null;
1184
+ }
1101
1185
 
1102
1186
  /** Map an artifact filename to a highlighter language, or null for plain text. */
1103
1187
  const CODE_ARTIFACT_LANGUAGES = {
@@ -1125,6 +1209,18 @@ function stripYamlFrontmatter(content) {
1125
1209
  return text.slice(3 + close.index + close[0].length);
1126
1210
  }
1127
1211
 
1212
+ const HTML_ARTIFACT_RE = /\.(html?|xhtml)$/i;
1213
+
1214
+ /**
1215
+ * HTML-shaped artifacts, recognized by extension OR content type — the same
1216
+ * belt-and-braces rule the diff and tabular predicates use, because an agent
1217
+ * may write `text/html` bytes under a name we would not guess.
1218
+ */
1219
+ function isHtmlArtifact(filename, contentType) {
1220
+ if (HTML_ARTIFACT_RE.test(String(filename || ""))) return true;
1221
+ return /^(text\/html|application\/xhtml\+xml)$/i.test(String(contentType || "").trim().split(";")[0]);
1222
+ }
1223
+
1128
1224
  const TABULAR_ARTIFACT_RE = /\.(csv|tsv)$/i;
1129
1225
 
1130
1226
  function isTabularArtifact(filename, contentType) {
@@ -1324,6 +1420,24 @@ function isDiffArtifact(filename, contentType) {
1324
1420
  return /^text\/x-(patch|diff)$/i.test(String(contentType || "").trim());
1325
1421
  }
1326
1422
 
1423
+ /**
1424
+ * Drop the TUI's "(press a to download)" tail that the shared link decorator
1425
+ * appends after an artifact link.
1426
+ *
1427
+ * It arrives as ordinary text immediately following the link, so it survives
1428
+ * into the portal where the key does not exist and download is the wrong verb
1429
+ * anyway — the card next to it opens a reader. Matched against the exported
1430
+ * constant rather than a second copy of the literal.
1431
+ */
1432
+ function stripArtifactDownloadHint(text) {
1433
+ const value = String(text ?? "");
1434
+ if (value.startsWith(ARTIFACT_DOWNLOAD_HINT)) return value.slice(ARTIFACT_DOWNLOAD_HINT.length);
1435
+ // Leading whitespace already consumed by a preceding run/token.
1436
+ const trimmedHint = ARTIFACT_DOWNLOAD_HINT.trimStart();
1437
+ if (value.startsWith(trimmedHint)) return value.slice(trimmedHint.length);
1438
+ return value;
1439
+ }
1440
+
1327
1441
  /** Parse an `artifact://<sessionId>/<filename>` href. */
1328
1442
  function parseArtifactHref(href) {
1329
1443
  const match = /^artifact:\/\/([^/]+)\/(.+)$/i.exec(String(href || "").trim());
@@ -1344,33 +1458,127 @@ function parseArtifactHref(href) {
1344
1458
  */
1345
1459
  const ControllerContext = React.createContext(null);
1346
1460
 
1347
- function ArtifactLink({ label, artifactRef, style }) {
1461
+ /**
1462
+ * Classify an artifact for display: what kind of thing it is, said in words a
1463
+ * reader recognizes rather than a file extension.
1464
+ *
1465
+ * HTML is called out as its own kind because it behaves differently from every
1466
+ * other artifact — it opens as a live rendered page rather than as text — and
1467
+ * the card should promise that before the click, not after.
1468
+ */
1469
+ function describeArtifact(filename) {
1470
+ const name = String(filename || "");
1471
+ const ext = (/\.([A-Za-z0-9]+)$/.exec(name)?.[1] || "").toLowerCase();
1472
+ if (isHtmlArtifact(name)) return { kind: "page", type: "HTML", noun: "Page" };
1473
+ if (isDiffArtifact(name)) return { kind: "diff", type: ext.toUpperCase() || "DIFF", noun: "Diff" };
1474
+ if (IMAGE_FILENAME_RE.test(name)) return { kind: "image", type: ext.toUpperCase(), noun: "Image" };
1475
+ if (isTabularArtifact(name)) return { kind: "table", type: ext.toUpperCase(), noun: "Table" };
1476
+ if (/\.md$/i.test(name)) return { kind: "doc", type: "MD", noun: "Document" };
1477
+ if (codeLanguageForArtifact(name)) return { kind: "code", type: ext.toUpperCase(), noun: "Code" };
1478
+ return { kind: "file", type: ext.toUpperCase() || "FILE", noun: "File" };
1479
+ }
1480
+
1481
+ /**
1482
+ * A filename read as a title: drop the extension, drop a trailing date stamp,
1483
+ * and turn separators into spaces. `icm-pg-outage-chain-20260805.html` reads as
1484
+ * "Icm pg outage chain" — the same move a document viewer makes, because the
1485
+ * card is a thing you recognize at a glance, not a path you retype.
1486
+ */
1487
+ function artifactCardTitle(filename) {
1488
+ const base = String(filename || "").replace(/\.[A-Za-z0-9]+$/, "");
1489
+ const undated = base.replace(/[-_ ]?\d{6,8}$/, "");
1490
+ const words = (undated || base).replace(/[-_]+/g, " ").trim();
1491
+ if (!words) return String(filename || "Artifact");
1492
+ return words.charAt(0).toUpperCase() + words.slice(1);
1493
+ }
1494
+
1495
+ /** Per-kind mark for the card's thumbnail. Inline SVG so it inherits color. */
1496
+ function ArtifactCardGlyph({ kind }) {
1497
+ const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" };
1498
+ const inner = kind === "page"
1499
+ // A browser window: what you get is a rendered page, not a file.
1500
+ ? [
1501
+ React.createElement("rect", { key: "w", x: 2.5, y: 3.5, width: 15, height: 13, rx: 2, ...stroke }),
1502
+ React.createElement("path", { key: "b", d: "M2.5 7.5h15", ...stroke }),
1503
+ React.createElement("circle", { key: "d", cx: 5.2, cy: 5.5, r: 0.7, fill: "currentColor", stroke: "none" }),
1504
+ ]
1505
+ : kind === "image"
1506
+ ? [
1507
+ React.createElement("rect", { key: "f", x: 2.5, y: 3.5, width: 15, height: 13, rx: 2, ...stroke }),
1508
+ React.createElement("path", { key: "m", d: "M2.5 13l4-4 4 3.5 3-2.5 3.5 3", ...stroke }),
1509
+ ]
1510
+ : kind === "table"
1511
+ ? [
1512
+ React.createElement("rect", { key: "f", x: 2.5, y: 3.5, width: 15, height: 13, rx: 2, ...stroke }),
1513
+ React.createElement("path", { key: "g", d: "M2.5 8h15M8 8v8.5M2.5 12.5h15", ...stroke }),
1514
+ ]
1515
+ : [
1516
+ // Document: a page with a folded corner and text lines.
1517
+ React.createElement("path", { key: "p", d: "M5 2.5h6.5L16 7v10.5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-14a1 1 0 0 1 1-1z", ...stroke }),
1518
+ React.createElement("path", { key: "c", d: "M11.5 2.5V7H16", ...stroke }),
1519
+ kind === "diff"
1520
+ ? React.createElement("path", { key: "t", d: "M7 11h6M10 8.5v5", ...stroke })
1521
+ : React.createElement("path", { key: "t", d: "M7 10.5h6M7 13.5h4", ...stroke }),
1522
+ ];
1523
+ return React.createElement("svg", {
1524
+ className: "ps-artifact-card-glyph",
1525
+ viewBox: "0 0 20 20",
1526
+ "aria-hidden": "true",
1527
+ }, inner);
1528
+ }
1529
+
1530
+ /**
1531
+ * An artifact in the transcript, as a card rather than a link.
1532
+ *
1533
+ * The old inline link was a coloured filename followed by "(press a to
1534
+ * download)" — a keystroke that does not exist in a browser, describing the
1535
+ * wrong verb for something you want to read. A card names the thing, says what
1536
+ * kind it is, and opens the reader on click.
1537
+ */
1538
+ function ArtifactLink({ artifactRef }) {
1348
1539
  const controller = React.useContext(ControllerContext);
1349
- const diff = isDiffArtifact(artifactRef.filename);
1540
+ const descriptor = describeArtifact(artifactRef.filename);
1350
1541
  const onOpen = React.useCallback((event) => {
1351
1542
  event.preventDefault();
1352
1543
  event.stopPropagation();
1353
- if (!controller?.revealArtifact) return;
1354
- // On a phone the preview takes the whole pane — a list/detail split has
1355
- // no room to be useful at 390px.
1356
- const fullscreen = typeof window !== "undefined"
1357
- && typeof window.matchMedia === "function"
1358
- && window.matchMedia("(max-width: 920px)").matches;
1359
- Promise.resolve(controller.revealArtifact(artifactRef.sessionId, artifactRef.filename, { fullscreen }))
1360
- .catch(() => {});
1544
+ openArtifactFromChat(controller, artifactRef.sessionId, artifactRef.filename);
1361
1545
  }, [controller, artifactRef.sessionId, artifactRef.filename]);
1362
1546
 
1547
+ // Deliberately NOT given the surrounding run's inline style. That style
1548
+ // carries the link colour and underline the transcript uses for inline
1549
+ // links, and inheriting it painted the card's title as underlined accent
1550
+ // text — a card is a surface, not a phrase.
1363
1551
  return React.createElement("button", {
1364
1552
  type: "button",
1365
- className: `ps-artifact-link${diff ? " is-diff" : ""}`,
1553
+ className: `ps-artifact-card is-${descriptor.kind}`,
1366
1554
  onClick: onOpen,
1367
- title: diff
1368
- ? `Open ${artifactRef.filename} as a diff`
1369
- : `Open ${artifactRef.filename} in Files`,
1370
- style,
1555
+ title: `Open ${artifactRef.filename}`,
1371
1556
  },
1372
- diff ? React.createElement("span", { className: "ps-artifact-link-glyph", "aria-hidden": "true" }, "±") : null,
1373
- React.createElement("span", { className: "ps-artifact-link-label" }, label || artifactRef.filename));
1557
+ React.createElement("span", { className: "ps-artifact-card-thumb", "aria-hidden": "true" },
1558
+ React.createElement(ArtifactCardGlyph, { kind: descriptor.kind })),
1559
+ React.createElement("span", { className: "ps-artifact-card-text" },
1560
+ React.createElement("span", { className: "ps-artifact-card-title" }, artifactCardTitle(artifactRef.filename)),
1561
+ React.createElement("span", { className: "ps-artifact-card-meta" }, `${descriptor.noun} · ${descriptor.type}`)),
1562
+ React.createElement("span", { className: "ps-artifact-card-open" }, "Open"));
1563
+ }
1564
+
1565
+ /**
1566
+ * Open an artifact from the transcript.
1567
+ *
1568
+ * Desktop gets the takeover pane — the chat stays visible beside it, which is
1569
+ * the whole point of following a link from a conversation. A phone has no room
1570
+ * for two things at once and keeps the full-viewport overlay.
1571
+ */
1572
+ function openArtifactFromChat(controller, sessionId, filename) {
1573
+ if (!controller?.revealArtifact) return;
1574
+ const isPhone = typeof window !== "undefined"
1575
+ && typeof window.matchMedia === "function"
1576
+ && window.matchMedia("(max-width: 920px)").matches;
1577
+ // Whether ✕ hands the column back or collapses it again is decided inside
1578
+ // revealArtifact, from the layout it can already see.
1579
+ Promise.resolve(isPhone
1580
+ ? controller.revealArtifact(sessionId, filename, { fullscreen: true })
1581
+ : controller.revealArtifact(sessionId, filename, { pane: true })).catch(() => {});
1374
1582
  }
1375
1583
 
1376
1584
  function isDiffLanguage(language) {
@@ -1677,8 +1885,16 @@ function MermaidDiagram({ code, theme, fallback }) {
1677
1885
  }
1678
1886
 
1679
1887
  function renderInlineMarkdown(source, theme, keyPrefix = "md") {
1680
- return tokenizeInlineMarkdown(source).map((token, index) => {
1888
+ const tokens = tokenizeInlineMarkdown(source);
1889
+ return tokens.map((token, index) => {
1681
1890
  const key = `${keyPrefix}:${index}`;
1891
+ // The shared decorator appends the TUI's download hint as plain text
1892
+ // right after the artifact link; the portal drops it.
1893
+ if (index > 0 && tokens[index - 1]?.type === "link" && parseArtifactHref(tokens[index - 1]?.href)) {
1894
+ const stripped = stripArtifactDownloadHint(token.text || "");
1895
+ if (!stripped) return null;
1896
+ if (stripped !== token.text) token = { ...token, text: stripped };
1897
+ }
1682
1898
  if (token.type === "code") {
1683
1899
  return React.createElement("code", { key, className: "ps-md-inline-code" }, token.text);
1684
1900
  }
@@ -1691,12 +1907,7 @@ function renderInlineMarkdown(source, theme, keyPrefix = "md") {
1691
1907
  if (token.type === "link") {
1692
1908
  const artifactRef = parseArtifactHref(token.href);
1693
1909
  if (artifactRef) {
1694
- return React.createElement(ArtifactLink, {
1695
- key,
1696
- label: token.text,
1697
- artifactRef,
1698
- style: { color: resolveColor(theme, "cyan") },
1699
- });
1910
+ return React.createElement(ArtifactLink, { key, artifactRef });
1700
1911
  }
1701
1912
  // Only http(s) becomes a navigable anchor. Anything else — an
1702
1913
  // unknown scheme, or a malformed href from a bad transform — would
@@ -2197,7 +2408,18 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
2197
2408
  className: "ps-binary-preview-image",
2198
2409
  src: imageUrl,
2199
2410
  alt: filename || "artifact image",
2200
- onClick: () => { try { window.open(imageUrl, "_blank", "noopener"); } catch { /* popup blocked */ } },
2411
+ // Click-through goes to the artifact deep link, NOT to the
2412
+ // blob URL. A blob: document opened at top level runs at
2413
+ // the portal's own origin, and IMAGE_FILENAME_RE admits
2414
+ // .svg — an SVG is a scriptable document, so opening one
2415
+ // that way would execute artifact-authored script with
2416
+ // access to the signed-in user's token. Rendering it in
2417
+ // <img> (below) is safe; navigating to it is not.
2418
+ onClick: () => {
2419
+ const target = buildArtifactLinkUrl(sessionId, filename);
2420
+ if (!target) return;
2421
+ try { window.open(target, "_blank", "noopener"); } catch { /* popup blocked */ }
2422
+ },
2201
2423
  })
2202
2424
  : React.createElement("p", { className: "ps-binary-preview-copy" },
2203
2425
  isRasterImage
@@ -2205,6 +2427,463 @@ function BinaryArtifactPreviewPanel({ title, color, focused, theme, filename, co
2205
2427
  : "Preview is intentionally disabled for non-text artifacts in the browser workspace. Use Download to save the file and open it in the default app.")));
2206
2428
  }
2207
2429
 
2430
+ /**
2431
+ * Full-fidelity HTML artifact preview.
2432
+ *
2433
+ * Two things make this different from every other preview branch:
2434
+ *
2435
+ * 1. It renders from the DOWNLOAD stream, not `previewContent`. The text
2436
+ * preview pipeline truncates at FILE_PREVIEW_CHAR_LIMIT (200k chars), so a
2437
+ * real agent-built dashboard is routinely cut off mid-document. "Full
2438
+ * fidelity" has to mean all of the bytes.
2439
+ *
2440
+ * 2. The document runs its own scripts, inside a sandbox with NO
2441
+ * `allow-same-origin`. That combination is the whole security model here:
2442
+ * blob: URLs inherit the origin of whoever created them, so withholding
2443
+ * allow-same-origin is what forces the frame into an opaque origin. The
2444
+ * artifact's tooltips, SVG interactions and layout scripts all work; its
2445
+ * script cannot read the portal's localStorage, cookies, bearer token, or
2446
+ * reach across into the parent DOM. Popups are allowed so external links
2447
+ * still work; forms and top-level navigation are not.
2448
+ */
2449
+ /**
2450
+ * Re-run a rendered artifact's layout when the frame's width changes.
2451
+ *
2452
+ * Agent-authored HTML routinely computes geometry ONCE at load — this
2453
+ * dashboard measures the container and writes absolute SVG coordinates, with
2454
+ * no resize listener anywhere in it. Widening the pane reflowed its CSS grid
2455
+ * (media queries need no script) but left the graph drawn for the old width,
2456
+ * with three columns stacked on top of each other.
2457
+ *
2458
+ * Nothing can be fixed from out here: the frame is deliberately opaque-origin,
2459
+ * so we cannot call into it, read its scroll position, or re-run its init. The
2460
+ * one lever that works from outside is making the document load again at the
2461
+ * new size — a remount, which re-executes the same blob with no refetch.
2462
+ *
2463
+ * So: debounce until the drag settles, ignore trivial jitter, and never fire
2464
+ * on the initial measurement. That costs one reload per resize gesture, and it
2465
+ * does lose scroll position — unavoidable across an opaque origin, and the
2466
+ * alternative is a picture that is simply wrong at the size the user chose.
2467
+ */
2468
+ const HTML_PREVIEW_RESIZE_SETTLE_MS = 260;
2469
+ // Comfortably wider than a scrollbar. A reload changes the document's height,
2470
+ // which can add or remove the frame's vertical scrollbar, which changes the
2471
+ // host's width by ~15px — enough to clear a smaller threshold and schedule
2472
+ // another reload, which toggles the scrollbar back. That loop is what made the
2473
+ // panel appear to redraw continuously; measuring against the last COMMITTED
2474
+ // width (below) closes it, and the wider band keeps it shut.
2475
+ const HTML_PREVIEW_RESIZE_MIN_DELTA_PX = 28;
2476
+
2477
+ /**
2478
+ * Reload the rendered frame when the pane's width settles, double-buffered.
2479
+ *
2480
+ * Two rules keep this from being visible:
2481
+ *
2482
+ * - Compare against the width the CURRENT document was rendered at, not the
2483
+ * last width observed. Otherwise every intermediate measurement becomes a
2484
+ * new baseline and the scrollbar feedback loop above never terminates.
2485
+ * - Load the replacement behind the one on screen and swap on its `load`
2486
+ * event. A remounted iframe is blank while it loads, and a blank frame
2487
+ * paints its background — white, so a light-assuming document is readable —
2488
+ * which is exactly the flash. Nothing is ever swapped in unpainted.
2489
+ *
2490
+ * Returns the two nonces the caller renders: `live` is on screen, `pending` is
2491
+ * loading behind it. Both use the same element key across promotion so React
2492
+ * reuses the element instead of remounting it (which would reload it again).
2493
+ */
2494
+ function useHtmlPreviewReload(elementRef, enabled, resetKey, relayoutKey) {
2495
+ const [live, setLive] = React.useState(null);
2496
+ const [pending, setPending] = React.useState(0);
2497
+ const committedWidthRef = React.useRef(null);
2498
+ const nonceRef = React.useRef(0);
2499
+
2500
+ // A different artifact is a different document: drop what is on screen and
2501
+ // stage the new one, so the old page is never shown under a new title.
2502
+ React.useEffect(() => {
2503
+ committedWidthRef.current = null;
2504
+ nonceRef.current += 1;
2505
+ setLive(null);
2506
+ setPending(nonceRef.current);
2507
+ }, [resetKey]);
2508
+
2509
+ // Zoom changes the frame's LAYOUT width (see the width/transform pair in
2510
+ // the stylesheet), so a document that computed its geometry once is just
2511
+ // as stale after zooming as after a drag. Stage a replacement — but keep
2512
+ // the current one on screen, unlike resetKey above, because this is the
2513
+ // same document at a new size rather than a different one.
2514
+ const firstRelayout = React.useRef(true);
2515
+ React.useEffect(() => {
2516
+ if (firstRelayout.current) { firstRelayout.current = false; return; }
2517
+ if (!enabled) return;
2518
+ committedWidthRef.current = null;
2519
+ nonceRef.current += 1;
2520
+ setPending(nonceRef.current);
2521
+ }, [relayoutKey, enabled]);
2522
+
2523
+ React.useEffect(() => {
2524
+ const node = elementRef.current;
2525
+ if (!enabled || !node || typeof ResizeObserver === "undefined") return undefined;
2526
+ let timer = null;
2527
+ const observer = new ResizeObserver((entries) => {
2528
+ const width = Math.round(entries[0]?.contentRect?.width || 0);
2529
+ if (!width) return;
2530
+ if (committedWidthRef.current === null) {
2531
+ committedWidthRef.current = width;
2532
+ return;
2533
+ }
2534
+ if (Math.abs(width - committedWidthRef.current) < HTML_PREVIEW_RESIZE_MIN_DELTA_PX) return;
2535
+ if (timer) clearTimeout(timer);
2536
+ timer = setTimeout(() => {
2537
+ timer = null;
2538
+ // Commit the width as of the moment we decide to reload, so the
2539
+ // next comparison is against what the new document will render
2540
+ // at rather than against a mid-drag sample.
2541
+ committedWidthRef.current = Math.round(node.getBoundingClientRect().width) || width;
2542
+ nonceRef.current += 1;
2543
+ setPending(nonceRef.current);
2544
+ }, HTML_PREVIEW_RESIZE_SETTLE_MS);
2545
+ });
2546
+ observer.observe(node);
2547
+ return () => {
2548
+ if (timer) clearTimeout(timer);
2549
+ observer.disconnect();
2550
+ };
2551
+ }, [elementRef, enabled]);
2552
+
2553
+ // `load` means the document finished loading, NOT that it finished
2554
+ // painting. Promoting on the load event alone swapped in a frame the
2555
+ // compositor had not drawn yet, so the white background showed through for
2556
+ // a beat. Two frames of slack is enough for the paint to land, and it is
2557
+ // invisible next to the reload it follows.
2558
+ const promote = React.useCallback((nonce) => {
2559
+ const swap = () => {
2560
+ setPending((current) => (current === nonce ? null : current));
2561
+ setLive((current) => (current === nonce ? current : nonce));
2562
+ };
2563
+ if (typeof requestAnimationFrame !== "function") { swap(); return; }
2564
+ requestAnimationFrame(() => requestAnimationFrame(swap));
2565
+ }, []);
2566
+
2567
+ return { live, pending, promote };
2568
+ }
2569
+
2570
+ /**
2571
+ * Width the frame LAYS OUT at while fitting, before being scaled down.
2572
+ *
2573
+ * It has to be a constant: the frame is sandboxed without `allow-same-origin`,
2574
+ * so the document's real content width is unreadable from here — there is no
2575
+ * contentDocument to measure. 1024 is the width a fixed-layout page is most
2576
+ * likely to have been written for, and a responsive one reflows to it happily.
2577
+ */
2578
+ const HTML_PREVIEW_FIT_LAYOUT_WIDTH_PX = 1024;
2579
+
2580
+ /** Fit-to-width switch. Rendered by whichever surface owns the artifact bar. */
2581
+ function HtmlFitWidthToggle({ fitWidth, setFitWidth }) {
2582
+ return React.createElement(IconButton, {
2583
+ icon: fitWidth ? "1:1" : "⤢",
2584
+ label: fitWidth ? "Actual size" : "Fit page to width",
2585
+ active: fitWidth,
2586
+ onClick: () => setFitWidth(!fitWidth),
2587
+ });
2588
+ }
2589
+
2590
+ const HTML_ZOOM_STEPS = [0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3];
2591
+
2592
+ /**
2593
+ * Zoom for the rendered artifact ONLY.
2594
+ *
2595
+ * Browser zoom scales the entire workspace — the session list and the
2596
+ * transcript along with it — when the thing you actually wanted to read
2597
+ * larger is one dense chart. This moves the artifact and leaves the interface
2598
+ * around it alone.
2599
+ */
2600
+ function HtmlZoomControl({ zoom, setZoom }) {
2601
+ const step = (direction) => {
2602
+ const index = HTML_ZOOM_STEPS.findIndex((value) => Math.abs(value - zoom) < 0.005);
2603
+ const from = index === -1 ? HTML_ZOOM_STEPS.indexOf(1) : index;
2604
+ const next = HTML_ZOOM_STEPS[Math.min(HTML_ZOOM_STEPS.length - 1, Math.max(0, from + direction))];
2605
+ setZoom(next);
2606
+ };
2607
+ return React.createElement("span", { className: "ps-html-zoom", role: "group", "aria-label": "Artifact zoom" },
2608
+ React.createElement("button", {
2609
+ type: "button",
2610
+ className: "ps-html-zoom-btn",
2611
+ onClick: () => step(-1),
2612
+ disabled: zoom <= HTML_ZOOM_STEPS[0] + 0.001,
2613
+ title: "Zoom out (artifact only)",
2614
+ "aria-label": "Zoom out",
2615
+ }, "−"),
2616
+ // Doubles as reset — the fastest way back from an exploratory zoom.
2617
+ React.createElement("button", {
2618
+ type: "button",
2619
+ className: "ps-html-zoom-level",
2620
+ onClick: () => setZoom(1),
2621
+ title: "Reset zoom to 100%",
2622
+ "aria-label": `Zoom ${Math.round(zoom * 100)} percent, click to reset`,
2623
+ }, `${Math.round(zoom * 100)}%`),
2624
+ React.createElement("button", {
2625
+ type: "button",
2626
+ className: "ps-html-zoom-btn",
2627
+ onClick: () => step(1),
2628
+ disabled: zoom >= HTML_ZOOM_STEPS[HTML_ZOOM_STEPS.length - 1] - 0.001,
2629
+ title: "Zoom in (artifact only)",
2630
+ "aria-label": "Zoom in",
2631
+ }, "+"));
2632
+ }
2633
+
2634
+ /** Rendered-page vs. HTML-source switch. Shared by the Files tab and the reader. */
2635
+ function HtmlViewModeToggle({ mode, setMode }) {
2636
+ return React.createElement("span", {
2637
+ className: "ps-html-preview-modes",
2638
+ role: "group",
2639
+ "aria-label": "Preview mode",
2640
+ }, ["rendered", "source"].map((value) => React.createElement("button", {
2641
+ key: value,
2642
+ type: "button",
2643
+ className: `ps-html-preview-mode${mode === value ? " is-active" : ""}`,
2644
+ "aria-pressed": mode === value,
2645
+ onClick: () => setMode(value),
2646
+ }, value === "rendered" ? "Rendered" : "Source")));
2647
+ }
2648
+
2649
+ function useElementBox(ref) {
2650
+ const [box, setBox] = React.useState({ width: 0, height: 0 });
2651
+ React.useLayoutEffect(() => {
2652
+ const node = ref.current;
2653
+ if (!node || typeof ResizeObserver === "undefined") return undefined;
2654
+ const update = () => {
2655
+ const rect = node.getBoundingClientRect();
2656
+ setBox((current) => {
2657
+ const width = Math.round(rect.width) || 0;
2658
+ const height = Math.round(rect.height) || 0;
2659
+ return current.width === width && current.height === height
2660
+ ? current
2661
+ : { width, height };
2662
+ });
2663
+ };
2664
+ update();
2665
+ const observer = new ResizeObserver(update);
2666
+ observer.observe(node);
2667
+ return () => observer.disconnect();
2668
+ }, [ref]);
2669
+ return box;
2670
+ }
2671
+
2672
+ function fetchArtifactHtmlObjectUrl(controller, sessionId, filename) {
2673
+ const downloadResponse = controller?.transport?.api?.downloadArtifactResponse;
2674
+ if (typeof downloadResponse !== "function") {
2675
+ return Promise.reject(new Error("artifact download unavailable"));
2676
+ }
2677
+ return downloadResponse.call(controller.transport.api, sessionId, filename)
2678
+ .then(async (response) => {
2679
+ if (!response?.ok) throw new Error(`download failed (${response?.status})`);
2680
+ // Re-type the blob explicitly rather than trusting the response's
2681
+ // content-type. An artifact stored as application/octet-stream
2682
+ // would otherwise make the iframe offer a download instead of
2683
+ // rendering, and a missing charset would mojibake UTF-8 content.
2684
+ const bytes = await response.arrayBuffer();
2685
+ return URL.createObjectURL(new Blob([bytes], { type: "text/html;charset=utf-8" }));
2686
+ });
2687
+ }
2688
+
2689
+ function HtmlArtifactPreviewPanel({
2690
+ controller, sessionId, filename, contentType, sizeBytes, theme,
2691
+ color = "cyan", focused = false, title = null, chromeless = false,
2692
+ }) {
2693
+ // Mode lives in controller state so the reader pane's header can own the
2694
+ // toggle. Inside the Files tab this panel still draws its own.
2695
+ const mode = useControllerSelector(controller, (state) => state.files.htmlViewMode || "rendered");
2696
+ const setMode = React.useCallback((next) => {
2697
+ controller.dispatch({ type: "files/htmlViewMode", mode: next });
2698
+ }, [controller]);
2699
+ const [frameUrl, setFrameUrl] = React.useState(null);
2700
+ const [error, setError] = React.useState(null);
2701
+ // Measured on the wrapper, not the iframe: an iframe being remounted is
2702
+ // briefly absent, and observing an element that disappears would drop the
2703
+ // baseline and re-fire on the way back.
2704
+ const frameHostRef = React.useRef(null);
2705
+ const fitWidth = useControllerSelector(controller, (state) => Boolean(state.files.htmlFitWidth));
2706
+ const setFitWidth = React.useCallback((next) => {
2707
+ controller.dispatch({ type: "files/htmlFitWidth", enabled: next });
2708
+ }, [controller]);
2709
+ const hostBox = useElementBox(frameHostRef);
2710
+ // Only ever scale DOWN. When the pane is already wider than the layout
2711
+ // width there is nothing to fit, and blowing the page up to fill it would
2712
+ // just make a responsive document coarse.
2713
+ const fitScale = fitWidth && hostBox.width > 0 && hostBox.width < HTML_PREVIEW_FIT_LAYOUT_WIDTH_PX
2714
+ ? hostBox.width / HTML_PREVIEW_FIT_LAYOUT_WIDTH_PX
2715
+ : 1;
2716
+ const fitActive = fitScale < 1;
2717
+ // Zoom composes with fit rather than competing with it: fit chooses the
2718
+ // base scale that shows the whole width, zoom moves from there. At
2719
+ // zoom 1 with fit off, neither touches the frame at all.
2720
+ const zoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
2721
+ const setZoom = React.useCallback((next) => {
2722
+ controller.dispatch({ type: "files/htmlZoom", zoom: next });
2723
+ }, [controller]);
2724
+ const renderScale = fitActive ? fitScale * zoom : zoom;
2725
+ // While fitting, the frame's own width is pinned to the layout constant, so
2726
+ // a pane resize changes only the SCALE — the document does not need to be
2727
+ // reloaded, and reloading would cost the reader their scroll position on
2728
+ // something as ordinary as rotating the phone.
2729
+ const frames = useHtmlPreviewReload(frameHostRef, mode === "rendered" && !fitActive, `${sessionId}/${filename}`, zoom);
2730
+
2731
+ // The object URL is owned by this component — created on mount, revoked on
2732
+ // unmount or artifact change. It deliberately does not share the thumbnail
2733
+ // LRU: that cache revokes at its cap, which would blank a frame the user
2734
+ // is still reading.
2735
+ React.useEffect(() => {
2736
+ setFrameUrl(null);
2737
+ setError(null);
2738
+ if (mode !== "rendered" || !controller || !sessionId || !filename) return undefined;
2739
+ let cancelled = false;
2740
+ let created = null;
2741
+ fetchArtifactHtmlObjectUrl(controller, sessionId, filename)
2742
+ .then((url) => {
2743
+ created = url;
2744
+ if (cancelled) {
2745
+ URL.revokeObjectURL(url);
2746
+ return;
2747
+ }
2748
+ setFrameUrl(url);
2749
+ })
2750
+ .catch((err) => { if (!cancelled) setError(err?.message || String(err)); });
2751
+ return () => {
2752
+ cancelled = true;
2753
+ if (created) URL.revokeObjectURL(created);
2754
+ };
2755
+ }, [controller, sessionId, filename, mode]);
2756
+
2757
+ const deepLink = buildArtifactLinkUrl(sessionId, filename);
2758
+ const actions = chromeless ? null : React.createElement(React.Fragment, null,
2759
+ React.createElement(HtmlViewModeToggle, { mode, setMode }),
2760
+ mode === "rendered"
2761
+ ? React.createElement(HtmlFitWidthToggle, { fitWidth, setFitWidth })
2762
+ : null,
2763
+ mode === "rendered"
2764
+ ? React.createElement(HtmlZoomControl, { zoom, setZoom })
2765
+ : null,
2766
+ deepLink
2767
+ ? React.createElement(IconButton, {
2768
+ icon: "↗",
2769
+ label: "Open in a new tab",
2770
+ onClick: () => { try { window.open(deepLink, "_blank", "noopener"); } catch { /* popup blocked */ } },
2771
+ })
2772
+ : null);
2773
+
2774
+ const body = mode === "source"
2775
+ ? React.createElement(HtmlArtifactSourcePanel, { controller, sessionId, filename, theme })
2776
+ : error
2777
+ ? React.createElement("div", { className: "ps-html-preview-status" },
2778
+ React.createElement("p", null, `Could not load ${filename}: ${error}`),
2779
+ React.createElement("p", null, "Use Download to save the file and open it locally."))
2780
+ : frameUrl
2781
+ // Live and pending are rendered together while a reload is in
2782
+ // flight; the pending one is offscreen-transparent until its
2783
+ // load fires. Keys are `frame:<nonce>` in BOTH roles so
2784
+ // promotion is a class change, not a remount — remounting
2785
+ // would reload the document we just finished loading.
2786
+ ? [frames.live, frames.pending]
2787
+ .filter((nonce, index, all) => nonce !== null && all.indexOf(nonce) === index)
2788
+ .map((nonce) => React.createElement("iframe", {
2789
+ key: `frame:${nonce}`,
2790
+ className: `ps-html-preview-frame${nonce === frames.live ? "" : " is-staging"}`,
2791
+ // A transform does not change the layout box, so the
2792
+ // frame is laid out at the full layout width and a
2793
+ // proportionally TALLER height, then scaled back into
2794
+ // the host. Height in layout px, not %, because a
2795
+ // percentage would resolve against a flex parent whose
2796
+ // definite height is not guaranteed.
2797
+ style: fitActive
2798
+ ? {
2799
+ flex: "none",
2800
+ width: `${HTML_PREVIEW_FIT_LAYOUT_WIDTH_PX}px`,
2801
+ height: `${Math.max(1, Math.round(hostBox.height / renderScale))}px`,
2802
+ transform: `scale(${renderScale})`,
2803
+ transformOrigin: "top left",
2804
+ }
2805
+ // Zoom without fit takes the other route: shrink the
2806
+ // frame's LAYOUT box by the zoom factor and scale it
2807
+ // back up, so the document reflows at the zoomed size
2808
+ // instead of being magnified as a flat picture.
2809
+ : zoom !== 1 && hostBox.width > 0
2810
+ ? {
2811
+ flex: "none",
2812
+ width: `${Math.max(1, Math.round(hostBox.width / zoom))}px`,
2813
+ height: `${Math.max(1, Math.round(hostBox.height / zoom))}px`,
2814
+ transform: `scale(${zoom})`,
2815
+ transformOrigin: "top left",
2816
+ }
2817
+ : undefined,
2818
+ src: frameUrl,
2819
+ title: filename || "HTML artifact",
2820
+ // No allow-same-origin — see the block comment above.
2821
+ sandbox: "allow-scripts allow-popups allow-popups-to-escape-sandbox",
2822
+ referrerPolicy: "no-referrer",
2823
+ onLoad: () => frames.promote(nonce),
2824
+ }))
2825
+ : React.createElement("div", { className: "ps-html-preview-status" },
2826
+ React.createElement("p", null, `Rendering ${filename}…`),
2827
+ sizeBytes != null
2828
+ ? React.createElement("p", { className: "ps-html-preview-meta" },
2829
+ [contentType, formatArtifactPreviewBytes(sizeBytes)].filter(Boolean).join(" • "))
2830
+ : null);
2831
+
2832
+ // The measured host wraps the rendered branch only — in Source mode there
2833
+ // is no document whose layout could go stale, so nothing observes width.
2834
+ const hosted = mode === "rendered"
2835
+ ? React.createElement("div", {
2836
+ ref: frameHostRef,
2837
+ // The frame's layout box stays HTML_PREVIEW_FIT_LAYOUT_WIDTH_PX wide
2838
+ // while fitting; without clipping, that overflow would hand the host
2839
+ // a horizontal scrollbar for content that is already fully visible.
2840
+ className: `ps-html-preview-host${fitActive || zoom !== 1 ? " is-fitting" : ""}`,
2841
+ }, body)
2842
+ : body;
2843
+
2844
+ return React.createElement(Panel, { title, color, focused, theme, actions, className: "ps-html-preview" }, hosted);
2845
+ }
2846
+
2847
+ /**
2848
+ * Source view for an HTML artifact — highlighted, and fetched in full rather
2849
+ * than reusing the 200k-truncated preview text, so toggling Rendered→Source
2850
+ * does not silently lose two thirds of a large document.
2851
+ */
2852
+ function HtmlArtifactSourcePanel({ controller, sessionId, filename, theme }) {
2853
+ const [text, setText] = React.useState(null);
2854
+ const [error, setError] = React.useState(null);
2855
+ React.useEffect(() => {
2856
+ setText(null);
2857
+ setError(null);
2858
+ const downloadResponse = controller?.transport?.api?.downloadArtifactResponse;
2859
+ if (typeof downloadResponse !== "function" || !sessionId || !filename) {
2860
+ setError("artifact download unavailable");
2861
+ return undefined;
2862
+ }
2863
+ let cancelled = false;
2864
+ downloadResponse.call(controller.transport.api, sessionId, filename)
2865
+ .then(async (response) => {
2866
+ if (!response?.ok) throw new Error(`download failed (${response?.status})`);
2867
+ return response.text();
2868
+ })
2869
+ .then((value) => { if (!cancelled) setText(value); })
2870
+ .catch((err) => { if (!cancelled) setError(err?.message || String(err)); });
2871
+ return () => { cancelled = true; };
2872
+ }, [controller, sessionId, filename]);
2873
+
2874
+ if (error) {
2875
+ return React.createElement("div", { className: "ps-html-preview-status" },
2876
+ React.createElement("p", null, `Could not load source: ${error}`));
2877
+ }
2878
+ if (text == null) {
2879
+ return React.createElement("div", { className: "ps-html-preview-status" },
2880
+ React.createElement("p", null, "Loading source…"));
2881
+ }
2882
+ return React.createElement("div", { className: "ps-scroll-panel is-preview ps-diff-preview" },
2883
+ React.createElement("pre", { className: "ps-md-code-pre" },
2884
+ React.createElement("code", null, renderHighlightedCode(text, "html", theme))));
2885
+ }
2886
+
2208
2887
  function isBoxTopLine(text) {
2209
2888
  const value = String(text || "").trim();
2210
2889
  return value.startsWith("┌") && value.endsWith("┐");
@@ -2545,7 +3224,18 @@ function fetchArtifactObjectUrl(controller, sessionId, filename) {
2545
3224
  .then(async (response) => {
2546
3225
  if (!response?.ok) throw new Error(`download failed (${response?.status})`);
2547
3226
  const blob = await response.blob();
2548
- return URL.createObjectURL(blob);
3227
+ // Re-type from the extension when the stored content type is not an
3228
+ // image one. This is what made an agent-written bar_chart.svg
3229
+ // (uploaded as text/plain) render as a broken-image glyph: the blob
3230
+ // inherited text/plain and <img> will not paint that. Re-typing does
3231
+ // NOT widen the SVG-script exposure the block below guards against —
3232
+ // <img> never runs script in an SVG regardless of how it is typed,
3233
+ // and the click-through still goes to the artifact deep link.
3234
+ const imageMime = imageMimeFromFilename(filename);
3235
+ const typedBlob = imageMime && !IMAGE_CONTENT_TYPE_RE.test(blob.type)
3236
+ ? new Blob([blob], { type: imageMime })
3237
+ : blob;
3238
+ return URL.createObjectURL(typedBlob);
2549
3239
  })
2550
3240
  .catch((error) => {
2551
3241
  // Failed fetches must not be cached — a retry on next render is fine.
@@ -3596,6 +4286,13 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
3596
4286
  );
3597
4287
  const selectedCount = Array.isArray(viewState.selectedIds) ? viewState.selectedIds.length : 0;
3598
4288
  const isBulkSelection = selectedCount > 1;
4289
+ // A folder has none of the session-manage surface — no model, no sharing,
4290
+ // no visibility — which is why canModifyActiveSession excludes groups. But
4291
+ // it does have a TITLE, and the rename modal already routes groups to
4292
+ // updateSessionGroup. Renaming is the one manage action a folder supports.
4293
+ // Session groups are private per-user organisation, so a folder you can
4294
+ // see is a folder you own; there is no separate ownership check to make.
4295
+ const canRenameActiveGroup = Boolean(activeSession?.isGroup && !isBulkSelection);
3599
4296
  const canPinActiveSession = Boolean(
3600
4297
  activeSession
3601
4298
  && !activeSession.isSystem
@@ -4116,8 +4813,17 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4116
4813
  React.createElement(IconButton, {
4117
4814
  className: "ps-mini-button",
4118
4815
  icon: React.createElement(ManageGlyph),
4119
- onClick: () => setManageOpen(true),
4120
- disabled: !canModifyActiveSession || isBulkSelection,
4816
+ onClick: () => {
4817
+ // Folders reuse this control rather than dead-ending on it:
4818
+ // it is where people look for "change this thing's name", and
4819
+ // it is where this one was reported from.
4820
+ if (activeSession?.isGroup) {
4821
+ controller.handleCommand(UI_COMMANDS.OPEN_RENAME_SESSION).catch(() => {});
4822
+ return;
4823
+ }
4824
+ setManageOpen(true);
4825
+ },
4826
+ disabled: (!canModifyActiveSession && !canRenameActiveGroup) || isBulkSelection,
4121
4827
  // A disabled control has to say WHY. This label used to describe
4122
4828
  // the ENABLED behaviour in every disabled case except bulk
4123
4829
  // selection, so on a system session the tooltip promised "rename,
@@ -4128,7 +4834,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
4128
4834
  : activeSession?.isSystem
4129
4835
  ? "System sessions are managed by administrators"
4130
4836
  : activeSession?.isGroup
4131
- ? "Folders cannot be managed here — use the folder controls"
4837
+ ? "Rename folder"
4132
4838
  : !canModifyActiveSession
4133
4839
  ? "Only this session's owner or an admin can manage it"
4134
4840
  : "Manage session — rename, switch model, and sharing",
@@ -5038,6 +5744,141 @@ function ChatPane({ controller, mobile = false, fullWidth = false, showComposer
5038
5744
  });
5039
5745
  }
5040
5746
 
5747
+ /**
5748
+ * The desktop artifact reader: a full-height pane that takes over the right
5749
+ * column, replacing the inspector AND activity panes for as long as it is open.
5750
+ *
5751
+ * Why a takeover rather than one more box: an artifact worth opening from the
5752
+ * transcript is the thing you want to READ, and the previous arrangement gave
5753
+ * it the bottom third of a column it shared with a file list. The chat stays
5754
+ * beside it — that is the half of the split that matters — while everything
5755
+ * that was only ever context for the chat steps aside.
5756
+ *
5757
+ * ✕ hands the column back to the inspector/activity split, or re-collapses it
5758
+ * if it was already collapsed when the pane opened (see closeArtifactPane).
5759
+ */
5760
+ function ArtifactTakeoverPane({ controller, onClose }) {
5761
+ const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
5762
+ const theme = getTheme(themeId);
5763
+ const view = useControllerSelector(controller, (state) => {
5764
+ const id = state.files.selectedArtifactId || "";
5765
+ const slash = id.indexOf("/");
5766
+ return {
5767
+ sessionId: slash > 0 ? id.slice(0, slash) : null,
5768
+ filename: slash > 0 ? id.slice(slash + 1) : "",
5769
+ canPrev: controller.canStepArtifactPreview(-1),
5770
+ canNext: controller.canStepArtifactPreview(1),
5771
+ };
5772
+ }, shallowEqualObject);
5773
+
5774
+ const descriptor = describeArtifact(view.filename);
5775
+ const htmlViewMode = useControllerSelector(controller, (state) => state.files.htmlViewMode || "rendered");
5776
+ const htmlFitWidth = useControllerSelector(controller, (state) => Boolean(state.files.htmlFitWidth));
5777
+ const setHtmlFitWidth = React.useCallback((next) => {
5778
+ controller.dispatch({ type: "files/htmlFitWidth", enabled: next });
5779
+ }, [controller]);
5780
+ const setHtmlViewMode = React.useCallback((next) => {
5781
+ controller.dispatch({ type: "files/htmlViewMode", mode: next });
5782
+ }, [controller]);
5783
+ const htmlZoom = useControllerSelector(controller, (state) => Number(state.files.htmlZoom) || 1);
5784
+ const setHtmlZoom = React.useCallback((next) => {
5785
+ controller.dispatch({ type: "files/htmlZoom", zoom: next });
5786
+ }, [controller]);
5787
+ const step = React.useCallback((delta) => {
5788
+ controller.stepArtifactPreview(delta).catch(() => {});
5789
+ }, [controller]);
5790
+
5791
+ // ‹ › as keys, not just buttons — the same navigation the phone overlay
5792
+ // has. Bound on the pane itself rather than the window so it cannot
5793
+ // hijack the arrow keys while the user is typing in the composer.
5794
+ const onKeyDown = React.useCallback((event) => {
5795
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
5796
+ if (event.key === "ArrowLeft" || event.key === "[") { event.preventDefault(); step(-1); return; }
5797
+ if (event.key === "ArrowRight" || event.key === "]") { event.preventDefault(); step(1); return; }
5798
+ if (event.key === "Escape") { event.preventDefault(); onClose(); }
5799
+ }, [onClose, step]);
5800
+
5801
+ const deepLink = buildArtifactLinkUrl(view.sessionId, view.filename);
5802
+
5803
+ return React.createElement("section", {
5804
+ className: "ps-artifact-pane",
5805
+ onKeyDown,
5806
+ tabIndex: -1,
5807
+ role: "region",
5808
+ "aria-label": `Artifact: ${view.filename || "none"}`,
5809
+ },
5810
+ React.createElement("header", { className: "ps-artifact-pane-bar" },
5811
+ // Rendered/Source lives HERE for HTML rather than in a second header
5812
+ // row below — one bar owns the artifact, the way the reference does.
5813
+ descriptor.kind === "page"
5814
+ ? React.createElement(HtmlViewModeToggle, { mode: htmlViewMode, setMode: setHtmlViewMode })
5815
+ : null,
5816
+ // The panel is chromeless in here, so this bar owns the fit control —
5817
+ // and this is the surface that needs it most: a fixed-width page in a
5818
+ // phone-width frame is exactly the case fitting exists for.
5819
+ descriptor.kind === "page" && htmlViewMode === "rendered"
5820
+ ? React.createElement(HtmlFitWidthToggle, { fitWidth: htmlFitWidth, setFitWidth: setHtmlFitWidth })
5821
+ : null,
5822
+ // Zoom belongs here for the same reason: it scales the ARTIFACT, not
5823
+ // the workspace, which is the whole distinction from browser zoom.
5824
+ descriptor.kind === "page" && htmlViewMode === "rendered"
5825
+ ? React.createElement(HtmlZoomControl, { zoom: htmlZoom, setZoom: setHtmlZoom })
5826
+ : null,
5827
+ React.createElement("span", { className: "ps-artifact-pane-heading" },
5828
+ React.createElement("span", {
5829
+ className: "ps-artifact-pane-name",
5830
+ title: view.filename,
5831
+ }, artifactCardTitle(view.filename) || "Artifact"),
5832
+ React.createElement("span", { className: "ps-artifact-pane-type" }, descriptor.type)),
5833
+ React.createElement("span", { className: "ps-artifact-pane-actions" },
5834
+ React.createElement("button", {
5835
+ type: "button",
5836
+ className: "ps-artifact-pane-btn",
5837
+ disabled: !view.canPrev,
5838
+ onClick: () => step(-1),
5839
+ title: "Previous artifact (←)",
5840
+ "aria-label": "Previous artifact",
5841
+ }, "‹"),
5842
+ React.createElement("button", {
5843
+ type: "button",
5844
+ className: "ps-artifact-pane-btn",
5845
+ disabled: !view.canNext,
5846
+ onClick: () => step(1),
5847
+ title: "Next artifact (→)",
5848
+ "aria-label": "Next artifact",
5849
+ }, "›"),
5850
+ React.createElement("button", {
5851
+ type: "button",
5852
+ className: "ps-artifact-pane-btn",
5853
+ onClick: () => {
5854
+ if (!deepLink) return;
5855
+ copySessionLinkText(deepLink);
5856
+ controller.dispatch({ type: "ui/status", text: ARTIFACT_LINK_COPIED_STATUS });
5857
+ },
5858
+ disabled: !deepLink,
5859
+ title: "Copy a link that reopens this artifact",
5860
+ "aria-label": "Copy link",
5861
+ }, "🔗"),
5862
+ React.createElement("button", {
5863
+ type: "button",
5864
+ className: "ps-artifact-pane-btn",
5865
+ onClick: () => controller.handleCommand(UI_COMMANDS.DOWNLOAD_SELECTED_FILE).catch(() => {}),
5866
+ title: "Download",
5867
+ "aria-label": "Download",
5868
+ }, React.createElement(DownloadGlyph)),
5869
+ React.createElement("button", {
5870
+ type: "button",
5871
+ className: "ps-artifact-pane-btn is-close",
5872
+ onClick: onClose,
5873
+ title: "Close (Esc)",
5874
+ "aria-label": "Close artifact",
5875
+ }, "✕"))),
5876
+ React.createElement("div", { className: "ps-artifact-pane-body" },
5877
+ React.createElement(FilesPane, {
5878
+ controller, focused: false, previewOnly: true, chromeless: true, theme,
5879
+ })));
5880
+ }
5881
+
5041
5882
  /**
5042
5883
  * Mobile artifact viewer — a genuine full-viewport overlay, not a pane.
5043
5884
  *
@@ -5146,7 +5987,7 @@ function InspectorTabs({ activeTab, controller }) {
5146
5987
  // can host it in the activity slot where it gets the existing row resizer.
5147
5988
  // Detached this way the preview is resizable; nested inside the inspector it
5148
5989
  // could only ever have half of a pane.
5149
- function FilesPane({ controller, focused, mobile = false, previewOnly = false, nativeScroll = false }) {
5990
+ function FilesPane({ controller, focused, mobile = false, previewOnly = false, nativeScroll = false, chromeless = false }) {
5150
5991
  const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
5151
5992
  const theme = getTheme(themeId);
5152
5993
  const fileInputRef = React.useRef(null);
@@ -5279,6 +6120,20 @@ function FilesPane({ controller, focused, mobile = false, previewOnly = false, n
5279
6120
  onClick: () => controller.handleCommand(UI_COMMANDS.DOWNLOAD_SELECTED_FILE).catch(() => {}),
5280
6121
  disabled: !hasSelection,
5281
6122
  }),
6123
+ // Deep link to THIS artifact, opened full screen. Same URL shape the
6124
+ // agent's show_artifact tool hands back, so a link pasted in chat and a
6125
+ // link copied here land in exactly the same place.
6126
+ React.createElement(IconButton, {
6127
+ icon: "🔗",
6128
+ label: "Copy link to this artifact",
6129
+ onClick: () => {
6130
+ const url = buildArtifactLinkUrl(filesView.selectedSessionId, filesView.selectedFilename);
6131
+ if (!url) return;
6132
+ copySessionLinkText(url);
6133
+ controller.dispatch({ type: "ui/status", text: ARTIFACT_LINK_COPIED_STATUS });
6134
+ },
6135
+ disabled: !hasSelection || !filesView.selectedFilename,
6136
+ }),
5282
6137
  // Trash, not "✕" — the bulk-delete two buttons away was already a trash
5283
6138
  // can, and "✕" means "clear marks" in this same row.
5284
6139
  viewState.canDeleteArtifacts ? React.createElement(IconButton, {
@@ -5390,7 +6245,27 @@ function FilesPane({ controller, focused, mobile = false, previewOnly = false, n
5390
6245
  && isTabularArtifact(filesView.selectedFilename, filesView.previewContentType)
5391
6246
  && Boolean(filesView.previewContent);
5392
6247
 
5393
- const previewPane = previewIsTabular
6248
+ // HTML renders as HTML. Note this branch does NOT require previewContent:
6249
+ // the frame streams the artifact's own bytes, so it works even when the
6250
+ // text preview was truncated or never loaded.
6251
+ const previewIsHtml = previewReady
6252
+ && !filesView.previewIsBinary
6253
+ && isHtmlArtifact(filesView.selectedFilename, filesView.previewContentType);
6254
+
6255
+ const previewPane = previewIsHtml
6256
+ ? React.createElement(HtmlArtifactPreviewPanel, {
6257
+ controller,
6258
+ sessionId: filesView.selectedSessionId,
6259
+ filename: filesView.selectedFilename,
6260
+ contentType: filesView.previewContentType,
6261
+ sizeBytes: filesView.previewSizeBytes,
6262
+ theme,
6263
+ color: "cyan",
6264
+ focused: false,
6265
+ title: null,
6266
+ chromeless,
6267
+ })
6268
+ : previewIsTabular
5394
6269
  ? React.createElement(RenderedPreviewPanel, {
5395
6270
  controller, title: null, color: "cyan", focused: false, theme,
5396
6271
  paneKey: previewPaneKey, scrollOffset: viewState.previewScroll,
@@ -8548,6 +9423,7 @@ export function PilotSwarmWebApp({ controller }) {
8548
9423
  inspectorTab: rootState.ui.inspectorTab,
8549
9424
  filesFullscreen: Boolean(rootState.files.fullscreen),
8550
9425
  selectedArtifactId: rootState.files.selectedArtifactId || null,
9426
+ artifactPaneOpen: Boolean(rootState.files.paneOpen),
8551
9427
  adminVisible: Boolean(rootState.admin?.visible),
8552
9428
  }), shallowEqualObject);
8553
9429
  const profileSettingsHydratedRef = React.useRef(false);
@@ -8556,6 +9432,7 @@ export function PilotSwarmWebApp({ controller }) {
8556
9432
  // endpoint replaces the whole settings object, so this is what keeps a
8557
9433
  // desktop save from erasing the phone's preference.
8558
9434
  const otherChatViewModeRef = React.useRef(null);
9435
+ const otherTouchScaleRef = React.useRef(null);
8559
9436
  const profileSettingsSaveTimerRef = React.useRef(null);
8560
9437
  const profileSettingsPollTimerRef = React.useRef(null);
8561
9438
  const profileSettingsPollInFlightRef = React.useRef(false);
@@ -8613,8 +9490,11 @@ export function PilotSwarmWebApp({ controller }) {
8613
9490
  if (isChatViewMode(remoteNormalized[otherChatViewModeKey()])) {
8614
9491
  otherChatViewModeRef.current = remoteNormalized[otherChatViewModeKey()];
8615
9492
  }
9493
+ if (typeof remoteNormalized[otherTouchScaleKey()] === "boolean") {
9494
+ otherTouchScaleRef.current = remoteNormalized[otherTouchScaleKey()];
9495
+ }
8616
9496
  defaultProfileSettingsRef.current = buildDefaultProfileSettingsFromState(
8617
- controller.getState(), otherChatViewModeRef.current,
9497
+ controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
8618
9498
  );
8619
9499
  const settings = materializeProfileSettings(
8620
9500
  profile?.profileSettings,
@@ -8622,7 +9502,7 @@ export function PilotSwarmWebApp({ controller }) {
8622
9502
  );
8623
9503
  const settingsJson = JSON.stringify(settings);
8624
9504
  const currentSettingsBeforeApply = profileSettingsFromViewState(
8625
- controller.getState(), otherChatViewModeRef.current,
9505
+ controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
8626
9506
  );
8627
9507
  const currentSettingsBeforeApplyJson = JSON.stringify(currentSettingsBeforeApply);
8628
9508
  const hasUnpersistedLocalChange = profileSettingsHydratedRef.current
@@ -8649,7 +9529,7 @@ export function PilotSwarmWebApp({ controller }) {
8649
9529
  // remote state by definition and there is nothing to lose).
8650
9530
  if (!profileSettingsHydratedRef.current || !hasPendingLocalWrite) {
8651
9531
  const currentSettings = profileSettingsFromViewState(
8652
- controller.getState(), otherChatViewModeRef.current,
9532
+ controller.getState(), otherChatViewModeRef.current, otherTouchScaleRef.current,
8653
9533
  );
8654
9534
  lastProfileSettingsJsonRef.current = JSON.stringify(currentSettings);
8655
9535
  }
@@ -8683,7 +9563,7 @@ export function PilotSwarmWebApp({ controller }) {
8683
9563
 
8684
9564
  React.useEffect(() => {
8685
9565
  if (!profileSettingsHydratedRef.current) return undefined;
8686
- const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current);
9566
+ const settings = profileSettingsFromViewState(state, otherChatViewModeRef.current, otherTouchScaleRef.current);
8687
9567
  const settingsJson = JSON.stringify(settings);
8688
9568
  if (lastProfileSettingsJsonRef.current === settingsJson) return undefined;
8689
9569
  lastProfileSettingsJsonRef.current = settingsJson;
@@ -8748,6 +9628,20 @@ export function PilotSwarmWebApp({ controller }) {
8748
9628
  && Boolean(state.selectedArtifactId)
8749
9629
  && !filesFullscreenActive;
8750
9630
 
9631
+ // The takeover pane only makes sense on desktop; the phone has its own
9632
+ // full-viewport overlay and no column to take over.
9633
+ const artifactPaneActive = !mobile
9634
+ && state.artifactPaneOpen
9635
+ && Boolean(state.selectedArtifactId)
9636
+ && !filesFullscreenActive;
9637
+
9638
+ // Closing hands the column back — or collapses it again, if the user had
9639
+ // already resized it away before opening the pane. Both cases live in the
9640
+ // controller so the decision is testable without a DOM.
9641
+ const closeArtifactPane = React.useCallback(() => {
9642
+ controller.closeArtifactPane().catch(() => {});
9643
+ }, [controller]);
9644
+
8751
9645
  React.useEffect(() => {
8752
9646
  if (!filesFullscreenActive || !chatFocusMode) return;
8753
9647
  setChatFocusMode(false);
@@ -8814,7 +9708,13 @@ export function PilotSwarmWebApp({ controller }) {
8814
9708
  },
8815
9709
  React.createElement(ChatPane, { controller }))),
8816
9710
  React.createElement(ColumnResizeHandle, { controller, paneAdjust: state.paneAdjust }),
8817
- React.createElement("div", {
9711
+ // The artifact reader takes the WHOLE right column — inspector, resizer and
9712
+ // activity all step aside. The column keeps its width and resizer, so the
9713
+ // chat/reader split is dragged exactly like the chat/inspector one was.
9714
+ artifactPaneActive
9715
+ ? React.createElement("div", { className: "ps-workspace-column is-artifact" },
9716
+ React.createElement(ArtifactTakeoverPane, { controller, onClose: closeArtifactPane }))
9717
+ : React.createElement("div", {
8818
9718
  className: "ps-workspace-column",
8819
9719
  style: { gridTemplateRows: `${layout.inspectorPaneHeight}fr var(--ps-resizer-track, 16px) ${layout.activityPaneHeight}fr` },
8820
9720
  },