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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.34",
3
+ "version": "0.5.35",
4
4
  "description": "PilotSwarm application package: terminal UI, browser portal + Web API server, and MCP server — one install, three bins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -81,7 +81,7 @@
81
81
  "ink": "^6.8.0",
82
82
  "jose": "^6.2.2",
83
83
  "mermaid": "^11.16.0",
84
- "pilotswarm-sdk": "0.5.34",
84
+ "pilotswarm-sdk": "0.5.35",
85
85
  "react": "^19.2.4",
86
86
  "react-dom": "^19.2.4",
87
87
  "ws": "^8.18.2"
@@ -24,6 +24,8 @@ import {
24
24
  MIN_ACTIVITY_PANE_HEIGHT,
25
25
  MIN_INSPECTOR_PANE_HEIGHT,
26
26
  DEFAULT_ACTIVITY_PANE_RATIO,
27
+ DEFAULT_LEFT_PANE_RATIO,
28
+ COLLAPSE_RIGHT_THRESHOLD,
27
29
  normalizeFocusRegion,
28
30
  } from "./layout.js";
29
31
  import { parseTerminalMarkupRuns } from "./formatting.js";
@@ -680,7 +682,7 @@ function getRenameSessionPrefix(session) {
680
682
  if (separatorIndex > 0) {
681
683
  return currentTitle.slice(0, separatorIndex).trim() || null;
682
684
  }
683
- return currentTitle || null;
685
+ return null;
684
686
  }
685
687
 
686
688
  function getRenameSessionMaxLength(session) {
@@ -1243,6 +1245,12 @@ function isBinaryPreview(filename, contentType = "") {
1243
1245
  || normalizedType === "application/wasm";
1244
1246
  }
1245
1247
 
1248
+ // How recent a `session.artifact_presented` event must be for the portal to
1249
+ // act on it. Long enough to survive a slow turn and a brief reconnect; short
1250
+ // enough that a catch-up burst after a long disconnect does not reorganize the
1251
+ // workspace around something the agent said ages ago.
1252
+ const PRESENTED_ARTIFACT_FRESHNESS_MS = 120_000;
1253
+
1246
1254
  function truncateFilePreview(content, limit = FILE_PREVIEW_CHAR_LIMIT) {
1247
1255
  const text = String(content ?? "");
1248
1256
  if (text.length <= limit) return text;
@@ -4195,6 +4203,32 @@ export class PilotSwarmUiController {
4195
4203
  }
4196
4204
  }
4197
4205
 
4206
+ /**
4207
+ * Act on a `session.artifact_presented` event from the live stream.
4208
+ *
4209
+ * Separate from mergeSessionEvent so the guards are testable on their own
4210
+ * and so the merge path stays a straight line. Returns whether it revealed.
4211
+ */
4212
+ maybeRevealPresentedArtifact(sessionId, event) {
4213
+ const filename = String(event?.data?.filename || "").trim();
4214
+ if (!filename) return false;
4215
+ if (sessionId !== this.getState().sessions.activeSessionId) return false;
4216
+
4217
+ const createdAt = event?.createdAt ? Date.parse(event.createdAt) : Number.NaN;
4218
+ if (Number.isFinite(createdAt) && Date.now() - createdAt > PRESENTED_ARTIFACT_FRESHNESS_MS) {
4219
+ return false;
4220
+ }
4221
+
4222
+ // Same destination a user click reaches: the artifact reader, with the
4223
+ // chat still beside it. `fullscreen` from the tool means "give it the
4224
+ // whole window" and stays the phone/fullscreen path.
4225
+ const fullscreen = event?.data?.fullscreen === true;
4226
+ this.revealArtifact(sessionId, filename, fullscreen
4227
+ ? { fullscreen: true }
4228
+ : { pane: true }).catch(() => {});
4229
+ return true;
4230
+ }
4231
+
4198
4232
  mergeSessionEvent(sessionId, event) {
4199
4233
  if (!sessionId || !event) return false;
4200
4234
  const state = this.getState();
@@ -4227,6 +4261,24 @@ export class PilotSwarmUiController {
4227
4261
  },
4228
4262
  });
