pmx-canvas 0.1.0

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 (226) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/Readme.md +865 -0
  4. package/dist/canvas/global.css +3173 -0
  5. package/dist/canvas/index.js +183 -0
  6. package/dist/json-render/index.css +2 -0
  7. package/dist/json-render/index.js +389 -0
  8. package/dist/types/cli/agent.d.ts +13 -0
  9. package/dist/types/cli/index.d.ts +2 -0
  10. package/dist/types/cli/watch.d.ts +5 -0
  11. package/dist/types/client/App.d.ts +1 -0
  12. package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
  13. package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
  14. package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
  15. package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
  16. package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
  18. package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
  19. package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
  20. package/dist/types/client/canvas/DockedNode.d.ts +4 -0
  21. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
  23. package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
  24. package/dist/types/client/canvas/Minimap.d.ts +23 -0
  25. package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
  26. package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
  27. package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
  28. package/dist/types/client/canvas/snap-guides.d.ts +23 -0
  29. package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
  30. package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
  31. package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
  32. package/dist/types/client/ext-app/bridge.d.ts +161 -0
  33. package/dist/types/client/icons.d.ts +70 -0
  34. package/dist/types/client/index.d.ts +1 -0
  35. package/dist/types/client/nodes/ContextNode.d.ts +34 -0
  36. package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
  37. package/dist/types/client/nodes/FileNode.d.ts +5 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -0
  39. package/dist/types/client/nodes/ImageNode.d.ts +10 -0
  40. package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
  41. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
  42. package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
  43. package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
  44. package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
  45. package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
  46. package/dist/types/client/nodes/PromptNode.d.ts +5 -0
  47. package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
  48. package/dist/types/client/nodes/StatusNode.d.ts +4 -0
  49. package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
  50. package/dist/types/client/nodes/TraceNode.d.ts +4 -0
  51. package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
  52. package/dist/types/client/nodes/image-warnings.d.ts +6 -0
  53. package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
  54. package/dist/types/client/nodes/md-format.d.ts +25 -0
  55. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  56. package/dist/types/client/state/attention-store.d.ts +25 -0
  57. package/dist/types/client/state/canvas-store.d.ts +74 -0
  58. package/dist/types/client/state/intent-bridge.d.ts +158 -0
  59. package/dist/types/client/state/sse-bridge.d.ts +5 -0
  60. package/dist/types/client/theme/tokens.d.ts +27 -0
  61. package/dist/types/client/types.d.ts +40 -0
  62. package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
  63. package/dist/types/client/utils/placement.d.ts +1 -0
  64. package/dist/types/client/utils/platform.d.ts +2 -0
  65. package/dist/types/json-render/catalog.d.ts +815 -0
  66. package/dist/types/json-render/charts/components.d.ts +54 -0
  67. package/dist/types/json-render/charts/definitions.d.ts +103 -0
  68. package/dist/types/json-render/charts/extra-components.d.ts +58 -0
  69. package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
  70. package/dist/types/json-render/renderer/index.d.ts +16 -0
  71. package/dist/types/json-render/schema.d.ts +46 -0
  72. package/dist/types/json-render/server.d.ts +55 -0
  73. package/dist/types/mcp/server.d.ts +22 -0
  74. package/dist/types/server/agent-context.d.ts +21 -0
  75. package/dist/types/server/artifact-paths.d.ts +3 -0
  76. package/dist/types/server/canvas-operations.d.ts +154 -0
  77. package/dist/types/server/canvas-provenance.d.ts +13 -0
  78. package/dist/types/server/canvas-schema.d.ts +49 -0
  79. package/dist/types/server/canvas-serialization.d.ts +25 -0
  80. package/dist/types/server/canvas-state.d.ts +174 -0
  81. package/dist/types/server/canvas-validation.d.ts +33 -0
  82. package/dist/types/server/chart-template.d.ts +29 -0
  83. package/dist/types/server/code-graph.d.ts +67 -0
  84. package/dist/types/server/context-cards.d.ts +24 -0
  85. package/dist/types/server/diagram-presets.d.ts +28 -0
  86. package/dist/types/server/ext-app-call-registry.d.ts +16 -0
  87. package/dist/types/server/ext-app-tool-result.d.ts +1 -0
  88. package/dist/types/server/file-watcher.d.ts +16 -0
  89. package/dist/types/server/index.d.ts +243 -0
  90. package/dist/types/server/mcp-app-candidate.d.ts +25 -0
  91. package/dist/types/server/mcp-app-host.d.ts +65 -0
  92. package/dist/types/server/mcp-app-runtime.d.ts +47 -0
  93. package/dist/types/server/mutation-history.d.ts +105 -0
  94. package/dist/types/server/placement.d.ts +37 -0
  95. package/dist/types/server/server.d.ts +103 -0
  96. package/dist/types/server/spatial-analysis.d.ts +87 -0
  97. package/dist/types/server/trace-manager.d.ts +48 -0
  98. package/dist/types/server/web-artifacts.d.ts +50 -0
  99. package/dist/types/server/webpage-node.d.ts +25 -0
  100. package/dist/types/shared/auto-arrange.d.ts +29 -0
  101. package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
  102. package/dist/types/shared/placement.d.ts +26 -0
  103. package/dist/types/shared/semantic-attention.d.ts +97 -0
  104. package/package.json +109 -0
  105. package/skills/data-analysis/SKILL.md +324 -0
  106. package/skills/doc-coauthoring/SKILL.md +375 -0
  107. package/skills/frontend-design/SKILL.md +45 -0
  108. package/skills/json-render-codegen/SKILL.md +112 -0
  109. package/skills/json-render-core/SKILL.md +265 -0
  110. package/skills/json-render-ink/SKILL.md +273 -0
  111. package/skills/json-render-mcp/SKILL.md +132 -0
  112. package/skills/json-render-react/SKILL.md +264 -0
  113. package/skills/json-render-shadcn/SKILL.md +159 -0
  114. package/skills/playwright-cli/SKILL.md +67 -0
  115. package/skills/pmx-canvas/SKILL.md +668 -0
  116. package/skills/pmx-canvas/evals/evals.json +186 -0
  117. package/skills/pmx-canvas-testing/SKILL.md +78 -0
  118. package/skills/published-consumer-e2e/SKILL.md +43 -0
  119. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
  120. package/skills/web-artifacts-builder/SKILL.md +80 -0
  121. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
  122. package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
  123. package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
  124. package/skills/web-design-guidelines/SKILL.md +39 -0
  125. package/src/cli/agent.ts +2144 -0
  126. package/src/cli/index.ts +622 -0
  127. package/src/cli/watch.ts +88 -0
  128. package/src/client/App.tsx +507 -0
  129. package/src/client/canvas/AttentionHistory.tsx +81 -0
  130. package/src/client/canvas/AttentionToast.tsx +19 -0
  131. package/src/client/canvas/CanvasNode.tsx +363 -0
  132. package/src/client/canvas/CanvasViewport.tsx +590 -0
  133. package/src/client/canvas/CommandPalette.tsx +302 -0
  134. package/src/client/canvas/ContextMenu.tsx +601 -0
  135. package/src/client/canvas/ContextPinBar.tsx +25 -0
  136. package/src/client/canvas/ContextPinHud.tsx +22 -0
  137. package/src/client/canvas/DockedNode.tsx +66 -0
  138. package/src/client/canvas/EdgeLayer.tsx +280 -0
  139. package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
  140. package/src/client/canvas/FocusFieldLayer.tsx +107 -0
  141. package/src/client/canvas/Minimap.tsx +301 -0
  142. package/src/client/canvas/SelectionBar.tsx +69 -0
  143. package/src/client/canvas/ShortcutOverlay.tsx +69 -0
  144. package/src/client/canvas/SnapshotPanel.tsx +236 -0
  145. package/src/client/canvas/snap-guides.ts +170 -0
  146. package/src/client/canvas/use-node-drag.ts +51 -0
  147. package/src/client/canvas/use-node-resize.ts +59 -0
  148. package/src/client/canvas/use-pan-zoom.ts +191 -0
  149. package/src/client/ext-app/bridge.ts +542 -0
  150. package/src/client/icons.tsx +424 -0
  151. package/src/client/index.tsx +7 -0
  152. package/src/client/nodes/ContextNode.tsx +412 -0
  153. package/src/client/nodes/ExtAppFrame.tsx +509 -0
  154. package/src/client/nodes/FileNode.tsx +256 -0
  155. package/src/client/nodes/GroupNode.tsx +39 -0
  156. package/src/client/nodes/ImageNode.tsx +160 -0
  157. package/src/client/nodes/InlineFormatBar.tsx +169 -0
  158. package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
  159. package/src/client/nodes/LedgerNode.tsx +37 -0
  160. package/src/client/nodes/MarkdownNode.tsx +359 -0
  161. package/src/client/nodes/McpAppNode.tsx +85 -0
  162. package/src/client/nodes/MdFormatBar.tsx +109 -0
  163. package/src/client/nodes/PromptNode.tsx +597 -0
  164. package/src/client/nodes/ResponseNode.tsx +153 -0
  165. package/src/client/nodes/StatusNode.tsx +84 -0
  166. package/src/client/nodes/StatusSummary.tsx +38 -0
  167. package/src/client/nodes/TraceNode.tsx +120 -0
  168. package/src/client/nodes/WebpageNode.tsx +288 -0
  169. package/src/client/nodes/image-warnings.ts +95 -0
  170. package/src/client/nodes/inline-editor-commands.ts +37 -0
  171. package/src/client/nodes/md-format.ts +206 -0
  172. package/src/client/state/attention-bridge.ts +328 -0
  173. package/src/client/state/attention-store.ts +73 -0
  174. package/src/client/state/canvas-store.ts +631 -0
  175. package/src/client/state/intent-bridge.ts +315 -0
  176. package/src/client/state/sse-bridge.ts +965 -0
  177. package/src/client/theme/global.css +3173 -0
  178. package/src/client/theme/tokens.ts +72 -0
  179. package/src/client/types-shims.d.ts +5 -0
  180. package/src/client/types.ts +81 -0
  181. package/src/client/utils/ext-app-tool-result.ts +4 -0
  182. package/src/client/utils/placement.ts +4 -0
  183. package/src/client/utils/platform.ts +2 -0
  184. package/src/json-render/catalog.ts +256 -0
  185. package/src/json-render/charts/components.tsx +198 -0
  186. package/src/json-render/charts/definitions.ts +81 -0
  187. package/src/json-render/charts/extra-components.tsx +267 -0
  188. package/src/json-render/charts/extra-definitions.ts +145 -0
  189. package/src/json-render/renderer/index.css +174 -0
  190. package/src/json-render/renderer/index.tsx +86 -0
  191. package/src/json-render/schema.ts +62 -0
  192. package/src/json-render/server.ts +597 -0
  193. package/src/mcp/server.ts +1377 -0
  194. package/src/server/agent-context.ts +242 -0
  195. package/src/server/artifact-paths.ts +17 -0
  196. package/src/server/canvas-operations.ts +1279 -0
  197. package/src/server/canvas-provenance.ts +243 -0
  198. package/src/server/canvas-schema.ts +432 -0
  199. package/src/server/canvas-serialization.ts +95 -0
  200. package/src/server/canvas-state.ts +1134 -0
  201. package/src/server/canvas-validation.ts +114 -0
  202. package/src/server/chart-template.ts +449 -0
  203. package/src/server/code-graph.ts +370 -0
  204. package/src/server/context-cards.ts +31 -0
  205. package/src/server/diagram-presets.ts +71 -0
  206. package/src/server/ext-app-call-registry.ts +77 -0
  207. package/src/server/ext-app-tool-result.ts +4 -0
  208. package/src/server/file-watcher.ts +121 -0
  209. package/src/server/index.ts +647 -0
  210. package/src/server/mcp-app-candidate.ts +174 -0
  211. package/src/server/mcp-app-host.ts +814 -0
  212. package/src/server/mcp-app-runtime.ts +459 -0
  213. package/src/server/mutation-history.ts +350 -0
  214. package/src/server/placement.ts +125 -0
  215. package/src/server/server.ts +3846 -0
  216. package/src/server/spatial-analysis.ts +356 -0
  217. package/src/server/trace-manager.ts +333 -0
  218. package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
  219. package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
  220. package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
  221. package/src/server/web-artifacts.ts +442 -0
  222. package/src/server/webpage-node.ts +328 -0
  223. package/src/shared/auto-arrange.ts +439 -0
  224. package/src/shared/ext-app-tool-result.ts +76 -0
  225. package/src/shared/placement.ts +81 -0
  226. package/src/shared/semantic-attention.ts +598 -0
