pilotswarm 0.5.35 → 0.5.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +2 -2
  2. package/tui/src/app.js +0 -4
  3. package/tui/src/index.js +0 -9
  4. package/ui/core/src/canvas-actions.js +148 -0
  5. package/ui/core/src/commands.js +0 -1
  6. package/ui/core/src/controller.js +217 -19
  7. package/ui/core/src/history.js +134 -1
  8. package/ui/core/src/index.js +1 -0
  9. package/ui/core/src/reducer.js +209 -19
  10. package/ui/core/src/selectors.js +143 -154
  11. package/ui/core/src/state.js +51 -2
  12. package/ui/core/src/themes/duroxide.js +6 -1
  13. package/ui/core/src/themes/win95.js +4 -1
  14. package/ui/react/src/components.js +1 -7
  15. package/ui/react/src/web-app.js +853 -260
  16. package/web/dist/assets/{abnfDiagram-VRR7QNED-BwJEohLG.js → abnfDiagram-VRR7QNED-DlvPH-8O.js} +1 -1
  17. package/web/dist/assets/{arc-CWUJ_S5s.js → arc-r1liz5e5.js} +1 -1
  18. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-i6mrLxuE.js → architectureDiagram-ZJ3FMSHR-Bci5dNH3.js} +1 -1
  19. package/web/dist/assets/{blockDiagram-677ZJIJ3-C_KG-qPQ.js → blockDiagram-677ZJIJ3-Bug32AXp.js} +1 -1
  20. package/web/dist/assets/{c4Diagram-LMCZKHZV-BBJH7hbB.js → c4Diagram-LMCZKHZV-DwxSFqgE.js} +1 -1
  21. package/web/dist/assets/channel-jVpSYat3.js +1 -0
  22. package/web/dist/assets/{chunk-2Q5K7J3B-CXwaFcaM.js → chunk-2Q5K7J3B-B_dbI_xg.js} +1 -1
  23. package/web/dist/assets/{chunk-32BRIVSS-C8ujjkK-.js → chunk-32BRIVSS-BkF68W-R.js} +1 -1
  24. package/web/dist/assets/{chunk-5VM5RSS4-M9rz7YYk.js → chunk-5VM5RSS4-BSbNIbJu.js} +1 -1
  25. package/web/dist/assets/{chunk-EX3LRPZG-bk6tRxOA.js → chunk-EX3LRPZG-Blv_Iqa2.js} +1 -1
  26. package/web/dist/assets/{chunk-JWPE2WC7-DZ4WSHGH.js → chunk-JWPE2WC7-Dk_dBFKd.js} +1 -1
  27. package/web/dist/assets/{chunk-MOJQB5TN-B5-y6tMF.js → chunk-MOJQB5TN-DqNFBrfw.js} +1 -1
  28. package/web/dist/assets/{chunk-RYQCIY6F-yKcnHxm0.js → chunk-RYQCIY6F-Dlv7Svpb.js} +1 -1
  29. package/web/dist/assets/{chunk-V7JOEXUC-CpoNcATD.js → chunk-V7JOEXUC-DozGikwu.js} +1 -1
  30. package/web/dist/assets/{chunk-VR4S4FIN-Bf1yD2fn.js → chunk-VR4S4FIN-CmkqvGT4.js} +1 -1
  31. package/web/dist/assets/{chunk-XXDRQBXY-EnPvDVhP.js → chunk-XXDRQBXY-DyByai5N.js} +1 -1
  32. package/web/dist/assets/classDiagram-OUVF2IWQ-C78zSajE.js +1 -0
  33. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C78zSajE.js +1 -0
  34. package/web/dist/assets/{cose-bilkent-JH36ORCC-qhKoA2qj.js → cose-bilkent-JH36ORCC-Bv-gRVvA.js} +1 -1
  35. package/web/dist/assets/{cynefin-VYW2F7L2-jcyYelJS.js → cynefin-VYW2F7L2-DRnAsyZU.js} +1 -1
  36. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-D4RLufLP.js → cynefinDiagram-TSTJHNR4-B1fnUtjo.js} +1 -1
  37. package/web/dist/assets/{dagre-VKFMJZFB-C9BA3GTO.js → dagre-VKFMJZFB-DM6-QENB.js} +1 -1
  38. package/web/dist/assets/{diagram-FQU43EPY-DbSSrVUJ.js → diagram-FQU43EPY-BPxxnLdU.js} +1 -1
  39. package/web/dist/assets/{diagram-G47NLZAW-3egISQXQ.js → diagram-G47NLZAW-BgtJT3qF.js} +1 -1
  40. package/web/dist/assets/{diagram-NH7WQ7WH-8IKEMGRg.js → diagram-NH7WQ7WH-uekeIzsy.js} +1 -1
  41. package/web/dist/assets/{diagram-OA4YK3LP-VnaogqtK.js → diagram-OA4YK3LP-BVy3JZWE.js} +1 -1
  42. package/web/dist/assets/{diagram-WEI45ONY-DxigXH0T.js → diagram-WEI45ONY-BWdjUr32.js} +1 -1
  43. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-BSkNwJ9x.js → ebnfDiagram-CCIWWBDH-DD4fwhNK.js} +1 -1
  44. package/web/dist/assets/{erDiagram-Q63AITRT-CKN8JXl6.js → erDiagram-Q63AITRT-BllD_6Kh.js} +1 -1
  45. package/web/dist/assets/{flowDiagram-23GEKE2U-DDaD5ExP.js → flowDiagram-23GEKE2U-QEYKkAUN.js} +1 -1
  46. package/web/dist/assets/{ganttDiagram-NO4QXBWP-tOrBOuoI.js → ganttDiagram-NO4QXBWP-uZcfKTSW.js} +1 -1
  47. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-Cdp867ON.js → gitGraphDiagram-IHSO6WYX-XPdbPaH-.js} +1 -1
  48. package/web/dist/assets/index-BuDSoRIL.css +1 -0
  49. package/web/dist/assets/{index-BmwU43_u.js → index-D1itTxvB.js} +4 -4
  50. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DwcKHD2h.js → infoDiagram-FWYZ7A6U-DXCnQOJs.js} +1 -1
  51. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CWffK5kz.js → ishikawaDiagram-FXEZZL3T-687_4FBy.js} +1 -1
  52. package/web/dist/assets/{journeyDiagram-5HDEW3XC-BEAgS-EZ.js → journeyDiagram-5HDEW3XC-GXlKuda8.js} +1 -1
  53. package/web/dist/assets/{kanban-definition-HUTT4EX6-xDm-mRRj.js → kanban-definition-HUTT4EX6-C38-2DvN.js} +1 -1
  54. package/web/dist/assets/{linear-oP9MA0as.js → linear-lGPXiBcz.js} +1 -1
  55. package/web/dist/assets/{mermaid.core-DgXXt1OS.js → mermaid.core-C_neEtab.js} +4 -4
  56. package/web/dist/assets/{mindmap-definition-LN4V7U3C-BBQgVrYC.js → mindmap-definition-LN4V7U3C-BaJ9tpEG.js} +1 -1
  57. package/web/dist/assets/{papaparse.min-CR6JmBWm.js → papaparse.min-rDwKYbBf.js} +1 -1
  58. package/web/dist/assets/{pegDiagram-2B236MQR-C2O0cf3j.js → pegDiagram-2B236MQR-BdXJLYgY.js} +1 -1
  59. package/web/dist/assets/{pieDiagram-ENE6RG2P-D08pUtYU.js → pieDiagram-ENE6RG2P-DU19t6fM.js} +1 -1
  60. package/web/dist/assets/pilotswarm-BNKGG936.js +96 -0
  61. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-D-Z7YoEK.js → quadrantDiagram-ABIIQ3AL-CBkOdPp0.js} +1 -1
  62. package/web/dist/assets/{railroadDiagram-RFXS5EU6-ClkdveLI.js → railroadDiagram-RFXS5EU6-D2DMcGDw.js} +1 -1
  63. package/web/dist/assets/{requirementDiagram-TGXJPOKE-IY4xbn-Q.js → requirementDiagram-TGXJPOKE-Bx9tF1pr.js} +1 -1
  64. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-Cpt5i7KM.js → sankeyDiagram-HTMAVEWB-DcLlRfsi.js} +1 -1
  65. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-c07lALcz.js → sequenceDiagram-DBY2YBRQ-xr6UVRBO.js} +1 -1
  66. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CmCDiNbI.js → sizeCapture-X5ZJPWSS-0fUkSXmg.js} +1 -1
  67. package/web/dist/assets/{stateDiagram-2N3HPSRC-Dueu17zX.js → stateDiagram-2N3HPSRC-BWgJGGpg.js} +1 -1
  68. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DmAC8DdB.js +1 -0
  69. package/web/dist/assets/{swimlanes-5IMT3BWC-Csc2Fup8.js → swimlanes-5IMT3BWC-CZKya4_i.js} +2 -2
  70. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CVYx20_X.js +8 -0
  71. package/web/dist/assets/{timeline-definition-FHXFAJF6-4-lf702e.js → timeline-definition-FHXFAJF6-Df8BpeAu.js} +1 -1
  72. package/web/dist/assets/{vennDiagram-L72KCM5P-CmlaKpLI.js → vennDiagram-L72KCM5P-ChX5bjT6.js} +1 -1
  73. package/web/dist/assets/{wardleyDiagram-EHGQE667-B9Qe44pi.js → wardleyDiagram-EHGQE667-Dr48BgaJ.js} +1 -1
  74. package/web/dist/assets/{xychartDiagram-FW5EYKEG-BFcc0CLn.js → xychartDiagram-FW5EYKEG-Cia_MFfc.js} +1 -1
  75. package/web/dist/index.html +4 -4
  76. package/web/runtime.js +20 -1
  77. package/web/dist/assets/channel-5Ddqi9ib.js +0 -1
  78. package/web/dist/assets/classDiagram-OUVF2IWQ-B8u1fZiB.js +0 -1
  79. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B8u1fZiB.js +0 -1
  80. package/web/dist/assets/index-D-nA7gUe.css +0 -1
  81. package/web/dist/assets/pilotswarm-D7NLCbuc.js +0 -99
  82. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-AGpIV7wW.js +0 -1
  83. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTGZSV0N.js +0 -8