4229
4263
  }
4264
+ // show_artifact: the agent is presenting something to look at, so the
4265
+ // inspector switches to Files and opens that preview live.
4266
+ //
4267
+ // Three guards, because "the UI moves on its own" is only acceptable
4268
+ // when it is unmistakably a response to what is happening right now:
4269
+ //
4270
+ // - active session only. A background session finishing a dashboard
4271
+ // must never yank the pane away from the session being read.
4272
+ // - fresh events only. This path also runs for the catch-up burst
4273
+ // after a reconnect; replaying an hour-old presentation as if it
4274
+ // just happened would be a jump scare, not a feature.
4275
+ // - live path only. Bulk history loads (ensureSessionHistory) do not
4276
+ // come through here, so merely OPENING a session never auto-opens
4277
+ // whatever it last presented. Reopening is what the artifact link
4278
+ // in the transcript and the deep link are for.
4279
+ if (event.eventType === "session.artifact_presented") {
4280
+ this.maybeRevealPresentedArtifact(sessionId, event);
4281
+ }
4230
4282
  this.reconcileOutboxAgainstEvent(sessionId, event);
4231
4283
  const derivedModel = extractSessionModelFromEvent(event);
4232
4284
  const currentSession = this.getState().sessions.byId[sessionId] || { sessionId };
