@abhinav2203/codeflow-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 (134) hide show
  1. package/abhinav2203-codeflow-canvas-0.1.0.tgz +0 -0
  2. package/dist/bin/cli.d.ts +3 -0
  3. package/dist/bin/cli.d.ts.map +1 -0
  4. package/dist/bin/cli.js +84 -0
  5. package/dist/bin/cli.js.map +1 -0
  6. package/dist/components/blueprint-workbench.d.ts +2 -0
  7. package/dist/components/blueprint-workbench.d.ts.map +1 -0
  8. package/dist/components/blueprint-workbench.js +144 -0
  9. package/dist/components/blueprint-workbench.js.map +1 -0
  10. package/dist/components/code-diff-editor.d.ts +12 -0
  11. package/dist/components/code-diff-editor.d.ts.map +1 -0
  12. package/dist/components/code-diff-editor.js +39 -0
  13. package/dist/components/code-diff-editor.js.map +1 -0
  14. package/dist/components/code-editor.d.ts +25 -0
  15. package/dist/components/code-editor.d.ts.map +1 -0
  16. package/dist/components/code-editor.js +264 -0
  17. package/dist/components/code-editor.js.map +1 -0
  18. package/dist/components/file-tabs.d.ts +5 -0
  19. package/dist/components/file-tabs.d.ts.map +1 -0
  20. package/dist/components/file-tabs.js +164 -0
  21. package/dist/components/file-tabs.js.map +1 -0
  22. package/dist/components/file-tree.d.ts +7 -0
  23. package/dist/components/file-tree.d.ts.map +1 -0
  24. package/dist/components/file-tree.js +176 -0
  25. package/dist/components/file-tree.js.map +1 -0
  26. package/dist/components/graph-canvas.d.ts +25 -0
  27. package/dist/components/graph-canvas.d.ts.map +1 -0
  28. package/dist/components/graph-canvas.js +224 -0
  29. package/dist/components/graph-canvas.js.map +1 -0
  30. package/dist/components/ide-layout.d.ts +10 -0
  31. package/dist/components/ide-layout.d.ts.map +1 -0
  32. package/dist/components/ide-layout.js +40 -0
  33. package/dist/components/ide-layout.js.map +1 -0
  34. package/dist/components/ide-workbench.d.ts +4 -0
  35. package/dist/components/ide-workbench.d.ts.map +1 -0
  36. package/dist/components/ide-workbench.js +6 -0
  37. package/dist/components/ide-workbench.js.map +1 -0
  38. package/dist/components/index.d.ts +13 -0
  39. package/dist/components/index.d.ts.map +1 -0
  40. package/dist/components/index.js +13 -0
  41. package/dist/components/index.js.map +1 -0
  42. package/dist/components/monaco-setup.d.ts +4 -0
  43. package/dist/components/monaco-setup.d.ts.map +1 -0
  44. package/dist/components/monaco-setup.js +34 -0
  45. package/dist/components/monaco-setup.js.map +1 -0
  46. package/dist/components/opencode-settings.d.ts +8 -0
  47. package/dist/components/opencode-settings.d.ts.map +1 -0
  48. package/dist/components/opencode-settings.js +33 -0
  49. package/dist/components/opencode-settings.js.map +1 -0
  50. package/dist/components/policy-workbench.d.ts +2 -0
  51. package/dist/components/policy-workbench.d.ts.map +1 -0
  52. package/dist/components/policy-workbench.js +102 -0
  53. package/dist/components/policy-workbench.js.map +1 -0
  54. package/dist/components/ts-language-service.d.ts +14 -0
  55. package/dist/components/ts-language-service.d.ts.map +1 -0
  56. package/dist/components/ts-language-service.js +123 -0
  57. package/dist/components/ts-language-service.js.map +1 -0
  58. package/dist/index.d.ts +23 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +22 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/lib/browser/storage.d.ts +16 -0
  63. package/dist/lib/browser/storage.d.ts.map +1 -0
  64. package/dist/lib/browser/storage.js +18 -0
  65. package/dist/lib/browser/storage.js.map +1 -0
  66. package/dist/lib/edit.d.ts +14 -0
  67. package/dist/lib/edit.d.ts.map +1 -0
  68. package/dist/lib/edit.js +57 -0
  69. package/dist/lib/edit.js.map +1 -0
  70. package/dist/lib/flow-view.d.ts +80 -0
  71. package/dist/lib/flow-view.d.ts.map +1 -0
  72. package/dist/lib/flow-view.js +850 -0
  73. package/dist/lib/flow-view.js.map +1 -0
  74. package/dist/lib/heatmap.d.ts +28 -0
  75. package/dist/lib/heatmap.d.ts.map +1 -0
  76. package/dist/lib/heatmap.js +61 -0
  77. package/dist/lib/heatmap.js.map +1 -0
  78. package/dist/lib/index.d.ts +9 -0
  79. package/dist/lib/index.d.ts.map +1 -0
  80. package/dist/lib/index.js +6 -0
  81. package/dist/lib/index.js.map +1 -0
  82. package/dist/lib/node-navigation.d.ts +36 -0
  83. package/dist/lib/node-navigation.d.ts.map +1 -0
  84. package/dist/lib/node-navigation.js +52 -0
  85. package/dist/lib/node-navigation.js.map +1 -0
  86. package/dist/lib/traces.d.ts +3 -0
  87. package/dist/lib/traces.d.ts.map +1 -0
  88. package/dist/lib/traces.js +64 -0
  89. package/dist/lib/traces.js.map +1 -0
  90. package/dist/lib/types.d.ts +57 -0
  91. package/dist/lib/types.d.ts.map +1 -0
  92. package/dist/lib/types.js +7 -0
  93. package/dist/lib/types.js.map +1 -0
  94. package/dist/store/blueprint-store.d.ts +35 -0
  95. package/dist/store/blueprint-store.d.ts.map +1 -0
  96. package/dist/store/blueprint-store.js +79 -0
  97. package/dist/store/blueprint-store.js.map +1 -0
  98. package/dist/store/index.d.ts +3 -0
  99. package/dist/store/index.d.ts.map +1 -0
  100. package/dist/store/index.js +2 -0
  101. package/dist/store/index.js.map +1 -0
  102. package/package.json +52 -0
  103. package/scripts/wrap-cli.mjs +15 -0
  104. package/src/bin/cli.ts +128 -0
  105. package/src/components/blueprint-workbench.tsx +305 -0
  106. package/src/components/code-diff-editor.tsx +80 -0
  107. package/src/components/code-editor.tsx +389 -0
  108. package/src/components/file-tabs.tsx +288 -0
  109. package/src/components/file-tree.tsx +301 -0
  110. package/src/components/graph-canvas.tsx +404 -0
  111. package/src/components/ide-layout.tsx +104 -0
  112. package/src/components/ide-workbench.tsx +5 -0
  113. package/src/components/index.ts +12 -0
  114. package/src/components/monaco-setup.ts +67 -0
  115. package/src/components/opencode-settings.tsx +82 -0
  116. package/src/components/policy-workbench.tsx +233 -0
  117. package/src/components/ts-language-service.ts +170 -0
  118. package/src/index.ts +54 -0
  119. package/src/lib/browser/storage.ts +19 -0
  120. package/src/lib/edit.ts +74 -0
  121. package/src/lib/flow-view.ts +1176 -0
  122. package/src/lib/heatmap.ts +103 -0
  123. package/src/lib/index.ts +41 -0
  124. package/src/lib/node-navigation.ts +76 -0
  125. package/src/lib/traces.ts +79 -0
  126. package/src/lib/types.ts +79 -0
  127. package/src/store/blueprint-store.ts +136 -0
  128. package/src/store/index.ts +2 -0
  129. package/test-fixtures/minimal-blueprint.json +34 -0
  130. package/test-fixtures/sample-blueprint.json +184 -0
  131. package/tsconfig.build.json +9 -0
  132. package/tsconfig.json +22 -0
  133. package/tsconfig.tsbuildinfo +1 -0
  134. package/vitest.config.ts +9 -0
