pilotswarm 0.5.36 → 0.5.38

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 (86) hide show
  1. package/package.json +3 -3
  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 +296 -21
  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 +407 -19
  10. package/ui/core/src/selectors.js +223 -154
  11. package/ui/core/src/state.js +139 -2
  12. package/ui/core/src/themes/index.js +1 -1
  13. package/ui/react/src/components.js +1 -7
  14. package/ui/react/src/web-app.js +1719 -366
  15. package/web/auth/config.js +3 -0
  16. package/web/auth/index.js +8 -1
  17. package/web/auth/normalize/proxy.js +48 -0
  18. package/web/auth/providers/proxy.js +141 -0
  19. package/web/dist/assets/{abnfDiagram-VRR7QNED-LOLCtsns.js → abnfDiagram-VRR7QNED-DrJhXhSQ.js} +1 -1
  20. package/web/dist/assets/{arc-BE-jVDEi.js → arc-BEaG9DWd.js} +1 -1
  21. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CdmUjwtj.js → architectureDiagram-ZJ3FMSHR-D9fc1Imx.js} +1 -1
  22. package/web/dist/assets/{blockDiagram-677ZJIJ3-NZ4TOa8L.js → blockDiagram-677ZJIJ3-rS5Eikvt.js} +1 -1
  23. package/web/dist/assets/{c4Diagram-LMCZKHZV-9ubX8uGA.js → c4Diagram-LMCZKHZV-HKLlX5cR.js} +1 -1
  24. package/web/dist/assets/channel-DRy7FvyF.js +1 -0
  25. package/web/dist/assets/{chunk-2Q5K7J3B-DNDnjbP6.js → chunk-2Q5K7J3B-kq0TyLSB.js} +1 -1
  26. package/web/dist/assets/{chunk-32BRIVSS-iilfiffr.js → chunk-32BRIVSS-O-LMarT5.js} +1 -1
  27. package/web/dist/assets/{chunk-5VM5RSS4-BakOMFmf.js → chunk-5VM5RSS4-Di4h0CAu.js} +1 -1
  28. package/web/dist/assets/{chunk-EX3LRPZG-BhluhGyZ.js → chunk-EX3LRPZG-C8rgqUOt.js} +1 -1
  29. package/web/dist/assets/{chunk-JWPE2WC7-B6GKLlNb.js → chunk-JWPE2WC7-BSH41q4T.js} +1 -1
  30. package/web/dist/assets/{chunk-MOJQB5TN-CXmt8msF.js → chunk-MOJQB5TN-BAFuQHAC.js} +1 -1
  31. package/web/dist/assets/{chunk-RYQCIY6F-Bu58-SDr.js → chunk-RYQCIY6F-DAg6JOs7.js} +1 -1
  32. package/web/dist/assets/{chunk-V7JOEXUC-AQdyoYHL.js → chunk-V7JOEXUC-DbVrT8BG.js} +1 -1
  33. package/web/dist/assets/{chunk-VR4S4FIN-DeM5iMsE.js → chunk-VR4S4FIN-C6f5_Grp.js} +1 -1
  34. package/web/dist/assets/{chunk-XXDRQBXY-DjGjajy1.js → chunk-XXDRQBXY-CqcCYpqA.js} +1 -1
  35. package/web/dist/assets/classDiagram-OUVF2IWQ-C35RhB8X.js +1 -0
  36. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C35RhB8X.js +1 -0
  37. package/web/dist/assets/{cose-bilkent-JH36ORCC-BGi4_egJ.js → cose-bilkent-JH36ORCC-DJw04eFh.js} +1 -1
  38. package/web/dist/assets/{cynefin-VYW2F7L2-CcgV1ve4.js → cynefin-VYW2F7L2-CpjwdEIU.js} +1 -1
  39. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BRDEsbBG.js → cynefinDiagram-TSTJHNR4-RqEIWoT0.js} +1 -1
  40. package/web/dist/assets/{dagre-VKFMJZFB-OjuC8dJB.js → dagre-VKFMJZFB-C1eGQNK2.js} +1 -1
  41. package/web/dist/assets/{diagram-FQU43EPY-CGdROWYh.js → diagram-FQU43EPY-BODLQ7dz.js} +1 -1
  42. package/web/dist/assets/{diagram-G47NLZAW-B5J0EUfS.js → diagram-G47NLZAW-DlhmukMC.js} +1 -1
  43. package/web/dist/assets/{diagram-NH7WQ7WH-zmb4-cP2.js → diagram-NH7WQ7WH-Ba_jZoCN.js} +1 -1
  44. package/web/dist/assets/{diagram-OA4YK3LP-j514bgaH.js → diagram-OA4YK3LP-DnSiAJBG.js} +1 -1
  45. package/web/dist/assets/{diagram-WEI45ONY-h5G459mZ.js → diagram-WEI45ONY-B3KoQ7_W.js} +1 -1
  46. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-GnMgbuFk.js → ebnfDiagram-CCIWWBDH-ChnFc5gf.js} +1 -1
  47. package/web/dist/assets/{erDiagram-Q63AITRT-BFgH3UQp.js → erDiagram-Q63AITRT-D2KkJXLZ.js} +1 -1
  48. package/web/dist/assets/{flowDiagram-23GEKE2U-BsbQ_VXV.js → flowDiagram-23GEKE2U-CmybZufj.js} +1 -1
  49. package/web/dist/assets/{ganttDiagram-NO4QXBWP-C_fjo2sf.js → ganttDiagram-NO4QXBWP-BzPurW_v.js} +1 -1
  50. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BUoXrAI9.js → gitGraphDiagram-IHSO6WYX-DAbXl4gO.js} +1 -1
  51. package/web/dist/assets/{index-ONJimzcx.js → index-CRY94nFp.js} +6 -6
  52. package/web/dist/assets/index-DtL0lbis.css +1 -0
  53. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CJiSANyk.js → infoDiagram-FWYZ7A6U-C6qGjNA1.js} +1 -1
  54. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-BKoQ-a2c.js → ishikawaDiagram-FXEZZL3T-D2d1tO-9.js} +1 -1
  55. package/web/dist/assets/{journeyDiagram-5HDEW3XC-Db6Crcd3.js → journeyDiagram-5HDEW3XC-fP13w1b7.js} +1 -1
  56. package/web/dist/assets/{kanban-definition-HUTT4EX6-B9GaB0UJ.js → kanban-definition-HUTT4EX6-BJfWAXZ0.js} +1 -1
  57. package/web/dist/assets/{linear-BXwRb8MN.js → linear-CltApkHf.js} +1 -1
  58. package/web/dist/assets/{mermaid.core-DwzXN6up.js → mermaid.core-B1Dwetk3.js} +4 -4
  59. package/web/dist/assets/{mindmap-definition-LN4V7U3C-DKC_C_OY.js → mindmap-definition-LN4V7U3C-CdRUD7LF.js} +1 -1
  60. package/web/dist/assets/{papaparse.min-Cm1Pr54l.js → papaparse.min-C1y3wBH1.js} +1 -1
  61. package/web/dist/assets/{pegDiagram-2B236MQR-IeawbZPt.js → pegDiagram-2B236MQR-CeQkqN6Q.js} +1 -1
  62. package/web/dist/assets/{pieDiagram-ENE6RG2P-BOdsYBt8.js → pieDiagram-ENE6RG2P-Dqgc87jA.js} +1 -1
  63. package/web/dist/assets/pilotswarm-DayYdi39.js +96 -0
  64. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cr5yoN7C.js → quadrantDiagram-ABIIQ3AL-CD7f-w6m.js} +1 -1
  65. package/web/dist/assets/{railroadDiagram-RFXS5EU6-E7wuFBuk.js → railroadDiagram-RFXS5EU6-CcsAijSC.js} +1 -1
  66. package/web/dist/assets/{requirementDiagram-TGXJPOKE-sZCKiuDs.js → requirementDiagram-TGXJPOKE-BkzF80pL.js} +1 -1
  67. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-B_EWziUR.js → sankeyDiagram-HTMAVEWB-D9XOFw7Y.js} +1 -1
  68. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-D2mN7onX.js → sequenceDiagram-DBY2YBRQ-BFfaS_Sz.js} +1 -1
  69. package/web/dist/assets/{sizeCapture-X5ZJPWSS-CEL9Ly2T.js → sizeCapture-X5ZJPWSS-CDJV16tP.js} +1 -1
  70. package/web/dist/assets/{stateDiagram-2N3HPSRC-C7ejJoeN.js → stateDiagram-2N3HPSRC-C0E-MPXO.js} +1 -1
  71. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BMqXeMJu.js +1 -0
  72. package/web/dist/assets/{swimlanes-5IMT3BWC-Ck3pbFxk.js → swimlanes-5IMT3BWC-n5Vsv7n9.js} +2 -2
  73. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CGl4gRnH.js +8 -0
  74. package/web/dist/assets/{timeline-definition-FHXFAJF6-K3H8Yuyr.js → timeline-definition-FHXFAJF6-DW2gV8-n.js} +1 -1
  75. package/web/dist/assets/{vennDiagram-L72KCM5P-C3NKjSv9.js → vennDiagram-L72KCM5P-D9dbDm08.js} +1 -1
  76. package/web/dist/assets/{wardleyDiagram-EHGQE667-CeeVuFiO.js → wardleyDiagram-EHGQE667-CIcG7fiC.js} +1 -1
  77. package/web/dist/assets/{xychartDiagram-FW5EYKEG-CF-vCvEq.js → xychartDiagram-FW5EYKEG-elEIFsc9.js} +1 -1
  78. package/web/dist/index.html +4 -4
  79. package/web/runtime.js +20 -1
  80. package/web/dist/assets/channel-BhHZnKdH.js +0 -1
  81. package/web/dist/assets/classDiagram-OUVF2IWQ-CM_bKBKz.js +0 -1
  82. package/web/dist/assets/classDiagram-v2-EOCWNBFH-CM_bKBKz.js +0 -1
  83. package/web/dist/assets/index-CnH1Yv_d.css +0 -1
  84. package/web/dist/assets/pilotswarm-kCQFVjJm.js +0 -99
  85. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-UaVDt_gU.js +0 -1
  86. package/web/dist/assets/swimlanesDiagram-G3AALYLV-CqO_fF5t.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,7 +8,11 @@ import {
8
8
  normalizeArtifactEntries,
9
9
  normalizeSessionOwnerFilter,
10
10
  normalizeStoredActiveSessionId,
11
+ normalizeStoredCanvasPrefs,
12
+ canvasKey,
13
+ parseCanvasKey,
11
14
  normalizeStoredCollapsedSessionIds,
15
+ normalizeStoredDesktopPanes,
12
16
  normalizeStoredLayoutAdjustments,
13
17
  normalizeStoredPinnedSessionIds,
14
18
  normalizeStoredSessionOrder,
@@ -87,6 +91,44 @@ function prunePinnedIds(ids, byId) {
87
91
  return out;
88
92
  }
89
93
 
94
+ /**
95
+ * Fold a freshly applied profile's canvasPrefs over the local map. Two rules:
96
+ *
97
+ * - `lastViewedRev` merges by MAX. A poll snapshotted before a local view and
98
+ * applied after must not regress what this window has already seen — same
99
+ * monotonicity the revision counter itself keeps.
100
+ * - Entries prune like pins: absent-from-listing proves nothing (the listing
101
+ * is paged), but a session PRESENT as a child or group can never have a
102
+ * canvas, and a default-valued entry ({optedOut:false, lastViewedRev:0})
103
+ * says nothing worth storing. Both drop, which is what keeps the map from
104
+ * growing one dead key per session forever.
105
+ */
106
+ function mergeCanvasPrefs(local, stored, byId) {
107
+ const out = {};
108
+ const keys = new Set([...Object.keys(stored || {}), ...Object.keys(local || {})]);
109
+ for (const sessionId of keys) {
110
+ const storedEntry = (stored || {})[sessionId];
111
+ const localEntry = (local || {})[sessionId];
112
+ const merged = {
113
+ // A local-only entry (viewed since the poll was snapshotted) must
114
+ // survive the apply — dropping it un-views what this window saw.
115
+ optedOut: storedEntry ? storedEntry.optedOut : Boolean(localEntry?.optedOut),
116
+ lastViewedRev: Math.max(storedEntry?.lastViewedRev || 0, localEntry?.lastViewedRev || 0),
117
+ lastViewedDataRev: Math.max(storedEntry?.lastViewedDataRev || 0, localEntry?.lastViewedDataRev || 0),
118
+ // Latest write wins is unknowable here; the local value is the one
119
+ // this window just dragged, so it outranks the poll.
120
+ ...((localEntry?.zenRailPx || storedEntry?.zenRailPx)
121
+ ? { zenRailPx: localEntry?.zenRailPx || storedEntry?.zenRailPx }
122
+ : {}),
123
+ };
124
+ if (!merged.optedOut && merged.lastViewedRev === 0 && merged.lastViewedDataRev === 0 && !merged.zenRailPx) continue;
125
+ const session = byId?.[parseCanvasKey(sessionId).sessionId];
126
+ if (session && session.isGroup) continue;
127
+ out[sessionId] = merged;
128
+ }
129
+ return out;
130
+ }
131
+
90
132
 
91
133
  /**
92
134
  * Keep a collapse flag unless the session is genuinely gone.
@@ -772,8 +814,20 @@ export function appReducer(state, action) {
772
814
  // say "rich" fall through to the transcript below.
773
815
  const hasTouchScale = Object.prototype.hasOwnProperty.call(settings, "touchScale")
774
816
  && typeof settings.touchScale === "boolean";
775
- const hasChatViewMode = Object.prototype.hasOwnProperty.call(settings, "chatViewMode")
776
- && (settings.chatViewMode === "summary" || settings.chatViewMode === "transcript");
817
+ const hasRightPaneMode = Object.prototype.hasOwnProperty.call(settings, "rightPaneMode")
818
+ && (settings.rightPaneMode === "canvas" || settings.rightPaneMode === "panes");
819
+ // The two independent columns. A profile written by a build that
820
+ // predates them carries only rightPaneMode, so fall back to
821
+ // migrating that — see normalizeStoredDesktopPanes.
822
+ const hasDesktopPanes = Object.prototype.hasOwnProperty.call(settings, "desktopPanes")
823
+ && settings.desktopPanes && typeof settings.desktopPanes === "object";
824
+ const nextDesktopPanes = (hasDesktopPanes || hasRightPaneMode)
825
+ ? normalizeStoredDesktopPanes(
826
+ hasDesktopPanes ? settings.desktopPanes : null,
827
+ hasRightPaneMode ? settings.rightPaneMode : null,
828
+ )
829
+ : null;
830
+ const hasCanvasPrefs = Object.prototype.hasOwnProperty.call(settings, "canvasPrefs");
777
831
  const hasPins = Object.prototype.hasOwnProperty.call(settings, "pinnedSessionIds");
778
832
  const hasCollapsed = Object.prototype.hasOwnProperty.call(settings, "collapsedSessionIds");
779
833
  const hasSessionOrder = Object.prototype.hasOwnProperty.call(settings, "sessionOrder");
@@ -836,28 +890,18 @@ export function appReducer(state, action) {
836
890
  ...selection.ui,
837
891
  themeId: hasTheme ? settings.themeId.trim() : selection.ui.themeId,
838
892
  touchScale: hasTouchScale ? settings.touchScale : selection.ui.touchScale,
839
- chatViewMode: hasChatViewMode ? settings.chatViewMode : selection.ui.chatViewMode,
893
+ rightPaneMode: hasRightPaneMode ? settings.rightPaneMode : selection.ui.rightPaneMode,
894
+ ...(nextDesktopPanes
895
+ ? { canvasOpen: nextDesktopPanes.canvasOpen, diagnosticsOpen: nextDesktopPanes.diagnosticsOpen, canvasZen: nextDesktopPanes.zen === true }
896
+ : {}),
840
897
  layout: nextLayout,
841
898
  },
899
+ canvas: hasCanvasPrefs
900
+ ? { ...state.canvas, prefs: mergeCanvasPrefs(state.canvas.prefs, normalizeStoredCanvasPrefs(settings.canvasPrefs), hasLoadedSessions ? state.sessions.byId : null) }
901
+ : state.canvas,
842
902
  };
843
903
  }
844
904
 
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
905
  case "ui/modal":
862
906
  return {
863
907
  ...state,
@@ -924,6 +968,46 @@ export function appReducer(state, action) {
924
968
  },
925
969
  };
926
970
 
971
+ // Where the canvas/diagnostics seam sits when both columns are open.
972
+ // A pixel delta from an even split of the right side, like the other
973
+ // adjusts. Closing a column is a separate action — see
974
+ // CanvasDiagnosticsResizeHandle, which decides that on release.
975
+ case "ui/canvasPaneAdjust":
976
+ return {
977
+ ...state,
978
+ ui: {
979
+ ...state.ui,
980
+ layout: {
981
+ ...(state.ui.layout || {}),
982
+ canvasPaneAdjust: Number(action.canvasPaneAdjust) || 0,
983
+ },
984
+ },
985
+ };
986
+
987
+ case "ui/diagnosticsSplitAdjust":
988
+ return {
989
+ ...state,
990
+ ui: {
991
+ ...state.ui,
992
+ layout: {
993
+ ...(state.ui.layout || {}),
994
+ diagnosticsSplitAdjust: Number(action.diagnosticsSplitAdjust) || 0,
995
+ },
996
+ },
997
+ };
998
+
999
+ case "ui/diagnosticsPaneAdjust":
1000
+ return {
1001
+ ...state,
1002
+ ui: {
1003
+ ...state.ui,
1004
+ layout: {
1005
+ ...(state.ui.layout || {}),
1006
+ diagnosticsPaneAdjust: Number(action.diagnosticsPaneAdjust) || 0,
1007
+ },
1008
+ },
1009
+ };
1010
+
927
1011
  case "ui/activityPaneAdjust":
928
1012
  return {
929
1013
  ...state,
@@ -2587,6 +2671,310 @@ export function appReducer(state, action) {
2587
2671
  },
2588
2672
  };
2589
2673
 
2674
+ // ── Canvas ──────────────────────────────────────────────────────
2675
+ // The right column's mode plus the per-session attention state that
2676
+ // decides flip-vs-badge. Viewed-marking deliberately does NOT live in
2677
+ // these mode transitions: only the rendering surface knows what is
2678
+ // actually on screen, so `canvas/viewed` is dispatched by the canvas
2679
+ // pane when a revision really displays. Inferring "viewed" from mode
2680
+ // changes marked revs seen on a phone whose layout never showed the
2681
+ // pane, and left session-switches-while-in-canvas-mode unviewed.
2682
+ // - entering canvas mode is always a user gesture → clears opt-out
2683
+ // - a MANUAL switch away records the opt-out (future draws badge)
2684
+ // - `canvas/flip` is the agent-driven variant: same mode change,
2685
+ // plus a flipSeq tick that hosts with their own pane state (the
2686
+ // phone's tab strip) listen to
2687
+ case "ui/rightPaneMode": {
2688
+ const mode = action.mode === "canvas" ? "canvas" : "panes";
2689
+ const sessionId = action.sessionId || state.sessions.activeSessionId || null;
2690
+ let prefs = state.canvas.prefs;
2691
+ if (sessionId) {
2692
+ const prev = prefs[sessionId] || { optedOut: false, lastViewedRev: 0 };
2693
+ if (mode === "canvas" && prev.optedOut) {
2694
+ // Coming back by hand un-opts-out: the user has shown the
2695
+ // canvas is welcome again, so future draws may flip.
2696
+ prefs = { ...prefs, [sessionId]: { ...prev, optedOut: false } };
2697
+ } else if (mode === "panes" && action.manual && !prev.optedOut) {
2698
+ prefs = { ...prefs, [sessionId]: { ...prev, optedOut: true } };
2699
+ }
2700
+ }
2701
+ return {
2702
+ ...state,
2703
+ ui: { ...state.ui, rightPaneMode: mode, canvasOpen: mode === "canvas" },
2704
+ canvas: { ...state.canvas, prefs },
2705
+ };
2706
+ }
2707
+
2708
+ // Canvas as a column of its own. Same opt-out bookkeeping as the old
2709
+ // mode switch above — closing by hand still says "do not flip me back",
2710
+ // opening by hand still withdraws that — but closing the canvas no
2711
+ // longer implies showing anything else, because Diagnostics is now a
2712
+ // separate column with its own toggle.
2713
+ //
2714
+ // `open` may be omitted to mean "toggle".
2715
+ case "ui/canvasOpen": {
2716
+ const open = typeof action.open === "boolean" ? action.open : !state.ui.canvasOpen;
2717
+ const sessionId = action.sessionId || state.sessions.activeSessionId || null;
2718
+ let prefs = state.canvas.prefs;
2719
+ if (sessionId) {
2720
+ const prev = prefs[sessionId] || { optedOut: false, lastViewedRev: 0 };
2721
+ if (open && prev.optedOut) {
2722
+ prefs = { ...prefs, [sessionId]: { ...prev, optedOut: false } };
2723
+ } else if (!open && action.manual && !prev.optedOut) {
2724
+ prefs = { ...prefs, [sessionId]: { ...prev, optedOut: true } };
2725
+ }
2726
+ }
2727
+ return {
2728
+ ...state,
2729
+ // rightPaneMode is kept in step for anything still reading it.
2730
+ // Closing always drops full screen: otherwise the next open
2731
+ // comes back covering the workspace with no warning.
2732
+ ui: {
2733
+ ...state.ui,
2734
+ canvasOpen: open,
2735
+ rightPaneMode: open ? "canvas" : "panes",
2736
+ ...(open ? {} : { canvasMaximized: false, canvasZen: false }),
2737
+ },
2738
+ canvas: { ...state.canvas, prefs },
2739
+ };
2740
+ }
2741
+
2742
+ // Diagnostics — today's Inspector + Activity, travelling as one column.
2743
+ // No canvas bookkeeping: it has nothing to do with the canvas, which is
2744
+ // the whole point of splitting the old enum in two.
2745
+ // Zen: sessions hidden, chat a narrow rail, canvas the workbench.
2746
+ // Turning it on opens the canvas; it means nothing without one.
2747
+ case "ui/canvasZen": {
2748
+ const on = typeof action.on === "boolean" ? action.on : !state.ui.canvasZen;
2749
+ if (on === Boolean(state.ui.canvasZen)) return state;
2750
+ return {
2751
+ ...state,
2752
+ ui: { ...state.ui, canvasZen: on, ...(on ? { canvasOpen: true, rightPaneMode: "canvas" } : {}) },
2753
+ };
2754
+ }
2755
+
2756
+ // The zen chat-rail width for one session. Clamped so a wild drag can
2757
+ // never wedge the rail off screen or crush the canvas.
2758
+ case "canvas/zenRail": {
2759
+ const sessionId = String(action.sessionId || "").trim();
2760
+ const px = Number(action.px);
2761
+ if (!sessionId || !Number.isFinite(px)) return state;
2762
+ const prev = state.canvas.prefs[sessionId] || { optedOut: false, lastViewedRev: 0 };
2763
+ const clamped = Math.max(260, Math.min(720, Math.round(px)));
2764
+ return {
2765
+ ...state,
2766
+ canvas: { ...state.canvas, prefs: { ...state.canvas.prefs, [sessionId]: { ...prev, zenRailPx: clamped } } },
2767
+ };
2768
+ }
2769
+
2770
+ // Full-screen canvas. The header stays put and the canvas covers the
2771
+ // workspace below it, so there is always a way back.
2772
+ case "ui/canvasMaximized": {
2773
+ const on = typeof action.on === "boolean" ? action.on : !state.ui.canvasMaximized;
2774
+ if (on === Boolean(state.ui.canvasMaximized)) return state;
2775
+ return { ...state, ui: { ...state.ui, canvasMaximized: on } };
2776
+ }
2777
+
2778
+ case "ui/diagnosticsOpen": {
2779
+ const open = typeof action.open === "boolean" ? action.open : !state.ui.diagnosticsOpen;
2780
+ if (open === state.ui.diagnosticsOpen) return state;
2781
+ return { ...state, ui: { ...state.ui, diagnosticsOpen: open } };
2782
+ }
2783
+
2784
+ // The agent-driven flip. The guards (active session, freshness,
2785
+ // opt-out) live in the controller; by the time this dispatches the
2786
+ // flip is decided. flipSeq ticks even when the mode is already
2787
+ // "canvas" — a phone can be off the canvas tab while the persisted
2788
+ // mode still says canvas, and the tick is what brings its tab back.
2789
+ case "canvas/flip": {
2790
+ const slot = Number.isInteger(Number(action.slot)) && Number(action.slot) >= 1 && Number(action.slot) <= 5
2791
+ ? Number(action.slot) : 1;
2792
+ return {
2793
+ ...state,
2794
+ // Opens the canvas column ON THE SLOT THAT DREW; leaves
2795
+ // Diagnostics exactly as the user left it. An agent drawing
2796
+ // must not close their panels.
2797
+ ui: { ...state.ui, rightPaneMode: "canvas", canvasOpen: true, canvasSlot: slot },
2798
+ canvas: { ...state.canvas, flipSeq: (state.canvas.flipSeq || 0) + 1 },
2799
+ };
2800
+ }
2801
+
2802
+ // Which of the active session's canvases the pane shows. In-memory
2803
+ // only: a reload lands on slot 1 (or wherever the next flip points).
2804
+ case "ui/canvasSlot": {
2805
+ const slot = Number.isInteger(Number(action.slot)) && Number(action.slot) >= 1 && Number(action.slot) <= 5
2806
+ ? Number(action.slot) : 1;
2807
+ if (slot === (state.ui.canvasSlot || 1)) return state;
2808
+ return { ...state, ui: { ...state.ui, canvasSlot: slot } };
2809
+ }
2810
+
2811
+ // The surface's receipt: revision `rev` (a draw) and/or `dataRev` (a
2812
+ // tick) actually rendered on screen. Both feed the unseen badge.
2813
+ case "canvas/viewed": {
2814
+ const sessionId = String(action.sessionId || "").trim();
2815
+ if (!sessionId) return state;
2816
+ const rev = Number(action.rev);
2817
+ const dataRev = Number(action.dataRev);
2818
+ const key = canvasKey(sessionId, action.slot);
2819
+ const prev = state.canvas.prefs[key] || { optedOut: false, lastViewedRev: 0, lastViewedDataRev: 0 };
2820
+ const nextRev = Number.isFinite(rev) && rev > (prev.lastViewedRev || 0) ? rev : (prev.lastViewedRev || 0);
2821
+ const nextDataRev = Number.isFinite(dataRev) && dataRev > (prev.lastViewedDataRev || 0) ? dataRev : (prev.lastViewedDataRev || 0);
2822
+ if (nextRev === (prev.lastViewedRev || 0) && nextDataRev === (prev.lastViewedDataRev || 0)) return state;
2823
+ return {
2824
+ ...state,
2825
+ canvas: {
2826
+ ...state.canvas,
2827
+ prefs: { ...state.canvas.prefs, [key]: { ...prev, lastViewedRev: nextRev, lastViewedDataRev: nextDataRev } },
2828
+ },
2829
+ };
2830
+ }
2831
+
2832
+ // The sessions-list seed: rev+name per slot from session_canvases,
2833
+ // merged under the live entries. Never regresses a rev a live event
2834
+ // already delivered, never sets snapshotLoaded (the full snapshot —
2835
+ // note, contract, data replay — still loads on selection).
2836
+ case "canvas/seed": {
2837
+ const entries = Array.isArray(action.entries) ? action.entries : [];
2838
+ if (!entries.length) return state;
2839
+ let next = null;
2840
+ for (const e of entries) {
2841
+ const sessionId = String(e?.sessionId || "").trim();
2842
+ const rev = Number(e?.latestRev);
2843
+ const slotN = Number(e?.slot);
2844
+ if (!sessionId || !Number.isFinite(rev) || rev <= 0) continue;
2845
+ if (!Number.isInteger(slotN) || slotN < 1 || slotN > 5) continue;
2846
+ const key = canvasKey(sessionId, slotN);
2847
+ const existing = (next || state.canvas.bySessionId)[key] || {};
2848
+ if ((existing.latestRev || 0) >= rev && existing.name !== undefined) continue;
2849
+ next = next || { ...state.canvas.bySessionId };
2850
+ next[key] = {
2851
+ ...existing,
2852
+ slot: slotN,
2853
+ latestRev: Math.max(existing.latestRev || 0, rev),
2854
+ ...(typeof e.name === "string" && existing.name === undefined ? { name: e.name } : {}),
2855
+ ...(existing.sizeBytes === undefined && e.sizeBytes !== undefined ? { sizeBytes: e.sizeBytes } : {}),
2856
+ };
2857
+ }
2858
+ if (!next) return state;
2859
+ return { ...state, canvas: { ...state.canvas, bySessionId: next } };
2860
+ }
2861
+
2862
+ case "canvas/updated": {
2863
+ const sessionId = String(action.sessionId || "").trim();
2864
+ const rev = Number(action.rev);
2865
+ if (!sessionId || !Number.isFinite(rev) || rev <= 0) return state;
2866
+ const slot = Number.isInteger(Number(action.slot)) && Number(action.slot) >= 1 && Number(action.slot) <= 5
2867
+ ? Number(action.slot) : 1;
2868
+ const key = canvasKey(sessionId, slot);
2869
+ const existing = state.canvas.bySessionId[key] || {};
2870
+ // Monotonic: replays and out-of-order merges must never regress
2871
+ // the known revision (freshness gates the FLIP, never the data).
2872
+ if ((existing.latestRev || 0) >= rev) return state;
2873
+ return {
2874
+ ...state,
2875
+ canvas: {
2876
+ ...state.canvas,
2877
+ bySessionId: {
2878
+ ...state.canvas.bySessionId,
2879
+ [key]: {
2880
+ ...existing,
2881
+ slot,
2882
+ ...(typeof action.name === "string" ? { name: action.name } : {}),
2883
+ latestRev: rev,
2884
+ note: typeof action.note === "string" ? action.note : "",
2885
+ sizeBytes: Number.isFinite(Number(action.sizeBytes)) ? Number(action.sizeBytes) : null,
2886
+ // The contract belongs to the revision: a draw
2887
+ // without one REVOKES interactivity (default
2888
+ // closed), so no carry-over from the previous rev.
2889
+ responseContract: action.responseContract && typeof action.responseContract === "object"
2890
+ ? action.responseContract
2891
+ : null,
2892
+ },
2893
+ },
2894
+ },
2895
+ };
2896
+ }
2897
+
2898
+ // A data tick: content for the page, in place. Never flips (ticks do
2899
+ // not interrupt) — but it DOES count toward the unseen badge via
2900
+ // latestDataRev vs lastViewedDataRev. Monotonic like the draw rev.
2901
+ case "canvas/data": {
2902
+ const sessionId = String(action.sessionId || "").trim();
2903
+ const slot = Number.isInteger(Number(action.slot)) && Number(action.slot) >= 1 && Number(action.slot) <= 5
2904
+ ? Number(action.slot) : 1;
2905
+ const key = canvasKey(sessionId, slot);
2906
+ const dataRev = Number(action.dataRev);
2907
+ if (!sessionId || !Number.isFinite(dataRev) || dataRev <= 0) return state;
2908
+ const existing = state.canvas.bySessionId[key] || {};
2909
+ if ((existing.latestDataRev || 0) >= dataRev) return state;
2910
+ return {
2911
+ ...state,
2912
+ canvas: {
2913
+ ...state.canvas,
2914
+ bySessionId: {
2915
+ ...state.canvas.bySessionId,
2916
+ [key]: {
2917
+ ...existing,
2918
+ latestDataRev: dataRev,
2919
+ dataPayload: action.payload && typeof action.payload === "object" ? action.payload : null,
2920
+ },
2921
+ },
2922
+ },
2923
+ };
2924
+ }
2925
+
2926
+ case "canvas/snapshot": {
2927
+ const sessionId = String(action.sessionId || "").trim();
2928
+ if (!sessionId) return state;
2929
+ const slot = Number.isInteger(Number(action.slot)) && Number(action.slot) >= 1 && Number(action.slot) <= 5
2930
+ ? Number(action.slot) : 1;
2931
+ const key = canvasKey(sessionId, slot);
2932
+ const existing = state.canvas.bySessionId[key] || {};
2933
+ const rev = Number(action.rev) || 0;
2934
+ return {
2935
+ ...state,
2936
+ canvas: {
2937
+ ...state.canvas,
2938
+ bySessionId: {
2939
+ ...state.canvas.bySessionId,
2940
+ [key]: {
2941
+ ...existing,
2942
+ slot,
2943
+ ...(typeof action.name === "string" && ((existing.latestRev || 0) <= rev) ? { name: action.name } : {}),
2944
+ // Events may have beaten the snapshot; never regress.
2945
+ latestRev: Math.max(existing.latestRev || 0, rev),
2946
+ note: (existing.latestRev || 0) > rev ? existing.note : (typeof action.note === "string" ? action.note : existing.note || ""),
2947
+ sizeBytes: (existing.latestRev || 0) > rev ? existing.sizeBytes : (Number.isFinite(Number(action.sizeBytes)) ? Number(action.sizeBytes) : existing.sizeBytes ?? null),
2948
+ responseContract: (existing.latestRev || 0) > rev
2949
+ ? (existing.responseContract ?? null)
2950
+ : (action.responseContract && typeof action.responseContract === "object" ? action.responseContract : null),
2951
+ snapshotLoaded: true,
2952
+ },
2953
+ },
2954
+ },
2955
+ };
2956
+ }
2957
+
2958
+ // Continuity break (windowed bulk reload replaced gap-free replay):
2959
+ // the memoized snapshot can no longer be trusted — re-take on next need.
2960
+ case "canvas/snapshotInvalidate": {
2961
+ const sessionId = String(action.sessionId || "").trim();
2962
+ const existing = state.canvas.bySessionId[sessionId];
2963
+ if (!sessionId || !existing?.snapshotLoaded) return state;
2964
+ // Slots 2-5 of the same session invalidate with slot 1: the flag
2965
+ // that gates re-take lives on the slot-1 entry.
2966
+ return {
2967
+ ...state,
2968
+ canvas: {
2969
+ ...state.canvas,
2970
+ bySessionId: {
2971
+ ...state.canvas.bySessionId,
2972
+ [sessionId]: { ...existing, snapshotLoaded: false },
2973
+ },
2974
+ },
2975
+ };
2976
+ }
2977
+
2590
2978
  case "files/htmlViewMode":
2591
2979
  return {
2592
2980
  ...state,