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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/Readme.md +865 -0
- package/dist/canvas/global.css +3173 -0
- package/dist/canvas/index.js +183 -0
- package/dist/json-render/index.css +2 -0
- package/dist/json-render/index.js +389 -0
- package/dist/types/cli/agent.d.ts +13 -0
- package/dist/types/cli/index.d.ts +2 -0
- package/dist/types/cli/watch.d.ts +5 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
- package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
- package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
- package/dist/types/client/canvas/DockedNode.d.ts +4 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
- package/dist/types/client/canvas/Minimap.d.ts +23 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
- package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
- package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
- package/dist/types/client/canvas/snap-guides.d.ts +23 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
- package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
- package/dist/types/client/ext-app/bridge.d.ts +161 -0
- package/dist/types/client/icons.d.ts +70 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/nodes/ContextNode.d.ts +34 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
- package/dist/types/client/nodes/FileNode.d.ts +5 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -0
- package/dist/types/client/nodes/ImageNode.d.ts +10 -0
- package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
- package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
- package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
- package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
- package/dist/types/client/nodes/PromptNode.d.ts +5 -0
- package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +4 -0
- package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
- package/dist/types/client/nodes/TraceNode.d.ts +4 -0
- package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
- package/dist/types/client/nodes/image-warnings.d.ts +6 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
- package/dist/types/client/nodes/md-format.d.ts +25 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/attention-store.d.ts +25 -0
- package/dist/types/client/state/canvas-store.d.ts +74 -0
- package/dist/types/client/state/intent-bridge.d.ts +158 -0
- package/dist/types/client/state/sse-bridge.d.ts +5 -0
- package/dist/types/client/theme/tokens.d.ts +27 -0
- package/dist/types/client/types.d.ts +40 -0
- package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
- package/dist/types/client/utils/placement.d.ts +1 -0
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +815 -0
- package/dist/types/json-render/charts/components.d.ts +54 -0
- package/dist/types/json-render/charts/definitions.d.ts +103 -0
- package/dist/types/json-render/charts/extra-components.d.ts +58 -0
- package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
- package/dist/types/json-render/renderer/index.d.ts +16 -0
- package/dist/types/json-render/schema.d.ts +46 -0
- package/dist/types/json-render/server.d.ts +55 -0
- package/dist/types/mcp/server.d.ts +22 -0
- package/dist/types/server/agent-context.d.ts +21 -0
- package/dist/types/server/artifact-paths.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +154 -0
- package/dist/types/server/canvas-provenance.d.ts +13 -0
- package/dist/types/server/canvas-schema.d.ts +49 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -0
- package/dist/types/server/canvas-state.d.ts +174 -0
- package/dist/types/server/canvas-validation.d.ts +33 -0
- package/dist/types/server/chart-template.d.ts +29 -0
- package/dist/types/server/code-graph.d.ts +67 -0
- package/dist/types/server/context-cards.d.ts +24 -0
- package/dist/types/server/diagram-presets.d.ts +28 -0
- package/dist/types/server/ext-app-call-registry.d.ts +16 -0
- package/dist/types/server/ext-app-tool-result.d.ts +1 -0
- package/dist/types/server/file-watcher.d.ts +16 -0
- package/dist/types/server/index.d.ts +243 -0
- package/dist/types/server/mcp-app-candidate.d.ts +25 -0
- package/dist/types/server/mcp-app-host.d.ts +65 -0
- package/dist/types/server/mcp-app-runtime.d.ts +47 -0
- package/dist/types/server/mutation-history.d.ts +105 -0
- package/dist/types/server/placement.d.ts +37 -0
- package/dist/types/server/server.d.ts +103 -0
- package/dist/types/server/spatial-analysis.d.ts +87 -0
- package/dist/types/server/trace-manager.d.ts +48 -0
- package/dist/types/server/web-artifacts.d.ts +50 -0
- package/dist/types/server/webpage-node.d.ts +25 -0
- package/dist/types/shared/auto-arrange.d.ts +29 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
- package/dist/types/shared/placement.d.ts +26 -0
- package/dist/types/shared/semantic-attention.d.ts +97 -0
- package/package.json +109 -0
- package/skills/data-analysis/SKILL.md +324 -0
- package/skills/doc-coauthoring/SKILL.md +375 -0
- package/skills/frontend-design/SKILL.md +45 -0
- package/skills/json-render-codegen/SKILL.md +112 -0
- package/skills/json-render-core/SKILL.md +265 -0
- package/skills/json-render-ink/SKILL.md +273 -0
- package/skills/json-render-mcp/SKILL.md +132 -0
- package/skills/json-render-react/SKILL.md +264 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/playwright-cli/SKILL.md +67 -0
- package/skills/pmx-canvas/SKILL.md +668 -0
- package/skills/pmx-canvas/evals/evals.json +186 -0
- package/skills/pmx-canvas-testing/SKILL.md +78 -0
- package/skills/published-consumer-e2e/SKILL.md +43 -0
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
- package/skills/web-artifacts-builder/SKILL.md +80 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/src/cli/agent.ts +2144 -0
- package/src/cli/index.ts +622 -0
- package/src/cli/watch.ts +88 -0
- package/src/client/App.tsx +507 -0
- package/src/client/canvas/AttentionHistory.tsx +81 -0
- package/src/client/canvas/AttentionToast.tsx +19 -0
- package/src/client/canvas/CanvasNode.tsx +363 -0
- package/src/client/canvas/CanvasViewport.tsx +590 -0
- package/src/client/canvas/CommandPalette.tsx +302 -0
- package/src/client/canvas/ContextMenu.tsx +601 -0
- package/src/client/canvas/ContextPinBar.tsx +25 -0
- package/src/client/canvas/ContextPinHud.tsx +22 -0
- package/src/client/canvas/DockedNode.tsx +66 -0
- package/src/client/canvas/EdgeLayer.tsx +280 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
- package/src/client/canvas/FocusFieldLayer.tsx +107 -0
- package/src/client/canvas/Minimap.tsx +301 -0
- package/src/client/canvas/SelectionBar.tsx +69 -0
- package/src/client/canvas/ShortcutOverlay.tsx +69 -0
- package/src/client/canvas/SnapshotPanel.tsx +236 -0
- package/src/client/canvas/snap-guides.ts +170 -0
- package/src/client/canvas/use-node-drag.ts +51 -0
- package/src/client/canvas/use-node-resize.ts +59 -0
- package/src/client/canvas/use-pan-zoom.ts +191 -0
- package/src/client/ext-app/bridge.ts +542 -0
- package/src/client/icons.tsx +424 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ContextNode.tsx +412 -0
- package/src/client/nodes/ExtAppFrame.tsx +509 -0
- package/src/client/nodes/FileNode.tsx +256 -0
- package/src/client/nodes/GroupNode.tsx +39 -0
- package/src/client/nodes/ImageNode.tsx +160 -0
- package/src/client/nodes/InlineFormatBar.tsx +169 -0
- package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
- package/src/client/nodes/LedgerNode.tsx +37 -0
- package/src/client/nodes/MarkdownNode.tsx +359 -0
- package/src/client/nodes/McpAppNode.tsx +85 -0
- package/src/client/nodes/MdFormatBar.tsx +109 -0
- package/src/client/nodes/PromptNode.tsx +597 -0
- package/src/client/nodes/ResponseNode.tsx +153 -0
- package/src/client/nodes/StatusNode.tsx +84 -0
- package/src/client/nodes/StatusSummary.tsx +38 -0
- package/src/client/nodes/TraceNode.tsx +120 -0
- package/src/client/nodes/WebpageNode.tsx +288 -0
- package/src/client/nodes/image-warnings.ts +95 -0
- package/src/client/nodes/inline-editor-commands.ts +37 -0
- package/src/client/nodes/md-format.ts +206 -0
- package/src/client/state/attention-bridge.ts +328 -0
- package/src/client/state/attention-store.ts +73 -0
- package/src/client/state/canvas-store.ts +631 -0
- package/src/client/state/intent-bridge.ts +315 -0
- package/src/client/state/sse-bridge.ts +965 -0
- package/src/client/theme/global.css +3173 -0
- package/src/client/theme/tokens.ts +72 -0
- package/src/client/types-shims.d.ts +5 -0
- package/src/client/types.ts +81 -0
- package/src/client/utils/ext-app-tool-result.ts +4 -0
- package/src/client/utils/placement.ts +4 -0
- package/src/client/utils/platform.ts +2 -0
- package/src/json-render/catalog.ts +256 -0
- package/src/json-render/charts/components.tsx +198 -0
- package/src/json-render/charts/definitions.ts +81 -0
- package/src/json-render/charts/extra-components.tsx +267 -0
- package/src/json-render/charts/extra-definitions.ts +145 -0
- package/src/json-render/renderer/index.css +174 -0
- package/src/json-render/renderer/index.tsx +86 -0
- package/src/json-render/schema.ts +62 -0
- package/src/json-render/server.ts +597 -0
- package/src/mcp/server.ts +1377 -0
- package/src/server/agent-context.ts +242 -0
- package/src/server/artifact-paths.ts +17 -0
- package/src/server/canvas-operations.ts +1279 -0
- package/src/server/canvas-provenance.ts +243 -0
- package/src/server/canvas-schema.ts +432 -0
- package/src/server/canvas-serialization.ts +95 -0
- package/src/server/canvas-state.ts +1134 -0
- package/src/server/canvas-validation.ts +114 -0
- package/src/server/chart-template.ts +449 -0
- package/src/server/code-graph.ts +370 -0
- package/src/server/context-cards.ts +31 -0
- package/src/server/diagram-presets.ts +71 -0
- package/src/server/ext-app-call-registry.ts +77 -0
- package/src/server/ext-app-tool-result.ts +4 -0
- package/src/server/file-watcher.ts +121 -0
- package/src/server/index.ts +647 -0
- package/src/server/mcp-app-candidate.ts +174 -0
- package/src/server/mcp-app-host.ts +814 -0
- package/src/server/mcp-app-runtime.ts +459 -0
- package/src/server/mutation-history.ts +350 -0
- package/src/server/placement.ts +125 -0
- package/src/server/server.ts +3846 -0
- package/src/server/spatial-analysis.ts +356 -0
- package/src/server/trace-manager.ts +333 -0
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
- package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
- package/src/server/web-artifacts.ts +442 -0
- package/src/server/webpage-node.ts +328 -0
- package/src/shared/auto-arrange.ts +439 -0
- package/src/shared/ext-app-tool-result.ts +76 -0
- package/src/shared/placement.ts +81 -0
- 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
|
+
}
|