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.
- package/package.json +3 -3
- package/tui/src/app.js +0 -4
- package/tui/src/index.js +0 -9
- package/ui/core/src/canvas-actions.js +148 -0
- package/ui/core/src/commands.js +0 -1
- package/ui/core/src/controller.js +296 -21
- package/ui/core/src/history.js +134 -1
- package/ui/core/src/index.js +1 -0
- package/ui/core/src/reducer.js +407 -19
- package/ui/core/src/selectors.js +223 -154
- package/ui/core/src/state.js +139 -2
- package/ui/core/src/themes/index.js +1 -1
- package/ui/react/src/components.js +1 -7
- package/ui/react/src/web-app.js +1719 -366
- package/web/auth/config.js +3 -0
- package/web/auth/index.js +8 -1
- package/web/auth/normalize/proxy.js +48 -0
- package/web/auth/providers/proxy.js +141 -0
- package/web/dist/assets/{abnfDiagram-VRR7QNED-LOLCtsns.js → abnfDiagram-VRR7QNED-DrJhXhSQ.js} +1 -1
- package/web/dist/assets/{arc-BE-jVDEi.js → arc-BEaG9DWd.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CdmUjwtj.js → architectureDiagram-ZJ3FMSHR-D9fc1Imx.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-NZ4TOa8L.js → blockDiagram-677ZJIJ3-rS5Eikvt.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-9ubX8uGA.js → c4Diagram-LMCZKHZV-HKLlX5cR.js} +1 -1
- package/web/dist/assets/channel-DRy7FvyF.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-DNDnjbP6.js → chunk-2Q5K7J3B-kq0TyLSB.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-iilfiffr.js → chunk-32BRIVSS-O-LMarT5.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-BakOMFmf.js → chunk-5VM5RSS4-Di4h0CAu.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-BhluhGyZ.js → chunk-EX3LRPZG-C8rgqUOt.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-B6GKLlNb.js → chunk-JWPE2WC7-BSH41q4T.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-CXmt8msF.js → chunk-MOJQB5TN-BAFuQHAC.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-Bu58-SDr.js → chunk-RYQCIY6F-DAg6JOs7.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-AQdyoYHL.js → chunk-V7JOEXUC-DbVrT8BG.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-DeM5iMsE.js → chunk-VR4S4FIN-C6f5_Grp.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-DjGjajy1.js → chunk-XXDRQBXY-CqcCYpqA.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-C35RhB8X.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-C35RhB8X.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-BGi4_egJ.js → cose-bilkent-JH36ORCC-DJw04eFh.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CcgV1ve4.js → cynefin-VYW2F7L2-CpjwdEIU.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-BRDEsbBG.js → cynefinDiagram-TSTJHNR4-RqEIWoT0.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-OjuC8dJB.js → dagre-VKFMJZFB-C1eGQNK2.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-CGdROWYh.js → diagram-FQU43EPY-BODLQ7dz.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-B5J0EUfS.js → diagram-G47NLZAW-DlhmukMC.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-zmb4-cP2.js → diagram-NH7WQ7WH-Ba_jZoCN.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-j514bgaH.js → diagram-OA4YK3LP-DnSiAJBG.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-h5G459mZ.js → diagram-WEI45ONY-B3KoQ7_W.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-GnMgbuFk.js → ebnfDiagram-CCIWWBDH-ChnFc5gf.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-BFgH3UQp.js → erDiagram-Q63AITRT-D2KkJXLZ.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-BsbQ_VXV.js → flowDiagram-23GEKE2U-CmybZufj.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-C_fjo2sf.js → ganttDiagram-NO4QXBWP-BzPurW_v.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BUoXrAI9.js → gitGraphDiagram-IHSO6WYX-DAbXl4gO.js} +1 -1
- package/web/dist/assets/{index-ONJimzcx.js → index-CRY94nFp.js} +6 -6
- package/web/dist/assets/index-DtL0lbis.css +1 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-CJiSANyk.js → infoDiagram-FWYZ7A6U-C6qGjNA1.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-BKoQ-a2c.js → ishikawaDiagram-FXEZZL3T-D2d1tO-9.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-Db6Crcd3.js → journeyDiagram-5HDEW3XC-fP13w1b7.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-B9GaB0UJ.js → kanban-definition-HUTT4EX6-BJfWAXZ0.js} +1 -1
- package/web/dist/assets/{linear-BXwRb8MN.js → linear-CltApkHf.js} +1 -1
- package/web/dist/assets/{mermaid.core-DwzXN6up.js → mermaid.core-B1Dwetk3.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-DKC_C_OY.js → mindmap-definition-LN4V7U3C-CdRUD7LF.js} +1 -1
- package/web/dist/assets/{papaparse.min-Cm1Pr54l.js → papaparse.min-C1y3wBH1.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-IeawbZPt.js → pegDiagram-2B236MQR-CeQkqN6Q.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-BOdsYBt8.js → pieDiagram-ENE6RG2P-Dqgc87jA.js} +1 -1
- package/web/dist/assets/pilotswarm-DayYdi39.js +96 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-Cr5yoN7C.js → quadrantDiagram-ABIIQ3AL-CD7f-w6m.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-E7wuFBuk.js → railroadDiagram-RFXS5EU6-CcsAijSC.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-sZCKiuDs.js → requirementDiagram-TGXJPOKE-BkzF80pL.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-B_EWziUR.js → sankeyDiagram-HTMAVEWB-D9XOFw7Y.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-D2mN7onX.js → sequenceDiagram-DBY2YBRQ-BFfaS_Sz.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-CEL9Ly2T.js → sizeCapture-X5ZJPWSS-CDJV16tP.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-C7ejJoeN.js → stateDiagram-2N3HPSRC-C0E-MPXO.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BMqXeMJu.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-Ck3pbFxk.js → swimlanes-5IMT3BWC-n5Vsv7n9.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CGl4gRnH.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-K3H8Yuyr.js → timeline-definition-FHXFAJF6-DW2gV8-n.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-C3NKjSv9.js → vennDiagram-L72KCM5P-D9dbDm08.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-CeeVuFiO.js → wardleyDiagram-EHGQE667-CIcG7fiC.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-CF-vCvEq.js → xychartDiagram-FW5EYKEG-elEIFsc9.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/runtime.js +20 -1
- package/web/dist/assets/channel-BhHZnKdH.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-CM_bKBKz.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-CM_bKBKz.js +0 -1
- package/web/dist/assets/index-CnH1Yv_d.css +0 -1
- package/web/dist/assets/pilotswarm-kCQFVjJm.js +0 -99
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-UaVDt_gU.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CqO_fF5t.js +0 -8
package/ui/core/src/history.js
CHANGED
|
@@ -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);
|
package/ui/core/src/index.js
CHANGED
|
@@ -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";
|
package/ui/core/src/reducer.js
CHANGED
|
@@ -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
|
|
776
|
-
&& (settings.
|
|
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
|
-
|
|
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,
|