@@ -0,0 +1,965 @@
1
+ import { findOpenCanvasPosition } from '../utils/placement.js';
2
+ import { normalizeExtAppToolResult } from '../utils/ext-app-tool-result.js';
3
+ import type { CanvasEdge, CanvasNodeState } from '../types';
4
+ import {
5
+ activeNodeId,
6
+ addEdge,
7
+ addNode,
8
+ applyServerCanvasLayout,
9
+ cancelViewportAnimation,
10
+ canvasTheme,
11
+ connectionStatus,
12
+ replaceContextPinsFromServer,
13
+ edges,
14
+ focusNode,
15
+ hasInitialServerLayout,
16
+ nodes,
17
+ replaceViewport,
18
+ removeEdge,
19
+ removeNode,
20
+ restoreLayout,
21
+ sessionId,
22
+ traceEnabled,
23
+ updateNode,
24
+ updateNodeData,
25
+ } from './canvas-store';
26
+ import { invalidateTokenCache } from '../theme/tokens';
27
+ import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
28
+
29
+ let eventSource: EventSource | null = null;
30
+ let savedLayout: Map<string, Partial<CanvasNodeState>> | null = null;
31
+ let reconnectAttempts = 0;
32
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
33
+
34
+ // Maps responseNodeId → thread prompt node ID so response deltas/completions
35
+ // are routed into the thread's turns array instead of creating separate nodes.
36
+ // Entries are added on response-start and removed on response-complete.
37
+ // Not cleaned on SSE reconnect — orphaned entries are benign (small, bounded by active streams).
38
+ const responseToThreadMap = new Map<string, string>();
39
+
40
+ // ── Helpers ───────────────────────────────────────────────────
41
+
42
+ // D1: Simple string hash for deterministic node IDs (e.g. `md-${hashPath(path)}`).
43
+ // Uses Java's String.hashCode algorithm. Collisions are acceptable here — they
44
+ // just cause two paths to share a node slot (last-write-wins), which is benign
45
+ // for the canvas use case and keeps IDs stable across reconnects.
46
+ /** @internal — exported for testing */
47
+ export function hashPath(path: string): string {
48
+ let h = 0;
49
+ for (let i = 0; i < path.length; i++) {
50
+ h = ((h << 5) - h + path.charCodeAt(i)) | 0;
51
+ }
52
+ return Math.abs(h).toString(36);
53
+ }
54
+
55
+ function applyLayoutOverrides(node: CanvasNodeState): CanvasNodeState {
56
+ if (!savedLayout) return node;
57
+ const overrides = savedLayout.get(node.id);
58
+ if (!overrides) return node;
59
+ return {
60
+ ...node,
61
+ position: overrides.position ?? node.position,
62
+ size: overrides.size ?? node.size,
63
+ collapsed: overrides.collapsed ?? node.collapsed,
64
+ pinned: overrides.pinned ?? node.pinned,
65
+ dockPosition: overrides.dockPosition !== undefined ? overrides.dockPosition : node.dockPosition,
66
+ };
67
+ }
68
+
69
+ // ── Default positions by type ─────────────────────────────────
70
+ const DEFAULT_POSITIONS: Record<
71
+ CanvasNodeState['type'],
72
+ { x: number; y: number; w: number; h: number }
73
+ > & Record<'prompt' | 'response', { x: number; y: number; w: number; h: number }> = {
74
+ status: { x: 40, y: 80, w: 300, h: 120 },
75
+ markdown: { x: 380, y: 80, w: 720, h: 600 },
76
+ context: { x: 1130, y: 80, w: 320, h: 400 },
77
+ 'mcp-app': { x: 380, y: 720, w: 720, h: 500 },
78
+ webpage: { x: 380, y: 80, w: 520, h: 420 },
79
+ 'json-render': { x: 380, y: 720, w: 840, h: 620 },
80
+ graph: { x: 380, y: 720, w: 760, h: 520 },
81
+ ledger: { x: 1130, y: 520, w: 320, h: 280 },
82
+ trace: { x: 40, y: 900, w: 200, h: 56 },
83
+ file: { x: 380, y: 80, w: 720, h: 600 },
84
+ image: { x: 380, y: 80, w: 720, h: 520 },
85
+ group: { x: 220, y: 60, w: 840, h: 560 },
86
+ prompt: { x: 380, y: 1260, w: 520, h: 400 },
87
+ response: { x: 380, y: 1480, w: 720, h: 400 },
88
+ };
89
+
90
+ function makeNode(
91
+ id: string,
92
+ type: CanvasNodeState['type'],
93
+ data: Record<string, unknown>,
94
+ dockPosition: 'left' | 'right' | null = null,
95
+ ): CanvasNodeState {
96
+ const pos = DEFAULT_POSITIONS[type];
97
+ return applyLayoutOverrides({
98
+ id,
99
+ type,
100
+ position: { x: pos.x, y: pos.y },
101
+ size: { width: pos.w, height: pos.h },
102
+ zIndex: type === 'status' ? 0 : 1,
103
+ collapsed: false,
104
+ pinned: false,
105
+ dockPosition,
106
+ data,
107
+ });
108
+ }
109
+
110
+ function getMarkdownPlacement(): { x: number; y: number } {
111
+ return findOpenCanvasPosition(
112
+ [...nodes.value.values()],
113
+ DEFAULT_POSITIONS.markdown.w,
114
+ DEFAULT_POSITIONS.markdown.h,
115
+ );
116
+ }
117
+
118
+ // ── Node ensure helpers ───────────────────────────────────────
119
+ function ensureStatusNode(): void {
120
+ const id = 'status-main';
121
+ if (!nodes.value.has(id)) {
122
+ addNode(makeNode(id, 'status', { phase: 'idle', message: '', elapsed: 0 }, 'left'));
123
+ }
124
+ }
125
+
126
+ function ensureMarkdownNode(path: string, title: string): void {
127
+ const id = `md-${hashPath(path)}`;
128
+ const existing = nodes.value.get(id);
129
+ if (existing) {
130
+ updateNodeData(id, { path, title });
131
+ activeNodeId.value = id;
132
+ } else {
133
+ const placement = getMarkdownPlacement();
134
+ const node = makeNode(id, 'markdown', { path, title, content: '', rendered: '' });
135
+ node.position = placement;
136
+ addNode(node);
137
+ if (!node.dockPosition) {
138
+ focusNode(id);
139
+ }
140
+ }
141
+ }
142
+
143
+ function ensureContextNode(cards: unknown[]): void {
144
+ const id = 'context-main';
145
+ const existing = nodes.value.get(id);
146
+ if (existing) {
147
+ updateNodeData(id, { cards });
148
+ } else if (cards.length > 0) {
149
+ const node = makeNode(id, 'context', { cards });
150
+ addNode(node);
151
+ }
152
+ }
153
+
154
+ function ensureMcpAppNode(data: Record<string, unknown>): void {
155
+ const url = data.url as string;
156
+ const id = `mcp-${hashPath(url)}`;
157
+ const existing = nodes.value.get(id);
158
+ if (existing) {
159
+ updateNodeData(id, data);
160
+ } else {
161
+ addNode(makeNode(id, 'mcp-app', data));
162
+ focusNode(id);
163
+ }
164
+ }
165
+
166
+ function ensureExtAppNode(data: Record<string, unknown>): void {
167
+ const toolCallId = data.toolCallId as string;
168
+ const id = `ext-app-${toolCallId}`;
169
+ const existing = nodes.value.get(id);
170
+ if (existing) {
171
+ updateNodeData(id, data);
172
+ return;
173
+ }
174
+
175
+ // Check if there's already an ext-app node for the same server+tool still in
176
+ // "loading" state (no toolResult yet). Reuse it instead of creating a duplicate.
177
+ const serverName = data.serverName as string;
178
+ const toolName = data.toolName as string;
179
+ if (serverName && toolName) {
180
+ for (const [existingId, n] of nodes.value.entries()) {
181
+ if (
182
+ n.type === 'mcp-app' &&
183
+ n.data.mode === 'ext-app' &&
184
+ n.data.serverName === serverName &&
185
+ n.data.toolName === toolName &&
186
+ !n.data.toolResult
187
+ ) {
188
+ // Reuse this node — update its data with the new toolCallId and html
189
+ updateNodeData(existingId, { ...data });
190
+ return;
191
+ }
192
+ }
193
+ }
194
+
195
+ // Use custom position/size if provided (chart nodes), otherwise offset from defaults
196
+ const customX = data._x as number | undefined;
197
+ const customY = data._y as number | undefined;
198
+ const customW = data._width as number | undefined;
199
+ const customH = data._height as number | undefined;
200
+ const pos = DEFAULT_POSITIONS['mcp-app'];
201
+ const width = customW ?? pos.w;
202
+ const height = customH ?? pos.h;
203
+ const autoPos =
204
+ customX === undefined || customY === undefined
205
+ ? findOpenCanvasPosition([...nodes.value.values()], width, height)
206
+ : null;
207
+ const node = applyLayoutOverrides({
208
+ id,
209
+ type: 'mcp-app' as const,
210
+ position: {
211
+ x: customX ?? autoPos?.x ?? pos.x,
212
+ y: customY ?? autoPos?.y ?? pos.y,
213
+ },
214
+ size: {
215
+ width,
216
+ height,
217
+ },
218
+ zIndex: 1,
219
+ collapsed: false,
220
+ pinned: false,
221
+ dockPosition: null,
222
+ data: {
223
+ mode: 'ext-app',
224
+ ...data,
225
+ },
226
+ });
227
+ addNode(node);
228
+ if (!node.dockPosition) {
229
+ focusNode(id);
230
+ }
231
+ }
232
+
233
+ function findExtAppNodeId(toolCallId: string): string | null {
234
+ const directId = `ext-app-${toolCallId}`;
235
+ if (nodes.value.has(directId)) return directId;
236
+ for (const [nodeId, node] of nodes.value.entries()) {
237
+ if (
238
+ node.type === 'mcp-app' &&
239
+ node.data.mode === 'ext-app' &&
240
+ node.data.toolCallId === toolCallId
241
+ ) {
242
+ return nodeId;
243
+ }
244
+ }
245
+ return null;
246
+ }
247
+
248
+ function findOnlyPendingExtAppNodeId(serverName: unknown, toolName: unknown): string | null {
249
+ if (typeof serverName !== 'string' || !serverName) return null;
250
+ if (typeof toolName !== 'string' || !toolName) return null;
251
+ let matchId: string | null = null;
252
+ for (const [nodeId, node] of nodes.value.entries()) {
253
+ if (
254
+ node.type === 'mcp-app' &&
255
+ node.data.mode === 'ext-app' &&
256
+ node.data.serverName === serverName &&
257
+ node.data.toolName === toolName &&
258
+ !node.data.toolResult
259
+ ) {
260
+ if (matchId) return null;
261
+ matchId = nodeId;
262
+ }
263
+ }
264
+ return matchId;
265
+ }
266
+
267
+ function ensureLedgerNode(summary: Record<string, unknown>): void {
268
+ const id = 'ledger-main';
269
+ const existing = nodes.value.get(id);
270
+ if (existing) {
271
+ updateNodeData(id, summary);
272
+ } else {
273
+ const node = makeNode(id, 'ledger', summary, 'right');
274
+ node.collapsed = true;
275
+ addNode(node);
276
+ }
277
+ }
278
+
279
+ function applyCanvasTheme(theme: string): void {
280
+ const valid = theme === 'dark' || theme === 'light' || theme === 'high-contrast';
281
+ if (!valid || canvasTheme.value === theme) return;
282
+ canvasTheme.value = theme;
283
+ document.documentElement.setAttribute('data-theme', theme);
284
+ invalidateTokenCache();
285
+ }
286
+
287
+ function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {
288
+ return value === 'markdown'
289
+ || value === 'mcp-app'
290
+ || value === 'webpage'
291
+ || value === 'json-render'
292
+ || value === 'graph'
293
+ || value === 'prompt'
294
+ || value === 'response'
295
+ || value === 'status'
296
+ || value === 'context'
297
+ || value === 'ledger'
298
+ || value === 'trace'
299
+ || value === 'file'
300
+ || value === 'image'
301
+ || value === 'group';
302
+ }
303
+
304
+ function isCanvasEdgeType(value: unknown): value is CanvasEdge['type'] {
305
+ return value === 'relation'
306
+ || value === 'depends-on'
307
+ || value === 'flow'
308
+ || value === 'references';
309
+ }
310
+
311
+ function parseCanvasPosition(value: unknown): { x: number; y: number } | null {
312
+ if (!value || typeof value !== 'object') return null;
313
+ const position = value as { x?: unknown; y?: unknown };
314
+ if (typeof position.x !== 'number' || typeof position.y !== 'number') return null;
315
+ return { x: position.x, y: position.y };
316
+ }
317
+
318
+ function parseCanvasSize(value: unknown): { width: number; height: number } | null {
319
+ if (!value || typeof value !== 'object') return null;
320
+ const size = value as { width?: unknown; height?: unknown };
321
+ if (typeof size.width !== 'number' || typeof size.height !== 'number') return null;
322
+ return { width: size.width, height: size.height };
323
+ }
324
+
325
+ function parseCanvasNode(raw: Record<string, unknown>): CanvasNodeState | null {
326
+ if (typeof raw.id !== 'string' || !raw.id) return null;
327
+ if (!isCanvasNodeType(raw.type)) return null;
328
+
329
+ const position = parseCanvasPosition(raw.position);
330
+ const size = parseCanvasSize(raw.size);
331
+ if (!position || !size) return null;
332
+
333
+ const dockPosition =
334
+ raw.dockPosition === 'left' || raw.dockPosition === 'right' ? raw.dockPosition : null;
335
+ const data =
336
+ raw.data && typeof raw.data === 'object' ? Object.fromEntries(Object.entries(raw.data)) : {};
337
+
338
+ return {
339
+ id: raw.id,
340
+ type: raw.type,
341
+ position,
342
+ size,
343
+ zIndex: typeof raw.zIndex === 'number' ? raw.zIndex : 1,
344
+ collapsed: raw.collapsed === true,
345
+ pinned: raw.pinned === true,
346
+ dockPosition,
347
+ data,
348
+ };
349
+ }
350
+
351
+ function parseCanvasEdge(raw: Record<string, unknown>): CanvasEdge | null {
352
+ if (typeof raw.id !== 'string' || !raw.id) return null;
353
+ if (typeof raw.from !== 'string' || !raw.from) return null;
354
+ if (typeof raw.to !== 'string' || !raw.to) return null;
355
+ if (!isCanvasEdgeType(raw.type)) return null;
356
+
357
+ return {
358
+ id: raw.id,
359
+ from: raw.from,
360
+ to: raw.to,
361
+ type: raw.type,
362
+ ...(typeof raw.label === 'string' ? { label: raw.label } : {}),
363
+ ...(raw.style === 'solid' || raw.style === 'dashed' || raw.style === 'dotted'
364
+ ? { style: raw.style }
365
+ : {}),
366
+ ...(raw.animated === true ? { animated: true } : {}),
367
+ };
368
+ }
369
+
370
+ // ── SSE event handlers ───────────────────────────────────────
371
+ function handleConnected(data: Record<string, unknown>): void {
372
+ sessionId.value = (data.sessionId as string) || '';
373
+ connectionStatus.value = 'connected';
374
+ if (typeof data.theme === 'string') {
375
+ applyCanvasTheme(data.theme);
376
+ }
377
+ if (data.ledgerSummary) {
378
+ ensureLedgerNode(data.ledgerSummary as Record<string, unknown>);
379
+ }
380
+ }
381
+
382
+ function handleWorkbenchOpen(data: Record<string, unknown>): void {
383
+ // H6: Guard — path must be a string for node ID stability
384
+ if (typeof data.path !== 'string' || !data.path) return;
385
+ const path = data.path;
386
+ const title =
387
+ (typeof data.title === 'string' ? data.title : '') || path.split('/').pop() || 'Untitled';
388
+
389
+ ensureMarkdownNode(path, title);
390
+ if (data.ledgerSummary) {
391
+ ensureLedgerNode(data.ledgerSummary as Record<string, unknown>);
392
+ }
393
+ }
394
+
395
+ function handleCanvasStatus(data: Record<string, unknown>): void {
396
+ ensureStatusNode();
397
+ updateNodeData('status-main', {
398
+ message: typeof data.message === 'string' ? data.message : String(data.message ?? ''),
399
+ level: data.level ?? 'ok',
400
+ source: data.source,
401
+ });
402
+ }
403
+
404
+ function handleExecutionPhase(data: Record<string, unknown>): void {
405
+ ensureStatusNode();
406
+ updateNodeData('status-main', {
407
+ phase: data.phase,
408
+ detail: data.detail,
409
+ });
410
+ }
411
+
412
+ function handleContextCards(data: Record<string, unknown>): void {
413
+ const cards = (data.cards as unknown[]) ?? [];
414
+ ensureContextNode(cards);
415
+ }
416
+
417
+ function handleMcpAppCandidate(data: Record<string, unknown>): void {
418
+ // H6: Guard — url must be a string for hashPath and iframe src
419
+ if (typeof data.url === 'string' && data.url) {
420
+ ensureMcpAppNode({
421
+ url: data.url,
422
+ sourceServer: data.sourceServer,
423
+ sourceTool: data.sourceTool,
424
+ inferredType: data.inferredType,
425
+ trustedDomain: data.trustedDomain,
426
+ hostMode: data.hostMode ?? 'hosted',
427
+ });
428
+ }
429
+ }
430
+
431
+ function handleMcpAppHostSnapshot(data: Record<string, unknown>): void {
432
+ // Update all existing MCP nodes with session state changes
433
+ const sessions = (data.sessions as Array<Record<string, unknown>>) ?? [];
434
+ for (const session of sessions) {
435
+ const url = session.url as string;
436
+ if (!url) continue;
437
+ const id = `mcp-${hashPath(url)}`;
438
+ if (nodes.value.has(id)) {
439
+ updateNodeData(id, { sessionState: session.state, lastSeenAt: session.lastSeenAt });
440
+ }
441
+ }
442
+ }
443
+
444
+ function handleMcpAppHostFallback(data: Record<string, unknown>): void {
445
+ // H6: Guard — url must be a string
446
+ if (typeof data.url === 'string' && data.url) {
447
+ const id = `mcp-${hashPath(data.url as string)}`;
448
+ if (nodes.value.has(id)) {
449
+ updateNodeData(id, { hostMode: 'fallback', fallbackReason: data.reasonCode });
450
+ }
451
+ }
452
+ }
453
+
454
+ function handleAuxOpen(data: Record<string, unknown>): void {
455
+ // Track auxiliary tabs in the context node
456
+ const id = 'context-main';
457
+ const existing = nodes.value.get(id);
458
+ if (!existing) return;
459
+ const auxTabs = ((existing.data.auxTabs as unknown[]) ?? []).concat(data);
460
+ updateNodeData(id, { auxTabs });
461
+ }
462
+
463
+ function handleAuxClose(data: Record<string, unknown>): void {
464
+ const id = 'context-main';
465
+ if (nodes.value.has(id)) {
466
+ const mode = data.mode as string;
467
+ if (mode === 'all') {
468
+ updateNodeData(id, { auxTabs: [] });
469
+ } else {
470
+ const existing = nodes.value.get(id);
471
+ if (!existing) return;
472
+ const auxTabs = ((existing.data.auxTabs as Array<Record<string, unknown>>) ?? []).filter(
473
+ (t) => t.id !== data.id,
474
+ );
475
+ updateNodeData(id, { auxTabs });
476
+ }
477
+ }
478
+ }
479
+
480
+ function handleAssistantComplete(data: Record<string, unknown>): void {
481
+ ensureStatusNode();
482
+ updateNodeData('status-main', {
483
+ phase: 'idle',
484
+ lastCompletion: {
485
+ tokenCount: data.tokenCount,
486
+ artifactCount: data.artifactCount,
487
+ },
488
+ });
489
+ }
490
+
491
+ function handleToolStart(data: Record<string, unknown>): void {
492
+ ensureStatusNode();
493
+ updateNodeData('status-main', {
494
+ phase: 'tooling',
495
+ detail: `${data.name}`,
496
+ activeTool: data.name,
497
+ });
498
+ }
499
+
500
+ function handleToolComplete(_data: Record<string, unknown>): void {
501
+ ensureStatusNode();
502
+ updateNodeData('status-main', {
503
+ activeTool: null,
504
+ });
505
+ }
506
+
507
+ function handleReviewState(data: Record<string, unknown>): void {
508
+ const state = data.state as string;
509
+ if (state === 'active' && data.path) {
510
+ const id = `md-${hashPath(data.path as string)}`;
511
+ if (nodes.value.has(id)) {
512
+ updateNodeData(id, { reviewActive: true });
513
+ }
514
+ }
515
+ }
516
+
517
+ function handleExtAppOpen(data: Record<string, unknown>): void {
518
+ if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
519
+ ensureExtAppNode({
520
+ toolCallId: data.toolCallId,
521
+ title: data.title,
522
+ html: data.html,
523
+ toolInput: data.toolInput,
524
+ serverName: data.serverName,
525
+ toolName: data.toolName,
526
+ appSessionId: data.appSessionId,
527
+ resourceUri: data.resourceUri,
528
+ toolDefinition: data.toolDefinition,
529
+ resourceMeta: data.resourceMeta,
530
+ hostMode: 'hosted',
531
+ trustedDomain: true,
532
+ ...(data.chartConfig ? { chartConfig: data.chartConfig } : {}),
533
+ // Custom position/size for chart nodes (passed through from canvas_add_chart)
534
+ ...(typeof data.x === 'number' && { _x: data.x }),
535
+ ...(typeof data.y === 'number' && { _y: data.y }),
536
+ ...(typeof data.width === 'number' && { _width: data.width }),
537
+ ...(typeof data.height === 'number' && { _height: data.height }),
538
+ });
539
+ }
540
+
541
+ function handleExtAppUpdate(data: Record<string, unknown>): void {
542
+ if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
543
+ const id =
544
+ findExtAppNodeId(data.toolCallId) ?? findOnlyPendingExtAppNodeId(data.serverName, data.toolName);
545
+ if (!id) return;
546
+ if (nodes.value.has(id)) {
547
+ updateNodeData(id, { html: data.html });
548
+ }
549
+ }
550
+
551
+ function handleExtAppResult(data: Record<string, unknown>): void {
552
+ if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
553
+ const id =
554
+ findExtAppNodeId(data.toolCallId) ?? findOnlyPendingExtAppNodeId(data.serverName, data.toolName);
555
+ if (!id) return;
556
+ if (nodes.value.has(id)) {
557
+ if (data.success === false) {
558
+ removeNode(id);
559
+ return;
560
+ }
561
+ updateNodeData(id, {
562
+ toolResult: normalizeExtAppToolResult({
563
+ result: data.result,
564
+ success: typeof data.success === 'boolean' ? data.success : undefined,
565
+ error: typeof data.error === 'string' ? data.error : undefined,
566
+ content: typeof data.content === 'string' ? data.content : undefined,
567
+ detailedContent:
568
+ typeof data.detailedContent === 'string' ? data.detailedContent : undefined,
569
+ }),
570
+ });
571
+ }
572
+ }
573
+
574
+ function handleSubagentStatus(data: Record<string, unknown>): void {
575
+ ensureStatusNode();
576
+ updateNodeData('status-main', {
577
+ subagent: {
578
+ state: data.state,
579
+ name: data.agentDisplayName ?? data.agentName,
580
+ },
581
+ });
582
+ }
583
+
584
+ // ── Canvas prompt/response events ─────────────────────────────
585
+
586
+ function handleCanvasPromptCreated(data: Record<string, unknown>): void {
587
+ const nodeId = data.nodeId as string;
588
+ if (!nodeId) return;
589
+ const text = (data.text as string) || '';
590
+ const position = data.position as { x: number; y: number } | undefined;
591
+ const parentNodeId = data.parentNodeId as string | undefined;
592
+ const contextNodeIds = data.contextNodeIds as string[] | undefined;
593
+
594
+ // If this is a thread reply (appended turn to existing node), just update its data
595
+ if (data.threadNodeId && nodes.value.has(data.threadNodeId as string)) {
596
+ const threadId = data.threadNodeId as string;
597
+ const existing = nodes.value.get(threadId);
598
+ if (!existing) return;
599
+ const currentTurns = Array.isArray(existing.data.turns)
600
+ ? [...(existing.data.turns as Array<Record<string, unknown>>)]
601
+ : [];
602
+ // Only add user turn if not already present (server may have added it)
603
+ const lastTurn = currentTurns[currentTurns.length - 1];
604
+ if (!lastTurn || lastTurn.role !== 'user' || lastTurn.text !== text) {
605
+ currentTurns.push({ role: 'user', text, status: 'pending' });
606
+ }
607
+ updateNodeData(threadId, { turns: currentTurns, threadStatus: 'pending' });
608
+ return;
609
+ }
610
+
611
+ if (!nodes.value.has(nodeId)) {
612
+ const pos = position ?? DEFAULT_POSITIONS.prompt;
613
+ addNode(
614
+ applyLayoutOverrides({
615
+ id: nodeId,
616
+ type: 'prompt' as const,
617
+ position: { x: pos.x, y: pos.y },
618
+ size: { width: DEFAULT_POSITIONS.prompt.w, height: 400 },
619
+ zIndex: 1,
620
+ collapsed: false,
621
+ pinned: false,
622
+ dockPosition: null,
623
+ data: {
624
+ text,
625
+ turns: text ? [{ role: 'user', text, status: 'pending' }] : [],
626
+ threadStatus: text ? 'pending' : 'draft',
627
+ status: text ? 'pending' : 'draft',
628
+ parentNodeId,
629
+ contextNodeIds,
630
+ },
631
+ }),
632
+ );
633
+ focusNode(nodeId);
634
+ }
635
+
636
+ // Add flow edge from parent → prompt if parent exists
637
+ if (parentNodeId && nodes.value.has(parentNodeId)) {
638
+ addEdge({
639
+ id: `edge-${parentNodeId}-${nodeId}`,
640
+ from: parentNodeId,
641
+ to: nodeId,
642
+ type: 'flow',
643
+ style: 'dashed',
644
+ });
645
+ }
646
+ }
647
+
648
+ function handleCanvasPromptStatus(data: Record<string, unknown>): void {
649
+ const nodeId = data.nodeId as string;
650
+ const status = data.status as string;
651
+ if (nodeId && nodes.value.has(nodeId)) {
652
+ updateNodeData(nodeId, { status });
653
+ }
654
+ }
655
+
656
+ function handleCanvasResponseStart(data: Record<string, unknown>): void {
657
+ const responseNodeId = data.responseNodeId as string;
658
+ const promptNodeId = data.promptNodeId as string;
659
+ if (!responseNodeId) return;
660
+
661
+ // Route response into thread node if prompt node has turns
662
+ const promptNode = promptNodeId ? nodes.value.get(promptNodeId) : undefined;
663
+ if (promptNode && Array.isArray(promptNode.data.turns)) {
664
+ responseToThreadMap.set(responseNodeId, promptNodeId);
665
+ const currentTurns = [...(promptNode.data.turns as Array<Record<string, unknown>>)];
666
+ currentTurns.push({ role: 'assistant', text: '', status: 'streaming' });
667
+ updateNodeData(promptNodeId, {
668
+ turns: currentTurns,
669
+ threadStatus: 'streaming',
670
+ _activeResponseId: responseNodeId,
671
+ });
672
+ focusNode(promptNodeId);
673
+ return;
674
+ }
675
+
676
+ // Fallback: create separate response node (for legacy prompt nodes without turns)
677
+ const pos = promptNode
678
+ ? { x: promptNode.position.x, y: promptNode.position.y + promptNode.size.height + 24 }
679
+ : { x: DEFAULT_POSITIONS.response.x, y: DEFAULT_POSITIONS.response.y };
680
+
681
+ if (!nodes.value.has(responseNodeId)) {
682
+ addNode(
683
+ applyLayoutOverrides({
684
+ id: responseNodeId,
685
+ type: 'response' as const,
686
+ position: pos,
687
+ size: { width: DEFAULT_POSITIONS.response.w, height: DEFAULT_POSITIONS.response.h },
688
+ zIndex: 1,
689
+ collapsed: false,
690
+ pinned: false,
691
+ dockPosition: null,
692
+ data: { content: '', status: 'streaming', promptNodeId },
693
+ }),
694
+ );
695
+ }
696
+
697
+ // Animated flow edge from prompt → response
698
+ if (promptNodeId) {
699
+ addEdge({
700
+ id: `edge-${promptNodeId}-${responseNodeId}`,
701
+ from: promptNodeId,
702
+ to: responseNodeId,
703
+ type: 'flow',
704
+ animated: true,
705
+ });
706
+ }
707
+
708
+ focusNode(responseNodeId);
709
+ }
710
+
711
+ function handleCanvasResponseDelta(data: Record<string, unknown>): void {
712
+ const responseNodeId = data.responseNodeId as string;
713
+ if (!responseNodeId) return;
714
+
715
+ // Route into thread if mapped
716
+ const threadId = responseToThreadMap.get(responseNodeId);
717
+ if (threadId) {
718
+ const threadNode = nodes.value.get(threadId);
719
+ if (threadNode && Array.isArray(threadNode.data.turns)) {
720
+ const currentTurns = [...(threadNode.data.turns as Array<Record<string, unknown>>)];
721
+ const lastTurn = currentTurns[currentTurns.length - 1];
722
+ if (lastTurn && lastTurn.role === 'assistant') {
723
+ lastTurn.text = data.content as string;
724
+ lastTurn.status = 'streaming';
725
+ }
726
+ updateNodeData(threadId, { turns: currentTurns, threadStatus: 'streaming' });
727
+ }
728
+ return;
729
+ }
730
+
731
+ // Fallback: update standalone response node
732
+ if (!nodes.value.has(responseNodeId)) return;
733
+ updateNodeData(responseNodeId, {
734
+ content: data.content as string,
735
+ status: 'streaming',
736
+ });
737
+ }
738
+
739
+ function handleCanvasResponseComplete(data: Record<string, unknown>): void {
740
+ const responseNodeId = data.responseNodeId as string;
741
+ if (!responseNodeId) return;
742
+
743
+ // Route into thread if mapped
744
+ const threadId = responseToThreadMap.get(responseNodeId);
745
+ if (threadId) {
746
+ const threadNode = nodes.value.get(threadId);
747
+ if (threadNode && Array.isArray(threadNode.data.turns)) {
748
+ const currentTurns = [...(threadNode.data.turns as Array<Record<string, unknown>>)];
749
+ const lastTurn = currentTurns[currentTurns.length - 1];
750
+ if (lastTurn && lastTurn.role === 'assistant') {
751
+ lastTurn.text = data.content as string;
752
+ lastTurn.status = 'complete';
753
+ }
754
+ updateNodeData(threadId, {
755
+ turns: currentTurns,
756
+ threadStatus: 'answered',
757
+ _activeResponseId: undefined,
758
+ });
759
+ }
760
+ responseToThreadMap.delete(responseNodeId);
761
+ return;
762
+ }
763
+
764
+ // Fallback: update standalone response node
765
+ if (!nodes.value.has(responseNodeId)) return;
766
+ updateNodeData(responseNodeId, {
767
+ content: data.content as string,
768
+ status: 'complete',
769
+ });
770
+
771
+ // Stop edge animation
772
+ const node = nodes.value.get(responseNodeId);
773
+ const promptNodeId = node?.data.promptNodeId as string | undefined;
774
+ if (promptNodeId) {
775
+ const edgeId = `edge-${promptNodeId}-${responseNodeId}`;
776
+ const existingEdge = edges.value.get(edgeId);
777
+ if (existingEdge) {
778
+ removeEdge(edgeId);
779
+ addEdge({ ...existingEdge, animated: false });
780
+ }
781
+ }
782
+ }
783
+
784
+ // ── Agent canvas tool events ──────────────────────────────────
785
+
786
+ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
787
+ const layout = data.layout as
788
+ | {
789
+ nodes?: Array<Record<string, unknown>>;
790
+ edges?: Array<Record<string, unknown>>;
791
+ viewport?: Record<string, unknown>;
792
+ }
793
+ | undefined;
794
+ if (!layout?.nodes) return;
795
+ hasInitialServerLayout.value = true;
796
+
797
+ const serverNodes = layout.nodes
798
+ .map(parseCanvasNode)
799
+ .filter((node): node is CanvasNodeState => node !== null);
800
+ const serverEdges = Array.isArray(layout.edges)
801
+ ? layout.edges.map(parseCanvasEdge).filter((edge): edge is CanvasEdge => edge !== null)
802
+ : Array.from(edges.value.values());
803
+ const nextViewport = layout.viewport
804
+ ? {
805
+ x: typeof layout.viewport.x === 'number' ? layout.viewport.x : 0,
806
+ y: typeof layout.viewport.y === 'number' ? layout.viewport.y : 0,
807
+ scale: typeof layout.viewport.scale === 'number' ? layout.viewport.scale : 1,
808
+ }
809
+ : undefined;
810
+
811
+ cancelViewportAnimation();
812
+ applyServerCanvasLayout({
813
+ ...(nextViewport ? { viewport: nextViewport } : {}),
814
+ nodes: serverNodes,
815
+ edges: serverEdges,
816
+ });
817
+
818
+ syncAttentionFromSse({ event: 'canvas-layout-update', data });
819
+ }
820
+
821
+ function reconnectDelayMs(attempt: number): number {
822
+ if (attempt <= 1) return 500;
823
+ if (attempt === 2) return 1000;
824
+ return Math.min(2500, 1500 + (attempt - 3) * 500);
825
+ }
826
+
827
+ function handleCanvasFocusNode(data: Record<string, unknown>): void {
828
+ const nodeId = data.nodeId as string;
829
+ if (nodeId && nodes.value.has(nodeId)) {
830
+ focusNode(nodeId);
831
+ }
832
+ }
833
+
834
+ function handleCanvasViewportUpdate(data: Record<string, unknown>): void {
835
+ const viewport = data.viewport as Record<string, unknown> | undefined;
836
+ if (!viewport) return;
837
+ const x = typeof viewport.x === 'number' ? viewport.x : 0;
838
+ const y = typeof viewport.y === 'number' ? viewport.y : 0;
839
+ const scale = typeof viewport.scale === 'number' ? viewport.scale : 1;
840
+ cancelViewportAnimation();
841
+ replaceViewport({ x, y, scale });
842
+ }
843
+
844
+ function handleContextUsage(data: Record<string, unknown>): void {
845
+ const id = 'context-main';
846
+ const existing = nodes.value.get(id);
847
+ if (existing) {
848
+ updateNodeData(id, {
849
+ currentTokens: data.currentTokens,
850
+ tokenLimit: data.tokenLimit,
851
+ messagesLength: data.messagesLength,
852
+ utilization: data.utilization,
853
+ nearLimit: data.nearLimit,
854
+ });
855
+ }
856
+ }
857
+
858
+ function handleTraceState(data: Record<string, unknown>): void {
859
+ traceEnabled.value = data.enabled === true;
860
+ }
861
+
862
+ function handleThemeChanged(data: Record<string, unknown>): void {
863
+ if (typeof data.theme === 'string') {
864
+ applyCanvasTheme(data.theme);
865
+ }
866
+ }
867
+
868
+ function handleContextPinsChanged(data: Record<string, unknown>): void {
869
+ const nodeIds = Array.isArray(data.nodeIds)
870
+ ? data.nodeIds.filter((id): id is string => typeof id === 'string')
871
+ : [];
872
+ replaceContextPinsFromServer(nodeIds);
873
+ syncAttentionFromSse({ event: 'context-pins-changed', data });
874
+ }
875
+
876
+ // ── SSE connection ────────────────────────────────────────────
877
+ /** @internal — exported for testing */
878
+ export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => void> = {
879
+ connected: handleConnected,
880
+ 'workbench-open': handleWorkbenchOpen,
881
+ 'canvas-status': handleCanvasStatus,
882
+ 'execution-phase': handleExecutionPhase,
883
+ 'context-cards': handleContextCards,
884
+ 'mcp-app-candidate': handleMcpAppCandidate,
885
+ 'mcp-app-host-snapshot': handleMcpAppHostSnapshot,
886
+ 'mcp-app-host-fallback': handleMcpAppHostFallback,
887
+ 'aux-open': handleAuxOpen,
888
+ 'aux-close': handleAuxClose,
889
+ 'assistant-complete': handleAssistantComplete,
890
+ 'tool-start': handleToolStart,
891
+ 'tool-complete': handleToolComplete,
892
+ 'review-state': handleReviewState,
893
+ 'subagent-status': handleSubagentStatus,
894
+ 'ext-app-open': handleExtAppOpen,
895
+ 'ext-app-update': handleExtAppUpdate,
896
+ 'ext-app-result': handleExtAppResult,
897
+ 'context-pins-changed': handleContextPinsChanged,
898
+ 'canvas-layout-update': handleCanvasLayoutUpdate,
899
+ 'canvas-focus-node': handleCanvasFocusNode,
900
+ 'canvas-viewport-update': handleCanvasViewportUpdate,
901
+ 'context-usage': handleContextUsage,
902
+ 'trace-state': handleTraceState,
903
+ 'theme-changed': handleThemeChanged,
904
+ 'canvas-prompt-created': handleCanvasPromptCreated,
905
+ 'canvas-prompt-status': handleCanvasPromptStatus,
906
+ 'canvas-response-start': handleCanvasResponseStart,
907
+ 'canvas-response-delta': handleCanvasResponseDelta,
908
+ 'canvas-response-complete': handleCanvasResponseComplete,
909
+ };
910
+
911
+ export function connectSSE(): () => void {
912
+ savedLayout = restoreLayout();
913
+ ensureStatusNode();
914
+ hasInitialServerLayout.value = false;
915
+ resetAttentionBridge();
916
+ if (reconnectTimer) {
917
+ clearTimeout(reconnectTimer);
918
+ reconnectTimer = null;
919
+ }
920
+
921
+ const sid = sessionId.value;
922
+ const url = sid ? `/api/workbench/events?session=${sid}` : '/api/workbench/events';
923
+ connectionStatus.value = 'connecting';
924
+
925
+ const source = new EventSource(url);
926
+ eventSource = source;
927
+
928
+ for (const [event, handler] of Object.entries(EVENT_HANDLERS)) {
929
+ source.addEventListener(event, (e) => {
930
+ try {
931
+ handler(JSON.parse((e as MessageEvent).data));
932
+ } catch (err) {
933
+ // H5: Surface malformed SSE data during debugging instead of silently swallowing
934
+ console.warn(`[sse-bridge] Failed to parse "${event}" event:`, err);
935
+ }
936
+ });
937
+ }
938
+
939
+ source.onopen = () => {
940
+ if (eventSource !== source) return;
941
+ reconnectAttempts = 0;
942
+ connectionStatus.value = 'connected';
943
+ };
944
+
945
+ source.onerror = () => {
946
+ if (eventSource !== source) return;
947
+ connectionStatus.value = 'disconnected';
948
+ source.close();
949
+ eventSource = null;
950
+ reconnectAttempts += 1;
951
+ reconnectTimer = setTimeout(() => {
952
+ reconnectTimer = null;
953
+ connectSSE();
954
+ }, reconnectDelayMs(reconnectAttempts));
955
+ };
956
+
957
+ return () => {
958
+ if (reconnectTimer) {
959
+ clearTimeout(reconnectTimer);
960
+ reconnectTimer = null;
961
+ }
962
+ source.close();
963
+ eventSource = null;
964
+ };
965
+ }