@@ -0,0 +1,301 @@
1
+ "use client";
2
+
3
+ import path from "node:path";
4
+
5
+ import { useCallback, useEffect, useState } from "react";
6
+
7
+ import { useBlueprintStore } from "../store/blueprint-store.js";
8
+
9
+ type FileNode = {
10
+ path: string;
11
+ name: string;
12
+ isDirectory: boolean;
13
+ };
14
+
15
+ type FileTreeNode = FileNode & {
16
+ children?: FileTreeNode[];
17
+ isExpanded: boolean;
18
+ isLoading: boolean;
19
+ error?: string;
20
+ };
21
+
22
+ type FileTreeProps = {
23
+ onFileSelect: (path: string) => void;
24
+ selectedPath?: string;
25
+ };
26
+
27
+ const FILE_LIST_API_ENDPOINT = "/api/files/list";
28
+
29
+ async function fetchFileList(directoryPath: string, repoPath: string | null): Promise<FileNode[]> {
30
+ const headers: Record<string, string> = { "content-type": "application/json" };
31
+ if (repoPath) {
32
+ headers["x-codeflow-repo-path"] = repoPath;
33
+ }
34
+
35
+ const response = await fetch(FILE_LIST_API_ENDPOINT, {
36
+ method: "POST",
37
+ headers,
38
+ body: JSON.stringify({ path: directoryPath })
39
+ });
40
+
41
+ if (!response.ok) {
42
+ throw new Error(`Failed to fetch file list: ${response.statusText}`);
43
+ }
44
+
45
+ return (await response.json()) as FileNode[];
46
+ }
47
+
48
+ function sortEntries(entries: FileNode[]): FileNode[] {
49
+ return [...entries].sort((a, b) => {
50
+ if (a.isDirectory !== b.isDirectory) {
51
+ return a.isDirectory ? -1 : 1;
52
+ }
53
+
54
+ return a.name.localeCompare(b.name);
55
+ });
56
+ }
57
+
58
+ function createInitialRoot(repoPath: string | null): FileTreeNode {
59
+ return {
60
+ path: ".",
61
+ name: repoPath ? path.basename(repoPath) : "workspace",
62
+ isDirectory: true,
63
+ isExpanded: true,
64
+ isLoading: true,
65
+ children: undefined
66
+ };
67
+ }
68
+
69
+ function getFileBadge(name: string): string {
70
+ const ext = name.split(".").pop()?.toLowerCase();
71
+ switch (ext) {
72
+ case "ts":
73
+ return "TS";
74
+ case "tsx":
75
+ return "TSX";
76
+ case "js":
77
+ return "JS";
78
+ case "jsx":
79
+ return "JSX";
80
+ case "json":
81
+ return "{}";
82
+ case "md":
83
+ return "MD";
84
+ default:
85
+ return "·";
86
+ }
87
+ }
88
+
89
+ function FileTreeItem({
90
+ node,
91
+ depth,
92
+ onToggle,
93
+ onSelect,
94
+ selectedPath
95
+ }: {
96
+ node: FileTreeNode;
97
+ depth: number;
98
+ onToggle: (path: string) => void;
99
+ onSelect: (path: string) => void;
100
+ selectedPath?: string;
101
+ }) {
102
+ const indentationStyle = { paddingLeft: `${depth * 16 + 8}px` };
103
+
104
+ return (
105
+ <div className="file-tree-item">
106
+ <div
107
+ aria-expanded={node.isDirectory ? node.isExpanded : undefined}
108
+ aria-selected={!node.isDirectory ? node.path === selectedPath : undefined}
109
+ className={`file-tree-row ${node.isDirectory ? "directory" : "file"} ${!node.isDirectory ? "selectable" : ""}`}
110
+ onClick={() => (node.isDirectory ? onToggle(node.path) : onSelect(node.path))}
111
+ onKeyDown={(event) => {
112
+ if (event.key === "Enter" || event.key === " ") {
113
+ event.preventDefault();
114
+ if (node.isDirectory) {
115
+ onToggle(node.path);
116
+ } else {
117
+ onSelect(node.path);
118
+ }
119
+ }
120
+ }}
121
+ role="treeitem"
122
+ style={indentationStyle}
123
+ tabIndex={0}
124
+ >
125
+ {node.isDirectory ? (
126
+ <span className={`file-tree-chevron ${node.isExpanded ? "expanded" : ""}`}>
127
+ {node.isLoading ? "◌" : node.isExpanded ? "▼" : "▶"}
128
+ </span>
129
+ ) : null}
130
+ <span className={`file-tree-icon ${node.isDirectory ? "is-directory" : "is-file"}`}>
131
+ {node.isDirectory ? (node.isExpanded ? "dir" : "dir") : getFileBadge(node.name)}
132
+ </span>
133
+ <span className={`file-tree-name ${!node.isDirectory ? "file-name" : ""}`}>{node.name}</span>
134
+ </div>
135
+
136
+ {node.isDirectory && node.isExpanded ? (
137
+ <div className="file-tree-children" role="group">
138
+ {node.isLoading ? (
139
+ <div className="file-tree-loading" style={indentationStyle}>
140
+ Loading...
141
+ </div>
142
+ ) : node.error ? (
143
+ <div className="file-tree-error" style={indentationStyle}>
144
+ {node.error}
145
+ </div>
146
+ ) : node.children?.length ? (
147
+ node.children.map((child) => (
148
+ <FileTreeItem
149
+ key={child.path}
150
+ depth={depth + 1}
151
+ node={child}
152
+ onSelect={onSelect}
153
+ onToggle={onToggle}
154
+ selectedPath={selectedPath}
155
+ />
156
+ ))
157
+ ) : (
158
+ <div className="file-tree-empty" style={indentationStyle}>
159
+ Empty folder
160
+ </div>
161
+ )}
162
+ </div>
163
+ ) : null}
164
+ </div>
165
+ );
166
+ }
167
+
168
+ export function FileTree({ onFileSelect, selectedPath }: FileTreeProps) {
169
+ const { repoPath } = useBlueprintStore();
170
+ const [rootNode, setRootNode] = useState<FileTreeNode>(() => createInitialRoot(repoPath));
171
+
172
+ useEffect(() => {
173
+ let cancelled = false;
174
+
175
+ void fetchFileList(".", repoPath)
176
+ .then((entries) => {
177
+ if (cancelled) {
178
+ return;
179
+ }
180
+
181
+ setRootNode({
182
+ ...createInitialRoot(repoPath),
183
+ isLoading: false,
184
+ children: sortEntries(entries).map((entry) => ({
185
+ ...entry,
186
+ isExpanded: false,
187
+ isLoading: false
188
+ }))
189
+ });
190
+ })
191
+ .catch((error) => {
192
+ if (cancelled) {
193
+ return;
194
+ }
195
+
196
+ setRootNode({
197
+ ...createInitialRoot(repoPath),
198
+ isLoading: false,
199
+ error: error instanceof Error ? error.message : "Failed to load"
200
+ });
201
+ });
202
+
203
+ return () => {
204
+ cancelled = true;
205
+ };
206
+ }, [repoPath]);
207
+
208
+ const expandNode = useCallback(
209
+ (pathToExpand: string) => {
210
+ setRootNode((prevRoot) => {
211
+ const updateNode = (node: FileTreeNode): FileTreeNode => {
212
+ if (node.path !== pathToExpand) {
213
+ if (node.children) {
214
+ return { ...node, children: node.children.map(updateNode) };
215
+ }
216
+
217
+ return node;
218
+ }
219
+
220
+ if (!node.isDirectory || node.isLoading) {
221
+ return node;
222
+ }
223
+
224
+ if (node.children !== undefined) {
225
+ return { ...node, isExpanded: !node.isExpanded };
226
+ }
227
+
228
+ void (async () => {
229
+ try {
230
+ const files = sortEntries(await fetchFileList(pathToExpand, repoPath));
231
+ setRootNode((currentRoot) => {
232
+ const withChildren = (currentNode: FileTreeNode): FileTreeNode => {
233
+ if (currentNode.path !== pathToExpand) {
234
+ if (currentNode.children) {
235
+ return { ...currentNode, children: currentNode.children.map(withChildren) };
236
+ }
237
+
238
+ return currentNode;
239
+ }
240
+
241
+ return {
242
+ ...currentNode,
243
+ error: undefined,
244
+ isExpanded: true,
245
+ isLoading: false,
246
+ children: files.map((file) => ({
247
+ ...file,
248
+ isExpanded: false,
249
+ isLoading: false
250
+ }))
251
+ };
252
+ };
253
+
254
+ return withChildren(currentRoot);
255
+ });
256
+ } catch (error) {
257
+ const errorMessage = error instanceof Error ? error.message : "Failed to load";
258
+ setRootNode((currentRoot) => {
259
+ const withError = (currentNode: FileTreeNode): FileTreeNode => {
260
+ if (currentNode.path !== pathToExpand) {
261
+ if (currentNode.children) {
262
+ return { ...currentNode, children: currentNode.children.map(withError) };
263
+ }
264
+
265
+ return currentNode;
266
+ }
267
+
268
+ return {
269
+ ...currentNode,
270
+ error: errorMessage,
271
+ isExpanded: true,
272
+ isLoading: false
273
+ };
274
+ };
275
+
276
+ return withError(currentRoot);
277
+ });
278
+ }
279
+ })();
280
+
281
+ return { ...node, error: undefined, isExpanded: true, isLoading: true };
282
+ };
283
+
284
+ return updateNode(prevRoot);
285
+ });
286
+ },
287
+ [repoPath]
288
+ );
289
+
290
+ return (
291
+ <div className="file-tree" role="tree">
292
+ <FileTreeItem
293
+ depth={0}
294
+ node={rootNode}
295
+ onSelect={onFileSelect}
296
+ onToggle={expandNode}
297
+ selectedPath={selectedPath}
298
+ />
299
+ </div>
300
+ );
301
+ }
@@ -0,0 +1,404 @@
1
+ "use client";
2
+
3
+ import { memo, useEffect } from "react";
4
+
5
+ import type { Edge, Node, NodeProps } from "@xyflow/react";
6
+ import {
7
+ Background,
8
+ Controls,
9
+ Handle,
10
+ MarkerType,
11
+ MiniMap,
12
+ Position,
13
+ ReactFlow,
14
+ ReactFlowProvider,
15
+ useReactFlow
16
+ } from "@xyflow/react";
17
+
18
+ import type { HeatmapData } from "../lib/heatmap.js";
19
+ import type { BlueprintGraph, GhostNode } from "@abhinav2203/codeflow-core/schema";
20
+ import { buildExecutionProjection, buildFlowEdges, buildFlowNodes, buildGhostFlowNodes } from "../lib/flow-view.js";
21
+ import type {
22
+ FlowExecutionProjection,
23
+ FlowExecutionState,
24
+ FlowExecutionStatus,
25
+ FlowNodeData
26
+ } from "../lib/flow-view.js";
27
+ import type { RuntimeExecutionResult } from "@abhinav2203/codeflow-core/schema";
28
+
29
+ type GraphCanvasProps = {
30
+ graph: BlueprintGraph | null;
31
+ selectedNodeId: string | null;
32
+ onSelect: (nodeId: string) => void;
33
+ nodes?: Array<Node<FlowNodeData>>;
34
+ edges?: Edge[];
35
+ onNodeDoubleClick?: (nodeId: string) => void;
36
+ emptyMessage?: string;
37
+ ghostNodes?: GhostNode[];
38
+ onGhostNodeClick?: (ghost: GhostNode) => void;
39
+ heatmapData?: HeatmapData;
40
+ activeNodeIds?: string[];
41
+ driftedNodeIds?: string[];
42
+ executionResult?: RuntimeExecutionResult | null;
43
+ detailMode?: boolean;
44
+ theme?: "light" | "dark";
45
+ };
46
+
47
+ const TRACE_STATUS_LABEL: Record<FlowNodeData["traceStatus"], string> = {
48
+ idle: "Ready",
49
+ success: "Synced",
50
+ warning: "Deploying",
51
+ error: "Invalid"
52
+ };
53
+
54
+ const EXECUTION_STATUS_LABEL: Record<FlowExecutionStatus, string> = {
55
+ idle: "Idle",
56
+ running: "Running",
57
+ pending: "Pending",
58
+ passed: "Passed",
59
+ failed: "Failed",
60
+ blocked: "Blocked",
61
+ skipped: "Skipped",
62
+ warning: "Warning"
63
+ };
64
+
65
+ const EXECUTION_STATUS_TONE: Record<Exclude<FlowExecutionStatus, "idle">, string> = {
66
+ running: "#2563eb",
67
+ pending: "#64748b",
68
+ passed: "#15803d",
69
+ failed: "#dc2626",
70
+ blocked: "#d97706",
71
+ skipped: "#64748b",
72
+ warning: "#c2410c"
73
+ };
74
+
75
+ const HEALTH_STATUS_LABEL: Record<FlowNodeData["healthState"], string> = {
76
+ neutral: "Stable",
77
+ aligned: "Aligned",
78
+ drift: "Drift",
79
+ heal: "Heal",
80
+ ghost: "Ghost"
81
+ };
82
+
83
+ const mergeClassNames = (...classNames: Array<string | undefined>) =>
84
+ classNames.filter(Boolean).join(" ").trim();
85
+
86
+ const getExecutionTone = (status?: FlowExecutionStatus): string | undefined => {
87
+ if (!status || status === "idle") {
88
+ return undefined;
89
+ }
90
+
91
+ return EXECUTION_STATUS_TONE[status];
92
+ };
93
+
94
+ const resolveExecutionFromNode = (
95
+ node: Node<FlowNodeData>,
96
+ projection: FlowExecutionProjection | null,
97
+ graph?: BlueprintGraph | null
98
+ ): FlowExecutionState | undefined => {
99
+ if (node.data.execution && node.data.execution.status !== "idle") {
100
+ return node.data.execution;
101
+ }
102
+
103
+ if (!projection) {
104
+ return undefined;
105
+ }
106
+
107
+ if (node.data.drilldownNodeId && projection.nodeStates[node.data.drilldownNodeId]?.status) {
108
+ return projection.nodeStates[node.data.drilldownNodeId];
109
+ }
110
+
111
+ if (projection.nodeStates[node.id]?.status) {
112
+ return projection.nodeStates[node.id];
113
+ }
114
+
115
+ if (node.id.startsWith("detail:root:")) {
116
+ const rootNodeId = node.id.slice("detail:root:".length);
117
+ return projection.nodeStates[rootNodeId];
118
+ }
119
+
120
+ if (node.id.startsWith("detail:blueprint:")) {
121
+ const blueprintNodeId = node.id.slice("detail:blueprint:".length);
122
+ return projection.nodeStates[blueprintNodeId];
123
+ }
124
+
125
+ if (node.id.startsWith("detail:method:") && graph) {
126
+ const suffix = node.id.slice("detail:method:".length);
127
+ const lastSeparator = suffix.lastIndexOf(":");
128
+ const rootNodeId = lastSeparator >= 0 ? suffix.slice(0, lastSeparator) : suffix;
129
+ return projection.nodeStates[rootNodeId];
130
+ }
131
+
132
+ return undefined;
133
+ };
134
+
135
+ const PolicyNode = memo(function PolicyNode({ data, selected }: NodeProps<Node<FlowNodeData>>) {
136
+ const executionStatus = data.execution?.status ?? "idle";
137
+ const executionTone = getExecutionTone(executionStatus);
138
+
139
+ return (
140
+ <>
141
+ <Handle className="policy-node-handle" position={Position.Left} type="target" />
142
+ <div
143
+ className={[
144
+ "policy-node-card",
145
+ `policy-node-${data.traceStatus}`,
146
+ `policy-node-health-${data.healthState}`,
147
+ executionStatus !== "idle" ? `policy-node-execution-${executionStatus}` : "",
148
+ data.isActiveBatch ? "is-batch-focus" : "",
149
+ data.isGhost ? "is-ghost" : "",
150
+ selected ? "is-selected" : ""
151
+ ]
152
+ .filter(Boolean)
153
+ .join(" ")}
154
+ >
155
+ <div className="policy-node-topline">
156
+ <span className="policy-node-kind">{data.kind}</span>
157
+ <div className="policy-node-pills">
158
+ {data.isActiveBatch ? <span className="policy-node-badge policy-node-badge-batch">Batch focus</span> : null}
159
+ <span className="policy-node-badge policy-node-badge-health">{HEALTH_STATUS_LABEL[data.healthState]}</span>
160
+ {executionStatus !== "idle" ? (
161
+ <span
162
+ className={`policy-node-badge policy-node-badge-execution policy-node-badge-execution-${executionStatus}`}
163
+ style={executionTone ? { borderColor: executionTone, color: executionTone } : undefined}
164
+ >
165
+ {EXECUTION_STATUS_LABEL[executionStatus]}
166
+ </span>
167
+ ) : null}
168
+ <span className="policy-node-status">{TRACE_STATUS_LABEL[data.traceStatus]}</span>
169
+ </div>
170
+ </div>
171
+ <h3>{data.label}</h3>
172
+ <p>{data.summary || "Select this node to inspect its policy contract, runtime, and generated implementation."}</p>
173
+ {data.execution?.message ? <p className="policy-node-execution-message">{data.execution.message}</p> : null}
174
+ <div className="policy-node-footer">
175
+ <span>{data.drilldownNodeId ? "Double-click for internals" : "Click to inspect"}</span>
176
+ {data.selected ? <span>Focused</span> : null}
177
+ </div>
178
+ </div>
179
+ <Handle className="policy-node-handle" position={Position.Right} type="source" />
180
+ </>
181
+ );
182
+ });
183
+
184
+ const nodeTypes = {
185
+ policyNode: PolicyNode
186
+ };
187
+
188
+ function GraphViewportSync({
189
+ edgeCount,
190
+ nodeCount
191
+ }: {
192
+ edgeCount: number;
193
+ nodeCount: number;
194
+ }) {
195
+ const { fitView } = useReactFlow();
196
+
197
+ useEffect(() => {
198
+ if (!nodeCount) {
199
+ return;
200
+ }
201
+
202
+ const frameId = window.requestAnimationFrame(() => {
203
+ void fitView({ duration: 220, padding: 0.18 });
204
+ });
205
+
206
+ return () => window.cancelAnimationFrame(frameId);
207
+ }, [edgeCount, fitView, nodeCount]);
208
+
209
+ return null;
210
+ }
211
+
212
+ export function GraphCanvas({
213
+ graph,
214
+ selectedNodeId,
215
+ onSelect,
216
+ nodes,
217
+ edges,
218
+ onNodeDoubleClick,
219
+ emptyMessage,
220
+ ghostNodes,
221
+ onGhostNodeClick,
222
+ heatmapData,
223
+ activeNodeIds,
224
+ driftedNodeIds,
225
+ executionResult,
226
+ detailMode = false,
227
+ theme = "light"
228
+ }: GraphCanvasProps) {
229
+ const executionProjection = graph ? buildExecutionProjection(graph, executionResult) : null;
230
+ const baseFlowNodes =
231
+ nodes ??
232
+ (graph
233
+ ? buildFlowNodes(graph, selectedNodeId ?? undefined, heatmapData, activeNodeIds, driftedNodeIds, executionResult)
234
+ : []);
235
+ const typedBaseFlowNodes = baseFlowNodes.map((node) => {
236
+ const execution = executionProjection ? resolveExecutionFromNode(node, executionProjection, graph) : undefined;
237
+
238
+ return {
239
+ ...node,
240
+ type: node.type ?? "policyNode",
241
+ data: execution
242
+ ? {
243
+ ...node.data,
244
+ execution: node.data.execution ?? execution
245
+ }
246
+ : node.data
247
+ };
248
+ });
249
+ const ghostFlowNodes =
250
+ ghostNodes && ghostNodes.length > 0 ? buildGhostFlowNodes(ghostNodes, typedBaseFlowNodes) : [];
251
+ const flowNodes = [...typedBaseFlowNodes, ...ghostFlowNodes];
252
+ const flowEdges = edges ?? (graph ? buildFlowEdges(graph, activeNodeIds, executionResult) : []);
253
+
254
+ const nodeMap = new Map(flowNodes.map((node) => [node.id, node]));
255
+
256
+ const decoratedFlowEdges = flowEdges.map((edge) => {
257
+ const execution = executionProjection?.edgeStates[edge.id];
258
+ const sourceNode = nodeMap.get(edge.source);
259
+ const targetNode = nodeMap.get(edge.target);
260
+ const inferredStatus =
261
+ execution?.status && execution.status !== "idle"
262
+ ? execution.status
263
+ : sourceNode?.data.execution?.status === "failed" || targetNode?.data.execution?.status === "failed"
264
+ ? "failed"
265
+ : sourceNode?.data.execution?.status === "blocked" || targetNode?.data.execution?.status === "blocked"
266
+ ? "blocked"
267
+ : sourceNode?.data.execution?.status === "running" || targetNode?.data.execution?.status === "running"
268
+ ? "running"
269
+ : sourceNode?.data.execution?.status === "warning" || targetNode?.data.execution?.status === "warning"
270
+ ? "warning"
271
+ : sourceNode?.data.execution?.status === "passed" && targetNode?.data.execution?.status === "passed"
272
+ ? "passed"
273
+ : sourceNode?.data.execution?.status === "skipped" || targetNode?.data.execution?.status === "skipped"
274
+ ? "skipped"
275
+ : undefined;
276
+
277
+ if (!inferredStatus) {
278
+ return edge;
279
+ }
280
+
281
+ const executionTone = getExecutionTone(inferredStatus);
282
+
283
+ return {
284
+ ...edge,
285
+ className: mergeClassNames(edge.className, `edge-flow-${inferredStatus}`),
286
+ animated: edge.animated || inferredStatus === "running",
287
+ style: {
288
+ ...edge.style,
289
+ stroke: executionTone ?? edge.style?.stroke,
290
+ strokeDasharray:
291
+ inferredStatus === "blocked"
292
+ ? "6 5"
293
+ : inferredStatus === "running"
294
+ ? "4 4"
295
+ : inferredStatus === "warning"
296
+ ? "8 4"
297
+ : inferredStatus === "skipped"
298
+ ? "10 6"
299
+ : edge.style?.strokeDasharray
300
+ }
301
+ };
302
+ });
303
+
304
+ if (!graph && flowNodes.length === 0) {
305
+ return (
306
+ <div className="canvas-empty">
307
+ <p>{emptyMessage ?? "Build a blueprint from an AI prompt, PRD text, or a JavaScript/TypeScript repo."}</p>
308
+ </div>
309
+ );
310
+ }
311
+
312
+ const handleNodeClick = (_: React.MouseEvent, node: Node<FlowNodeData>) => {
313
+ if (node.data.ghost && onGhostNodeClick) {
314
+ const ghost = ghostNodes?.find((g) => g.id === node.id);
315
+ if (ghost) {
316
+ onGhostNodeClick(ghost);
317
+ return;
318
+ }
319
+ }
320
+
321
+ onSelect(node.id);
322
+ };
323
+
324
+ return (
325
+ <ReactFlowProvider>
326
+ <div className={`canvas-shell ${detailMode ? "canvas-shell-detail" : ""}`}>
327
+ <ReactFlow
328
+ fitView
329
+ fitViewOptions={{ padding: 0.18 }}
330
+ minZoom={0.35}
331
+ maxZoom={1.8}
332
+ nodes={flowNodes}
333
+ edges={decoratedFlowEdges}
334
+ nodeTypes={nodeTypes}
335
+ className="graph-flow"
336
+ defaultEdgeOptions={{
337
+ markerEnd: {
338
+ type: MarkerType.ArrowClosed,
339
+ color: theme === "dark" ? "#6fe0d8" : "#15786f"
340
+ }
341
+ }}
342
+ onNodeClick={handleNodeClick}
343
+ onNodeDoubleClick={(_, node) => onNodeDoubleClick?.(node.id)}
344
+ >
345
+ <GraphViewportSync edgeCount={decoratedFlowEdges.length} nodeCount={flowNodes.length} />
346
+ <MiniMap
347
+ pannable
348
+ zoomable
349
+ nodeBorderRadius={10}
350
+ maskColor={theme === "dark" ? "rgba(5, 10, 20, 0.76)" : "rgba(255, 255, 255, 0.75)"}
351
+ nodeColor={(node) => {
352
+ const data = (node as Node<FlowNodeData>).data;
353
+ const executionColor = getExecutionTone(data?.execution?.status);
354
+
355
+ if (executionColor) {
356
+ return executionColor;
357
+ }
358
+
359
+ if (data?.isActiveBatch) {
360
+ return theme === "dark" ? "#67e2db" : "#15786f";
361
+ }
362
+
363
+ switch (data?.healthState) {
364
+ case "aligned":
365
+ return theme === "dark" ? "#4ade80" : "#15803d";
366
+ case "drift":
367
+ return theme === "dark" ? "#fbbf24" : "#c67a00";
368
+ case "heal":
369
+ return theme === "dark" ? "#fb7185" : "#cf3b57";
370
+ case "ghost":
371
+ return theme === "dark" ? "#64748b" : "#94a3b8";
372
+ default:
373
+ return theme === "dark" ? "#27456c" : "#d7e7fc";
374
+ }
375
+ }}
376
+ nodeStrokeColor={(node) => {
377
+ const data = (node as Node<FlowNodeData>).data;
378
+ const executionColor = getExecutionTone(data?.execution?.status);
379
+
380
+ if (executionColor) {
381
+ return executionColor;
382
+ }
383
+
384
+ return data?.isActiveBatch
385
+ ? theme === "dark"
386
+ ? "#a7fff8"
387
+ : "#0f766e"
388
+ : theme === "dark"
389
+ ? "rgba(167, 194, 236, 0.45)"
390
+ : "rgba(44, 66, 101, 0.22)";
391
+ }}
392
+ nodeStrokeWidth={2}
393
+ />
394
+ <Controls />
395
+ <Background
396
+ color={theme === "dark" ? "rgba(138, 173, 222, 0.12)" : "rgba(26, 42, 67, 0.08)"}
397
+ gap={24}
398
+ size={1.2}
399
+ />
400
+ </ReactFlow>
401
+ </div>
402
+ </ReactFlowProvider>
403
+ );
404
+ }