@@ -5262,7 +5314,11 @@ export class PilotSwarmUiController {
5262
5314
  * READ. Switches the inspector to Files, selects the artifact, and warms
5263
5315
  * the preview so the pane is populated by the time it renders.
5264
5316
  */
5265
- async revealArtifact(sessionId, filename, { force = true, fullscreen = false } = {}) {
5317
+ async revealArtifact(sessionId, filename, {
5318
+ force = true,
5319
+ fullscreen = false,
5320
+ pane = false,
5321
+ } = {}) {
5266
5322
  const resolvedSessionId = sessionId || this.getState().sessions.activeSessionId;
5267
5323
  const name = String(filename || "").trim();
5268
5324
  if (!resolvedSessionId || !name) return false;
@@ -5278,12 +5334,37 @@ export class PilotSwarmUiController {
5278
5334
  if (this.getState().sessions.activeSessionId !== resolvedSessionId) {
5279
5335
  await this.loadSession(resolvedSessionId).catch(() => null);
5280
5336
  }
5281
- this.dispatch({ type: "ui/inspectorTab", inspectorTab: "files" });
5282
- // Focus the inspector, not just the tab. On desktop this makes j/k
5283
- // drive the artifact list immediately; on mobile the visible pane is
5284
- // derived from focusRegion, so without this the preview would open on
5285
- // a pane the user cannot see.
5286
- this.setFocus("inspector");
5337
+ // The reader REPLACES the inspector, so it has no business changing
5338
+ // which inspector tab is selected. Switching to Files here meant that
5339
+ // opening an artifact from a session you were watching on the Sequence
5340
+ // tab silently reassigned that tab, and dropped you on a file list
5341
+ // you never opened. Leaving it alone makes ✕ restore the tab for free,
5342
+ // with no state to capture. The non-pane paths still need Files,
5343
+ // because there the inspector IS the preview.
5344
+ if (!pane) this.dispatch({ type: "ui/inspectorTab", inspectorTab: "files" });
5345
+ // The takeover pane is its own focus surface and, unlike the inspector,
5346
+ // it is what the user is looking at — so it must NOT steal focus to the
5347
+ // artifact list underneath. Chat keeps focus, which is the point: you
5348
+ // followed a link from the transcript and should still be able to type.
5349
+ if (pane) {
5350
+ // Whether the column was already collapsed is read HERE rather than
5351
+ // taken from the caller, so every entry point — a chat card, a deep
5352
+ // link, an agent's show_artifact — gets the same answer.
5353
+ //
5354
+ // And if it WAS collapsed, the column has to be opened: rendering
5355
+ // the reader into a zero-width track produced a pane that existed
5356
+ // in the DOM, reported itself open, and could not be seen. Closing
5357
+ // collapses it again, so the detour is invisible.
5358
+ const wasHidden = Boolean(this.getCurrentLayout()?.rightHidden);
5359
+ if (wasHidden) this.expandRightColumn();
5360
+ this.dispatch({ type: "files/pane", open: true, restoresToHidden: wasHidden });
5361
+ } else {
5362
+ // Focus the inspector, not just the tab. On desktop this makes j/k
5363
+ // drive the artifact list immediately; on mobile the visible pane is
5364
+ // derived from focusRegion, so without this the preview would open on
5365
+ // a pane the user cannot see.
5366
+ this.setFocus("inspector");
5367
+ }
5287
5368
  await this.ensureFilesForSession(resolvedSessionId, { force }).catch(() => null);
5288
5369
  this.dispatch({ type: "files/select", sessionId: resolvedSessionId, filename: name });
5289
5370
  await this.ensureFilePreview(resolvedSessionId, name, { force }).catch(() => null);
@@ -5291,6 +5372,57 @@ export class PilotSwarmUiController {
5291
5372
  return true;
5292
5373
  }
5293
5374
 
5375
+ /**
5376
+ * Close the artifact takeover pane, restoring the right column.
5377
+ *
5378
+ * Restores the inspector/activity split it replaced — unless the column was
5379
+ * already collapsed when the pane opened, in which case it collapses again.
5380
+ * Opening a reader must not become a way to un-hide panes the user had
5381
+ * deliberately resized away.
5382
+ */
5383
+ async closeArtifactPane() {
5384
+ const restoresToHidden = this.getState().files.paneRestoresToHidden;
5385
+ this.dispatch({ type: "files/pane", open: false });
5386
+ this.dispatch({ type: "files/fullscreen", fullscreen: false });
5387
+ // Selection is deliberately left alone: the artifact stays selected in
5388
+ // the Files list, so reopening the column shows what you were reading
5389
+ // rather than an empty preview.
5390
+ this.dispatch({ type: "files/previewOrigin", origin: null });
5391
+ if (restoresToHidden) this.collapseRightColumn();
5392
+ this.dispatch({ type: "ui/focus", focusRegion: FOCUS_REGIONS.CHAT });
5393
+ return { collapsedRightColumn: Boolean(restoresToHidden) };
5394
+ }
5395
+
5396
+ /**
5397
+ * Drive the left/right split far enough right that the layout's own
5398
+ * collapse rule hides the right column.
5399
+ *
5400
+ * Expressed as a paneAdjust rather than a new "hidden" flag so there stays
5401
+ * exactly one source of truth for column width — the same number the
5402
+ * resizer writes. A second flag would let the two disagree.
5403
+ */
5404
+ /** Return the left/right split to its default, un-collapsing the column. */
5405
+ expandRightColumn() {
5406
+ this.dispatch({ type: "ui/paneAdjust", paneAdjust: 0 });
5407
+ const nextLayout = this.getCurrentLayout({ paneAdjust: 0 });
5408
+ const currentFocus = this.getState().ui.focusRegion;
5409
+ const safeFocus = normalizeFocusRegion(currentFocus, nextLayout);
5410
+ if (safeFocus !== currentFocus) this.setFocus(safeFocus);
5411
+ }
5412
+
5413
+ collapseRightColumn() {
5414
+ const layoutState = this.getState().ui.layout || {};
5415
+ const totalWidth = layoutState.viewportWidth ?? 120;
5416
+ const baseLeftWidth = Math.floor(totalWidth * DEFAULT_LEFT_PANE_RATIO);
5417
+ // One past the threshold: `<=` collapses, so land strictly inside it.
5418
+ const paneAdjust = totalWidth - COLLAPSE_RIGHT_THRESHOLD - baseLeftWidth;
5419
+ this.dispatch({ type: "ui/paneAdjust", paneAdjust });
5420
+ const nextLayout = this.getCurrentLayout({ paneAdjust });
5421
+ const currentFocus = this.getState().ui.focusRegion;
5422
+ const safeFocus = normalizeFocusRegion(currentFocus, nextLayout);
5423
+ if (safeFocus !== currentFocus) this.setFocus(safeFocus);
5424
+ }
5425
+
5294
5426
  /**
5295
5427
  * Leave the artifact preview, returning wherever the user came FROM.
5296
5428
  *
@@ -5612,7 +5744,9 @@ export class PilotSwarmUiController {
5612
5744
  type: "ui/modal",
5613
5745
  modal: {
5614
5746
  type: "renameSession",
5615
- title: `Rename (${shortSessionIdValue(sessionId)})`,
5747
+ // A group row's id is synthetic, so showing its short form
5748
+ // reads as a stray hex string rather than an identifier.
5749
+ title: session.isGroup ? "Rename folder" : `Rename (${shortSessionIdValue(sessionId)})`,
5616
5750
  sessionId,
5617
5751
  previousFocus: state.ui.focusRegion,
5618
5752
  value,
@@ -5620,13 +5754,19 @@ export class PilotSwarmUiController {
5620
5754
  agentTitlePrefix,
5621
5755
  currentTitle: String(session.title || "").trim(),
5622
5756
  maxLength,
5757
+ // Folders share this modal but not its caveats: a group has no
5758
+ // agent and no LLM-generated title, so the selector suppresses
5759
+ // the auto-title warning for them.
5760
+ isGroup: Boolean(session.isGroup),
5623
5761
  },
5624
5762
  });
5625
5763
  this.dispatch({
5626
5764
  type: "ui/status",
5627
- text: agentTitlePrefix
5628
- ? `Rename title for ${agentTitlePrefix}; the agent-name prefix stays fixed`
5629
- : "Type a new session title and press Enter to save",
5765
+ text: session.isGroup
5766
+ ? "Type a new folder name and press Enter to save"
5767
+ : agentTitlePrefix
5768
+ ? `Rename title for ${agentTitlePrefix}; the agent-name prefix stays fixed`
5769
+ : "Type a new session title and press Enter to save",
5630
5770
  });
5631
5771
  }
5632
5772
 
@@ -9,6 +9,18 @@ const DEFAULT_STYLE = {
9
9
  const ARTIFACT_URI_RE = /artifact:\/\/([a-f0-9-]+)\/([^\s"'{}]+)/g;
10
10
  const SESSION_URI_RE = /session:\/\/([A-Za-z0-9-]+)/g;
11
11
 
12
+ /**
13
+ * The TUI's affordance for an artifact link, appended when a bare
14
+ * `artifact://` URI is decorated for display.
15
+ *
16
+ * Exported because the PORTAL has to remove it again: there, an artifact link
17
+ * renders as a card you click, so an instruction to press a key that does not
18
+ * exist in a browser is noise. Hosts disagree about the affordance but share
19
+ * the decorator, so the string is named in one place rather than matched by
20
+ * two copies of a literal that can drift apart.
21
+ */
22
+ export const ARTIFACT_DOWNLOAD_HINT = " (press a to download)";
23
+
12
24
  const COLOR_NAME_MAP = {
13
25
  black: "black",
14
26
  red: "red",
@@ -495,7 +507,7 @@ export function decorateArtifactLinksForChat(text) {
495
507
  if (whole.slice(Math.max(0, offset - 2), offset) === "](") return _match;
496
508
  const filename = normalizeArtifactFilename(rawFilename);
497
509
  if (!sessionId || !filename) return _match;
498
- return `[artifact: ${filename}](artifact://${sessionId}/${filename}) (press a to download)`;
510
+ return `[artifact: ${filename}](artifact://${sessionId}/${filename})${ARTIFACT_DOWNLOAD_HINT}`;
499
511
  });
500
512
  SESSION_URI_RE.lastIndex = 0;
501
513
  return withArtifacts.replace(SESSION_URI_RE, (_match, sessionId) => {
@@ -703,6 +703,14 @@ function formatActivity(event) {
703
703
  case "assistant.streaming_progress":
704
704
  return null;
705
705
 
706
+ case "session.artifact_presented": {
707
+ const data = (event?.data ?? {}) || {};
708
+ const filename = String(data.filename || "artifact");
709
+ const note = typeof data.note === "string" && data.note.trim() ? ` — ${data.note.trim()}` : "";
710
+ runs = buildLabeledActivityRuns(time, "[show]", "cyan", `${filename}${note}`, "white");
711
+ break;
712
+ }
713
+
706
714
  case "tool.execution_start":
707
715
  runs = formatToolActivityRuns(time, event, "start");
708
716
  break;
@@ -2568,6 +2568,53 @@ export function appReducer(state, action) {
2568
2568
  },
2569
2569
  };
2570
2570
 
2571
+ // The artifact takeover pane. `restoresToHidden` is captured ONCE, on
2572
+ // the opening transition — reopening while already open must not
2573
+ // overwrite it with "the column is visible", which is trivially true
2574
+ // whenever the pane itself is showing and would strand a user who had
2575
+ // collapsed the column with an inspector they never asked for.
2576
+ case "files/pane":
2577
+ return {
2578
+ ...state,
2579
+ files: {
2580
+ ...state.files,
2581
+ paneOpen: Boolean(action.open),
2582
+ paneRestoresToHidden: action.open
2583
+ ? (state.files.paneOpen
2584
+ ? state.files.paneRestoresToHidden
2585
+ : Boolean(action.restoresToHidden))
2586
+ : false,
2587
+ },
2588
+ };
2589
+
2590
+ case "files/htmlViewMode":
2591
+ return {
2592
+ ...state,
2593
+ files: {
2594
+ ...state.files,
2595
+ htmlViewMode: action.mode === "source" ? "source" : "rendered",
2596
+ },
2597
+ };
2598
+
2599
+ case "files/htmlFitWidth":
2600
+ return {
2601
+ ...state,
2602
+ files: {
2603
+ ...state.files,
2604
+ htmlFitWidth: Boolean(action.enabled),
2605
+ },
2606
+ };
2607
+
2608
+ // Clamped here rather than at the call site so every entry point —
2609
+ // buttons, keyboard, a restored profile — lands in the same range.
2610
+ case "files/htmlZoom": {
2611
+ const requested = Number(action.zoom);
2612
+ const zoom = Number.isFinite(requested)
2613
+ ? Math.min(3, Math.max(0.5, Math.round(requested * 100) / 100))
2614
+ : 1;
2615
+ return { ...state, files: { ...state.files, htmlZoom: zoom } };
2616
+ }
2617
+
2571
2618
  case "files/clearMarks":
2572
2619
  return { ...state, files: { ...state.files, markedIds: [] } };
2573
2620
 
@@ -5801,11 +5801,16 @@ export function selectRenameSessionModal(state, maxWidth = 76) {
5801
5801
  text: " cancel",
5802
5802
  color: "gray",
5803
5803
  }],
5804
- [{ text: "", color: "gray" }],
5805
- [{
5806
- text: "Manual titles stop future automatic LLM title changes for this session.",
5807
- color: "gray",
5808
- }],
5804
+ // A folder has no agent writing titles for it, so the auto-title
5805
+ // caveat is noise there — and worse, it implies a folder is a
5806
+ // session.
5807
+ ...(modal.isGroup ? [] : [
5808
+ [{ text: "", color: "gray" }],
5809
+ [{
5810
+ text: "Manual titles stop future automatic LLM title changes for this session.",
5811
+ color: "gray",
5812
+ }],
5813
+ ]),
5809
5814
  ],
5810
5815
  detailsLines,
5811
5816
  idealWidth: Math.min(
@@ -268,6 +268,34 @@ export function createInitialState({ mode = "local", branding = null, docs = nul
268
268
  // Selection to restore when leaving a chat-opened preview, so
269
269
  // following a chat link never disturbs the list.
270
270
  restoreArtifactId: null,
271
+ // The portal's artifact takeover pane: a full-height reader that
272
+ // replaces the inspector AND activity panes in the right column.
273
+ // Opened by chat artifact cards and artifact deep links; the Files
274
+ // list keeps its own stacked list-above-preview view, because
275
+ // taking over the column would hide the list being browsed.
276
+ //
277
+ // The TUI ignores this entirely — it has no such pane.
278
+ paneOpen: false,
279
+ // What the right column looked like BEFORE the pane opened, so ✕
280
+ // can put it back. When the user had already resized the column
281
+ // out of existence, closing must return to that — not conjure an
282
+ // inspector they had deliberately dismissed.
283
+ paneRestoresToHidden: false,
284
+ // Rendered page vs. HTML source. Lifted out of the preview
285
+ // component so the reader pane's single header can own the toggle
286
+ // — the alternative was a second header row stacked under the
287
+ // first, which is exactly the box-in-a-box the pane exists to undo.
288
+ htmlViewMode: "rendered",
289
+ // Zoom for the rendered HTML frame ONLY. Browser zoom scales the
290
+ // whole workspace — the session list and transcript included —
291
+ // when all you wanted was to read a dense chart. This scales the
292
+ // artifact and nothing else.
293
+ htmlZoom: 1,
294
+ // Fit-to-width for the rendered HTML frame. Lifted here for the
295
+ // same reason htmlViewMode is: on mobile the panel is chromeless
296
+ // and the artifact bar owns the controls, so the two surfaces have
297
+ // to read one value.
298
+ htmlFitWidth: false,
271
299
  filter: {
272
300
  scope: "selectedSession",
273
301
  query: "",
@@ -0,0 +1,136 @@
1
+ import { createTheme, THEME_GROUP_OTHER } from "./helpers.js";
2
+
3
+ // Duroxide (2025-) - the durable execution runtime, wearing its own brand.
4
+ //
5
+ // This is NOT the Rust theme with a different accent. Rust is rustc OUTPUT:
6
+ // hot Ferris orange on cold graphite, with the compiler's error/warning/help
7
+ // levels as the status palette. Duroxide is the PROJECT BRAND: the banner is
8
+ // a sheet of near-black felt with the mark pressed into it, and the mark is
9
+ // not orange at all - it is iron oxide, a terracotta so muted it reads as a
10
+ // material rather than a highlight. Ground, accent and mood all differ, which
11
+ // is what keeps two warm-dark themes from collapsing into one.
12
+ //
13
+ // Every value below is MEASURED off the banner artwork rather than picked by
14
+ // eye. The ground is #282627 with a paper grain running about +/-12 levels
15
+ // either side; the mark's modal colour is #a35838, its lit edge #ba714c.
16
+ //
17
+ // The catch: #a35838 on #282627 is 2.87:1. The exact brand colour cannot be
18
+ // text. So the brand oxide is spent on CHROME - the logo, borders, rules -
19
+ // and the interactive accent is a brightened sibling, #d2764a, which clears
20
+ // 4.5:1 on both grounds while staying recognisably the same pigment. Getting
21
+ // that backwards is how a brand palette becomes an unreadable interface.
22
+ //
23
+ // Rust's DNA survives where it earns its place: the status slots keep rustc's
24
+ // three-level vocabulary (error red, warning amber, help blue) with cargo
25
+ // green for success - a status palette a session list can use directly - but
26
+ // each is desaturated to sit inside the brand's restraint rather than shout
27
+ // over it.
28
+ //
29
+ // The banner's felt grain is not expressible as a palette entry, so it rides
30
+ // on [data-ps-theme="duroxide"] in the stylesheet, the same hook Win95 uses
31
+ // for its bevels.
32
+
33
+ // The brand oxide, sampled from the mark itself. Chrome only - see above.
34
+ const OXIDE = "#a35838";
35
+
36
+ // The mark itself, traced from the banner artwork at 1.8px tolerance: hexagon
37
+ // ring, eight-tooth gear, and the replay arrow sweeping its lower arc. Four
38
+ // subpaths in one string - outers wound clockwise, holes counter-clockwise.
39
+ const DUROXIDE_MARK =
40
+ "M4.14 33.09L4.14 18.89L5.7 15.77L30.05 1.4L33.95 1.4L43.32 6.56L57.99 15.3L59.08 16.7L59.7"
41
+ + "1 18.58L59.71 45.27L59.08 47.14L56.9 49.64L41.44 58.38L38.32 60.57L33.64 62.91L30.52 62.91"
42
+ + "L27.71 61.66L14.6 53.7L6.79 49.48L5.54 48.23L4.45 46.2L4.14 44.49L4.14 33.09ZM55.8 26.07L5"
43
+ + "5.8 19.36L55.02 18.42L33.8 6.09L31.45 5.78L30.05 6.24L8.51 18.89L8.51 45.11L10.22 46.67L18"
44
+ + ".03 50.89L30.05 58.22L31.77 58.85L33.17 58.69L53.93 46.67L55.65 44.96L55.8 26.07ZM17.09 34"
45
+ + ".81L16.78 33.56L13.19 32.62L13.19 27.0L17.09 26.54L18.97 23.26L16.94 19.67L21.0 15.61L24.4"
46
+ + "3 17.95L27.86 16.86L29.11 12.8L34.58 12.8L35.51 16.86L38.63 17.95L39.57 17.8L42.69 15.61L4"
47
+ + "6.6 19.67L46.28 20.92L44.88 22.95L44.88 23.73L46.28 26.38L50.65 27.16L50.65 32.47L47.69 33"
48
+ + ".4L47.06 34.03L42.54 32.0L42.54 29.97L41.91 27.63L40.82 25.6L38.63 23.26L34.73 21.39L32.55"
49
+ + " 21.07L29.11 21.39L26.3 22.48L23.96 24.35L22.4 26.54L21.31 29.97L21.31 33.25L22.87 37.46L2"
50
+ + "4.12 39.18L27.4 41.83L24.59 42.77L19.9 42.93L16.0 41.68L15.22 40.12L17.72 37.31L17.09 34.8"
51
+ + "1ZM36.76 35.59L35.51 32.62L35.82 32.0L50.19 39.34L49.25 41.99L43.32 51.2L42.54 51.04L41.6 "
52
+ + "46.2L35.51 49.64L31.3 50.58L28.49 50.58L21.93 49.01L18.97 47.3L16.47 45.11L16.78 44.8L19.1"
53
+ + "2 45.58L22.09 45.74L25.21 45.42L28.18 44.33L29.74 44.49L33.48 42.46L37.39 39.18L37.85 37.9"
54
+ + "3L36.76 35.59Z";
55
+
56
+ const duroxideTheme = createTheme({
57
+ id: "duroxide",
58
+ label: "Duroxide",
59
+ description: "Iron oxide pressed into black felt - the durable-execution runtime's own mark, traced from the banner.",
60
+ group: THEME_GROUP_OTHER,
61
+ // Traced from the banner artwork, not redrawn: the hexagon, the eight-tooth
62
+ // gear, and the replay arrow sweeping its lower arc. Subpaths are wound so
63
+ // the default nonzero fill rule punches the holes - ThemeIconMark renders
64
+ // a bare <path d fill> with no fill-rule to lean on.
65
+ icon: {
66
+ viewBox: "0 0 64 64",
67
+ // ONE path, not four. ThemeIconMark renders each entry as its own
68
+ // <path>, and the nonzero fill rule can only cancel windings WITHIN a
69
+ // single element - split across four, the hexagon ring and the gear
70
+ // hole both filled solid and the mark came out as a plain blob.
71
+ paths: [
72
+ { d: DUROXIDE_MARK, fill: OXIDE },
73
+ ],
74
+ },
75
+ page: {
76
+ // Deeper than the banner so panels sit ON the sheet rather than in it.
77
+ background: "#201e1f",
78
+ foreground: "#e5e0dc",
79
+ overlayBackground: "#2e2c2d",
80
+ overlayForeground: "#e5e0dc",
81
+ hintColor: "#a89f99",
82
+ modalBackdrop: "rgba(23, 21, 23, 0.86)",
83
+ modalBackground: "#2e2c2d",
84
+ modalBorder: "#4d4643",
85
+ modalForeground: "#e5e0dc",
86
+ modalMuted: "#a89f99",
87
+ // Selection is the oxide at its deepest measured shadow, not a tint of
88
+ // the accent - the colour the pressed edge of the mark actually goes.
89
+ modalSelectedBackground: "#7c3f26",
90
+ modalSelectedBorder: "#a35838",
91
+ modalSelectedForeground: "#ffeee4",
92
+ },
93
+ terminal: {
94
+ // The banner tone, exactly.
95
+ background: "#282627",
96
+ foreground: "#e5e0dc",
97
+ cursor: "#d2764a",
98
+ cursorAccent: "#201e1f",
99
+ selectionBackground: "#7c3f26",
100
+ black: "#201e1f",
101
+ // rustc's levels, desaturated into the brand's register.
102
+ red: "#e86a5f", // error[E0382]
103
+ green: "#7fb069", // Compiling / Finished
104
+ yellow: "#d9a441", // warning:
105
+ blue: "#6a9fd4", // the --> gutter rail and help:
106
+ magenta: "#b98bc4",
107
+ // The accent slot. The portal derives --ps-accent from tui.cyan, so the
108
+ // interactive colour travels here regardless of the slot's name.
109
+ cyan: "#d2764a",
110
+ white: "#e5e0dc",
111
+ brightBlack: "#a89f99",
112
+ brightRed: "#f4897f",
113
+ brightGreen: "#9bc888",
114
+ brightYellow: "#efc069",
115
+ brightBlue: "#8fbce4",
116
+ brightMagenta: "#d0a8d9",
117
+ // The banner's lit edge, measured where the press catches the light.
118
+ brightCyan: "#ba714c",
119
+ brightWhite: "#fdf7f2",
120
+ },
121
+ tui: {
122
+ border: "#46403d",
123
+ // The human speaks in the warning register, as in the Rust theme: the
124
+ // accent stays reserved for the machine and what it produces.
125
+ userChat: "#e0b070",
126
+ userChatLabel: "#f0cf9a",
127
+ selectionBackground: "#7c3f26",
128
+ selectionForeground: "#ffeee4",
129
+ activeHighlightBackground: "#7c3f26",
130
+ activeHighlightForeground: "#ffeee4",
131
+ promptCursorBackground: "#d2764a",
132
+ promptCursorForeground: "#201e1f",
133
+ },
134
+ });
135
+
136
+ export default duroxideTheme;
@@ -17,6 +17,7 @@ import msDosTheme from "./ms-dos.js";
17
17
  import doomTheme from "./doom.js";
18
18
  import winampTheme from "./winamp.js";
19
19
  import rustTheme from "./rust.js";
20
+ import duroxideTheme from "./duroxide.js";
20
21
  import spongebobTheme from "./spongebob.js";
21
22
 
22
23
  // Themes are ordered by GROUP first, then alphabetically inside it — the
@@ -54,6 +55,7 @@ const THEMES = Object.freeze([
54
55
  doomTheme,
55
56
  winampTheme,
56
57
  rustTheme,
58
+ duroxideTheme,
57
59
  spongebobTheme,
58
60
  ].sort(byGroupThenLabel));
59
61