@@ -1,4 +1,5 @@
1
1
  import { formatHumanDurationSeconds, formatTimestamp, shortSessionId, stripTerminalMarkupTags, summarizeJson } from "./formatting.js";
2
+ import { isCanvasActionContent, parseCanvasActionContent } from "./canvas-actions.js";
2
3
  import { formatCompactionActivityRuns } from "./context-usage.js";
3
4
  import { canonicalSystemTitle } from "./system-titles.js";
4
5
 
@@ -30,6 +31,9 @@ export const CHAT_HISTORY_EVENT_TYPES = [
30
31
  // silently never flipped. Observed live — two attempts died on
31
32
  // ARTIFACT_TOO_LARGE with nothing shown in the transcript.
32
33
  "session.regenerate_failed",
34
+ // Canvas revisions — kept in backward chat paging so the TUI's artifact
35
+ // link line survives history loads (the portal skips the line either way).
36
+ "session.canvas_updated",
33
37
  ];
34
38
 
35
39
  // Build the inline transcript divider marking a session-regeneration epoch
@@ -379,6 +383,13 @@ function sharesClientMessageId(left, right) {
379
383
  function areMessagesEquivalent(left, right) {
380
384
  if (!left || !right) return false;
381
385
  if (left.role !== right.role) return false;
386
+ // Canvas revision lines are platform-minted, exactly-once (one durable
387
+ // event per rev), and share identical text by design (the artifact://
388
+ // link) — the redelivery time-window below would eat rev N-1 on every
389
+ // reload, making the transcript disagree with what live append showed.
390
+ if (left.kind === "canvas-update" || right.kind === "canvas-update") {
391
+ return left.kind === right.kind && left.id === right.id;
392
+ }
382
393
 
383
394
  const leftText = comparableMessageText(left);
384
395
  const rightText = comparableMessageText(right);
@@ -458,6 +469,33 @@ function buildChatMessage(event, role) {
458
469
  const sessionMessageCard = buildSessionMessageChatCard(event, rawText);
459
470
  if (sessionMessageCard) return sessionMessageCard;
460
471
 
472
+ // A structured canvas response — the browser sent it on the viewer's
473
+ // behalf after validating it against the drawn contract. One item shape
474
+ // from ONE builder, so bulk load and live append cannot disagree. The
475
+ // portal chat hides it (the redraw is the visible half of the loop);
476
+ // the TUI shows a compact line.
477
+ if (role === "user" && isCanvasActionContent(rawText)) {
478
+ const parsed = parseCanvasActionContent(rawText);
479
+ if (parsed) {
480
+ return {
481
+ id: `${event.sessionId}:${event.seq}`,
482
+ kind: "canvas-action",
483
+ role: "user",
484
+ action: parsed.action,
485
+ data: parsed.data,
486
+ text: rawText,
487
+ time: formatTimestamp(event.createdAt),
488
+ createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
489
+ // Without the ids, the distinct-ids rule can never fire and two
490
+ // genuine same-payload submissions within 10s collapse to one
491
+ // falsely-"redelivered" bubble while the activity feed shows two.
492
+ ...(Array.isArray(event?.data?.clientMessageIds) && event.data.clientMessageIds.length > 0
493
+ ? { clientMessageIds: event.data.clientMessageIds }
494
+ : {}),
495
+ };
496
+ }
497
+ }
498
+
461
499
  const text = stripLeadingSenderMarker(extractVisibleChatText(rawText, role));
462
500
  if (!hasVisibleMessageText(text)) return null;
463
501
  const clientMessageIds = Array.isArray(event?.data?.clientMessageIds)
@@ -711,6 +749,31 @@ function formatActivity(event) {
711
749
  break;
712
750
  }
713
751
 
752
+ case "session.canvas_updated": {
753
+ const data = (event?.data ?? {}) || {};
754
+ const rev = Number(data.rev) || 0;
755
+ const note = typeof data.note === "string" && data.note.trim() ? ` — ${data.note.trim()}` : "";
756
+ // Server-side artifact draws record their source; surfacing it here
757
+ // distinguishes "rendered the stored app" from an inline redraw the
758
+ // same honest way ticks are distinguished from draws.
759
+ const sourceName = typeof data.source?.filename === "string" && data.source.filename.trim()
760
+ ? ` from ${data.source.filename.trim()}`
761
+ : "";
762
+ runs = buildLabeledActivityRuns(time, "[canvas]", "cyan", `rev ${rev}${sourceName}${note}`, "white");
763
+ break;
764
+ }
765
+
766
+ case "session.canvas_data": {
767
+ // A data TICK, deliberately distinct from a full redraw: the page
768
+ // patched itself in place; no document was replaced.
769
+ const data = (event?.data ?? {}) || {};
770
+ const dataRev = Number(data.dataRev) || 0;
771
+ const kb = Number.isFinite(Number(data.sizeBytes)) ? ` (${(Number(data.sizeBytes) / 1024).toFixed(1)} KB)` : "";
772
+ const note = typeof data.note === "string" && data.note.trim() ? ` — ${data.note.trim()}` : "";
773
+ runs = buildLabeledActivityRuns(time, "[canvas]", "cyan", `data tick ${dataRev}${kb}${note}`, "white");
774
+ break;
775
+ }
776
+
714
777
  case "tool.execution_start":
715
778
  runs = formatToolActivityRuns(time, event, "start");
716
779
  break;
@@ -934,6 +997,43 @@ export function getNextHistoryEventLimit(currentLimit = DEFAULT_HISTORY_EVENT_LI
934
997
  return nextLimit || Math.min(100_000, safeCurrent * 2);
935
998
  }
936
999
 
1000
+ /**
1001
+ * The canvas event's chat item, shared verbatim by the bulk loader and the
1002
+ * live append path — a reload must not change the transcript. `text` carries
1003
+ * the bare artifact URI because the TUI's press-a picker extracts
1004
+ * `artifact://` links from message text; the rendered line builds its own
1005
+ * runs and never shows this string.
1006
+ */
1007
+ function buildCanvasChatItem(event) {
1008
+ const sessionId = event.sessionId || null;
1009
+ return {
1010
+ id: `${event.sessionId || ""}:${event.seq}:canvas`,
1011
+ kind: "canvas-update",
1012
+ role: "canvas-update",
1013
+ sessionId,
1014
+ rev: Number(event?.data?.rev) || 0,
1015
+ note: typeof event?.data?.note === "string" ? event.data.note : "",
1016
+ text: sessionId ? `artifact://${sessionId}/canvas.html` : "",
1017
+ createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
1018
+ };
1019
+ }
1020
+
1021
+ /** The diagnostic feed line for a validated canvas response. */
1022
+ function buildCanvasActionActivity(event, item) {
1023
+ const time = formatTimestamp(event.createdAt);
1024
+ const detail = `action ${item.action}${Object.keys(item.data || {}).length ? " " + summarizeJson(item.data) : ""}`;
1025
+ const runs = buildLabeledActivityRuns(time, "[canvas]", "cyan", detail, "white");
1026
+ return {
1027
+ id: `${event.sessionId}:${event.seq}:canvas-action`,
1028
+ eventType: event.eventType,
1029
+ time,
1030
+ seq: Number.isFinite(Number(event?.seq)) ? Number(event.seq) : 0,
1031
+ createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
1032
+ text: flattenRunsText(runs),
1033
+ line: runs,
1034
+ };
1035
+ }
1036
+
937
1037
  export function buildHistoryModel(events = [], options = {}) {
938
1038
  const requestedLimit = Math.max(
939
1039
  DEFAULT_HISTORY_EVENT_LIMIT,
@@ -949,6 +1049,23 @@ export function buildHistoryModel(events = [], options = {}) {
949
1049
  activity.push(...buildEmbeddedSystemNoticeActivityItems(event, "user"));
950
1050
  const message = buildChatMessage(event, "user");
951
1051
  if (message) chat.push(message);
1052
+ if (message?.kind === "canvas-action") {
1053
+ const actionActivity = buildCanvasActionActivity(event, message);
1054
+ if (actionActivity) activity.push(actionActivity);
1055
+ }
1056
+ continue;
1057
+ }
1058
+ if (event.eventType === "session.canvas_updated") {
1059
+ // A flagged chat item, not a plain message: the PORTAL chat skips
1060
+ // it entirely (the canvas pane updating is the signal there),
1061
+ // while the TUI — which cannot render the canvas — shows it as an
1062
+ // ordinary artifact link. Shared data, host-chosen affordance,
1063
+ // same precedent as the download hint. `text` carries the bare
1064
+ // artifact URI for the TUI's press-a picker, which extracts
1065
+ // `artifact://` links from message text.
1066
+ chat.push(buildCanvasChatItem(event));
1067
+ const activityItem = formatActivity(event);
1068
+ if (activityItem) activity.push(activityItem);
952
1069
  continue;
953
1070
  }
954
1071
  if (event.eventType === "assistant.message") {
@@ -1038,8 +1155,12 @@ export function appendEventToHistory(history, event) {
1038
1155
 
1039
1156
  if (event.eventType === "user.message") {
1040
1157
  next.activity.push(...buildEmbeddedSystemNoticeActivityItems(event, "user"));
1041
- next.activity = clampHistoryItems(next.activity, loadedEventLimit);
1042
1158
  const message = buildChatMessage(event, "user");
1159
+ if (message?.kind === "canvas-action") {
1160
+ const actionActivity = buildCanvasActionActivity(event, message);
1161
+ if (actionActivity) next.activity.push(actionActivity);
1162
+ }
1163
+ next.activity = clampHistoryItems(next.activity, loadedEventLimit);
1043
1164
  if (!message) return next;
1044
1165
  // Prospective flag: covers a bulk load where the stop event arrived
1045
1166
  // before this message in the reduce order.
@@ -1052,6 +1173,18 @@ export function appendEventToHistory(history, event) {
1052
1173
  next.chat = clampHistoryItems(dedupeChatMessages(next.chat), loadedEventLimit);
1053
1174
  return next;
1054
1175
  }
1176
+ if (event.eventType === "session.canvas_updated") {
1177
+ // Same flagged chat item the bulk loader builds — the live path and
1178
+ // the history path must agree or a reload would change the transcript.
1179
+ next.chat.push(buildCanvasChatItem(event));
1180
+ next.chat = clampHistoryItems(next.chat, loadedEventLimit);
1181
+ const canvasActivity = formatActivity(event);
1182
+ if (canvasActivity) {
1183
+ next.activity.push(canvasActivity);
1184
+ next.activity = clampHistoryItems(next.activity, loadedEventLimit);
1185
+ }
1186
+ return next;
1187
+ }
1055
1188
  if (event.eventType === "assistant.message") {
1056
1189
  next.activity.push(...buildEmbeddedSystemNoticeActivityItems(event, "assistant"));
1057
1190
  next.activity = clampHistoryItems(next.activity, loadedEventLimit);
@@ -13,3 +13,4 @@ export * from "./selectors.js";
13
13
  export * from "./controller.js";
14
14
  export { isThemeLight } from "./themes/helpers.js";
15
15
  export * from "./themes/index.js";
16
+ export { validateCanvasAction, formatCanvasActionPrompt, parseCanvasActionContent, isCanvasActionContent, createCanvasActionLimiter, CANVAS_ACTION_PREFIX } from "./canvas-actions.js";
@@ -8,6 +8,7 @@ import {
8
8
  normalizeArtifactEntries,
9
9
  normalizeSessionOwnerFilter,
10
10
  normalizeStoredActiveSessionId,
11
+ normalizeStoredCanvasPrefs,
11
12
  normalizeStoredCollapsedSessionIds,
12
13
  normalizeStoredLayoutAdjustments,
13
14
  normalizeStoredPinnedSessionIds,
@@ -87,6 +88,39 @@ function prunePinnedIds(ids, byId) {
87
88
  return out;
88
89
  }
89
90
 
91
+ /**
92
+ * Fold a freshly applied profile's canvasPrefs over the local map. Two rules:
93
+ *
94
+ * - `lastViewedRev` merges by MAX. A poll snapshotted before a local view and
95
+ * applied after must not regress what this window has already seen — same
96
+ * monotonicity the revision counter itself keeps.
97
+ * - Entries prune like pins: absent-from-listing proves nothing (the listing
98
+ * is paged), but a session PRESENT as a child or group can never have a
99
+ * canvas, and a default-valued entry ({optedOut:false, lastViewedRev:0})
100
+ * says nothing worth storing. Both drop, which is what keeps the map from
101
+ * growing one dead key per session forever.
102
+ */
103
+ function mergeCanvasPrefs(local, stored, byId) {
104
+ const out = {};
105
+ const keys = new Set([...Object.keys(stored || {}), ...Object.keys(local || {})]);
106
+ for (const sessionId of keys) {
107
+ const storedEntry = (stored || {})[sessionId];
108
+ const localEntry = (local || {})[sessionId];
109
+ const merged = {
110
+ // A local-only entry (viewed since the poll was snapshotted) must
111
+ // survive the apply — dropping it un-views what this window saw.
112
+ optedOut: storedEntry ? storedEntry.optedOut : Boolean(localEntry?.optedOut),
113
+ lastViewedRev: Math.max(storedEntry?.lastViewedRev || 0, localEntry?.lastViewedRev || 0),
114
+ lastViewedDataRev: Math.max(storedEntry?.lastViewedDataRev || 0, localEntry?.lastViewedDataRev || 0),
115
+ };
116
+ if (!merged.optedOut && merged.lastViewedRev === 0 && merged.lastViewedDataRev === 0) continue;
117
+ const session = byId?.[sessionId];
118
+ if (session && (session.parentSessionId || session.isGroup)) continue;
119
+ out[sessionId] = merged;
120
+ }
121
+ return out;
122
+ }
123
+
90
124
 
91
125
  /**
92
126
  * Keep a collapse flag unless the session is genuinely gone.
@@ -772,8 +806,9 @@ export function appReducer(state, action) {
772
806
  // say "rich" fall through to the transcript below.
773
807
  const hasTouchScale = Object.prototype.hasOwnProperty.call(settings, "touchScale")
774
808
  && typeof settings.touchScale === "boolean";
775
- const hasChatViewMode = Object.prototype.hasOwnProperty.call(settings, "chatViewMode")
776
- && (settings.chatViewMode === "summary" || settings.chatViewMode === "transcript");
809
+ const hasRightPaneMode = Object.prototype.hasOwnProperty.call(settings, "rightPaneMode")
810
+ && (settings.rightPaneMode === "canvas" || settings.rightPaneMode === "panes");
811
+ const hasCanvasPrefs = Object.prototype.hasOwnProperty.call(settings, "canvasPrefs");
777
812
  const hasPins = Object.prototype.hasOwnProperty.call(settings, "pinnedSessionIds");
778
813
  const hasCollapsed = Object.prototype.hasOwnProperty.call(settings, "collapsedSessionIds");
779
814
  const hasSessionOrder = Object.prototype.hasOwnProperty.call(settings, "sessionOrder");
@@ -836,28 +871,15 @@ export function appReducer(state, action) {
836
871
  ...selection.ui,
837
872
  themeId: hasTheme ? settings.themeId.trim() : selection.ui.themeId,
838
873
  touchScale: hasTouchScale ? settings.touchScale : selection.ui.touchScale,
839
- chatViewMode: hasChatViewMode ? settings.chatViewMode : selection.ui.chatViewMode,
874
+ rightPaneMode: hasRightPaneMode ? settings.rightPaneMode : selection.ui.rightPaneMode,
840
875
  layout: nextLayout,
841
876
  },
877
+ canvas: hasCanvasPrefs
878
+ ? { ...state.canvas, prefs: mergeCanvasPrefs(state.canvas.prefs, normalizeStoredCanvasPrefs(settings.canvasPrefs), hasLoadedSessions ? state.sessions.byId : null) }
879
+ : state.canvas,
842
880
  };
843
881
  }
844
882
 
845
- case "ui/chatViewMode":
846
- if (action.mode !== "summary" && action.mode !== "transcript") {
847
- return state;
848
- }
849
- return {
850
- ...state,
851
- ui: {
852
- ...state.ui,
853
- chatViewMode: action.mode,
854
- scroll: {
855
- ...state.ui.scroll,
856
- chat: 0,
857
- },
858
- },
859
- };
860
-
861
883
  case "ui/modal":
862
884
  return {
863
885
  ...state,
@@ -2587,6 +2609,174 @@ export function appReducer(state, action) {
2587
2609
  },
2588
2610
  };
2589
2611
 
2612
+ // ── Canvas ──────────────────────────────────────────────────────
2613
+ // The right column's mode plus the per-session attention state that
2614
+ // decides flip-vs-badge. Viewed-marking deliberately does NOT live in
2615
+ // these mode transitions: only the rendering surface knows what is
2616
+ // actually on screen, so `canvas/viewed` is dispatched by the canvas
2617
+ // pane when a revision really displays. Inferring "viewed" from mode
2618
+ // changes marked revs seen on a phone whose layout never showed the
2619
+ // pane, and left session-switches-while-in-canvas-mode unviewed.
2620
+ // - entering canvas mode is always a user gesture → clears opt-out
2621
+ // - a MANUAL switch away records the opt-out (future draws badge)
2622
+ // - `canvas/flip` is the agent-driven variant: same mode change,
2623
+ // plus a flipSeq tick that hosts with their own pane state (the
2624
+ // phone's tab strip) listen to
2625
+ case "ui/rightPaneMode": {
2626
+ const mode = action.mode === "canvas" ? "canvas" : "panes";
2627
+ const sessionId = action.sessionId || state.sessions.activeSessionId || null;
2628
+ let prefs = state.canvas.prefs;
2629
+ if (sessionId) {
2630
+ const prev = prefs[sessionId] || { optedOut: false, lastViewedRev: 0 };
2631
+ if (mode === "canvas" && prev.optedOut) {
2632
+ // Coming back by hand un-opts-out: the user has shown the
2633
+ // canvas is welcome again, so future draws may flip.
2634
+ prefs = { ...prefs, [sessionId]: { ...prev, optedOut: false } };
2635
+ } else if (mode === "panes" && action.manual && !prev.optedOut) {
2636
+ prefs = { ...prefs, [sessionId]: { ...prev, optedOut: true } };
2637
+ }
2638
+ }
2639
+ return {
2640
+ ...state,
2641
+ ui: { ...state.ui, rightPaneMode: mode },
2642
+ canvas: { ...state.canvas, prefs },
2643
+ };
2644
+ }
2645
+
2646
+ // The agent-driven flip. The guards (active session, freshness,
2647
+ // opt-out) live in the controller; by the time this dispatches the
2648
+ // flip is decided. flipSeq ticks even when the mode is already
2649
+ // "canvas" — a phone can be off the canvas tab while the persisted
2650
+ // mode still says canvas, and the tick is what brings its tab back.
2651
+ case "canvas/flip": {
2652
+ return {
2653
+ ...state,
2654
+ ui: { ...state.ui, rightPaneMode: "canvas" },
2655
+ canvas: { ...state.canvas, flipSeq: (state.canvas.flipSeq || 0) + 1 },
2656
+ };
2657
+ }
2658
+
2659
+ // The surface's receipt: revision `rev` (a draw) and/or `dataRev` (a
2660
+ // tick) actually rendered on screen. Both feed the unseen badge.
2661
+ case "canvas/viewed": {
2662
+ const sessionId = String(action.sessionId || "").trim();
2663
+ if (!sessionId) return state;
2664
+ const rev = Number(action.rev);
2665
+ const dataRev = Number(action.dataRev);
2666
+ const prev = state.canvas.prefs[sessionId] || { optedOut: false, lastViewedRev: 0, lastViewedDataRev: 0 };
2667
+ const nextRev = Number.isFinite(rev) && rev > (prev.lastViewedRev || 0) ? rev : (prev.lastViewedRev || 0);
2668
+ const nextDataRev = Number.isFinite(dataRev) && dataRev > (prev.lastViewedDataRev || 0) ? dataRev : (prev.lastViewedDataRev || 0);
2669
+ if (nextRev === (prev.lastViewedRev || 0) && nextDataRev === (prev.lastViewedDataRev || 0)) return state;
2670
+ return {
2671
+ ...state,
2672
+ canvas: {
2673
+ ...state.canvas,
2674
+ prefs: { ...state.canvas.prefs, [sessionId]: { ...prev, lastViewedRev: nextRev, lastViewedDataRev: nextDataRev } },
2675
+ },
2676
+ };
2677
+ }
2678
+
2679
+ case "canvas/updated": {
2680
+ const sessionId = String(action.sessionId || "").trim();
2681
+ const rev = Number(action.rev);
2682
+ if (!sessionId || !Number.isFinite(rev) || rev <= 0) return state;
2683
+ const existing = state.canvas.bySessionId[sessionId] || {};
2684
+ // Monotonic: replays and out-of-order merges must never regress
2685
+ // the known revision (freshness gates the FLIP, never the data).
2686
+ if ((existing.latestRev || 0) >= rev) return state;
2687
+ return {
2688
+ ...state,
2689
+ canvas: {
2690
+ ...state.canvas,
2691
+ bySessionId: {
2692
+ ...state.canvas.bySessionId,
2693
+ [sessionId]: {
2694
+ ...existing,
2695
+ latestRev: rev,
2696
+ note: typeof action.note === "string" ? action.note : "",
2697
+ sizeBytes: Number.isFinite(Number(action.sizeBytes)) ? Number(action.sizeBytes) : null,
2698
+ // The contract belongs to the revision: a draw
2699
+ // without one REVOKES interactivity (default
2700
+ // closed), so no carry-over from the previous rev.
2701
+ responseContract: action.responseContract && typeof action.responseContract === "object"
2702
+ ? action.responseContract
2703
+ : null,
2704
+ },
2705
+ },
2706
+ },
2707
+ };
2708
+ }
2709
+
2710
+ // A data tick: content for the page, in place. Never flips (ticks do
2711
+ // not interrupt) — but it DOES count toward the unseen badge via
2712
+ // latestDataRev vs lastViewedDataRev. Monotonic like the draw rev.
2713
+ case "canvas/data": {
2714
+ const sessionId = String(action.sessionId || "").trim();
2715
+ const dataRev = Number(action.dataRev);
2716
+ if (!sessionId || !Number.isFinite(dataRev) || dataRev <= 0) return state;
2717
+ const existing = state.canvas.bySessionId[sessionId] || {};
2718
+ if ((existing.latestDataRev || 0) >= dataRev) return state;
2719
+ return {
2720
+ ...state,
2721
+ canvas: {
2722
+ ...state.canvas,
2723
+ bySessionId: {
2724
+ ...state.canvas.bySessionId,
2725
+ [sessionId]: {
2726
+ ...existing,
2727
+ latestDataRev: dataRev,
2728
+ dataPayload: action.payload && typeof action.payload === "object" ? action.payload : null,
2729
+ },
2730
+ },
2731
+ },
2732
+ };
2733
+ }
2734
+
2735
+ case "canvas/snapshot": {
2736
+ const sessionId = String(action.sessionId || "").trim();
2737
+ if (!sessionId) return state;
2738
+ const existing = state.canvas.bySessionId[sessionId] || {};
2739
+ const rev = Number(action.rev) || 0;
2740
+ return {
2741
+ ...state,
2742
+ canvas: {
2743
+ ...state.canvas,
2744
+ bySessionId: {
2745
+ ...state.canvas.bySessionId,
2746
+ [sessionId]: {
2747
+ ...existing,
2748
+ // Events may have beaten the snapshot; never regress.
2749
+ latestRev: Math.max(existing.latestRev || 0, rev),
2750
+ note: (existing.latestRev || 0) > rev ? existing.note : (typeof action.note === "string" ? action.note : existing.note || ""),
2751
+ sizeBytes: (existing.latestRev || 0) > rev ? existing.sizeBytes : (Number.isFinite(Number(action.sizeBytes)) ? Number(action.sizeBytes) : existing.sizeBytes ?? null),
2752
+ responseContract: (existing.latestRev || 0) > rev
2753
+ ? (existing.responseContract ?? null)
2754
+ : (action.responseContract && typeof action.responseContract === "object" ? action.responseContract : null),
2755
+ snapshotLoaded: true,
2756
+ },
2757
+ },
2758
+ },
2759
+ };
2760
+ }
2761
+
2762
+ // Continuity break (windowed bulk reload replaced gap-free replay):
2763
+ // the memoized snapshot can no longer be trusted — re-take on next need.
2764
+ case "canvas/snapshotInvalidate": {
2765
+ const sessionId = String(action.sessionId || "").trim();
2766
+ const existing = state.canvas.bySessionId[sessionId];
2767
+ if (!sessionId || !existing?.snapshotLoaded) return state;
2768
+ return {
2769
+ ...state,
2770
+ canvas: {
2771
+ ...state.canvas,
2772
+ bySessionId: {
2773
+ ...state.canvas.bySessionId,
2774
+ [sessionId]: { ...existing, snapshotLoaded: false },
2775
+ },
2776
+ },
2777
+ };
2778
+ }
2779
+
2590
2780
  case "files/htmlViewMode":
2591
2781
  return {
2592
2782
  ...state,