agent-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 (65) hide show
  1. package/bin/agent-canvas.js +14 -0
  2. package/bin/agent-canvas.ts +377 -0
  3. package/daemon/build.ts +185 -0
  4. package/daemon/client/AnnotationProvider.tsx +185 -0
  5. package/daemon/client/AnnotationSidebar.tsx +314 -0
  6. package/daemon/client/App.tsx +611 -0
  7. package/daemon/client/CompareView.tsx +333 -0
  8. package/daemon/client/FileBrowser.tsx +188 -0
  9. package/daemon/client/FileIcon.tsx +76 -0
  10. package/daemon/client/FileViewer.tsx +207 -0
  11. package/daemon/client/PlanRenderer.tsx +665 -0
  12. package/daemon/client/Popover.tsx +285 -0
  13. package/daemon/client/ResponsePreview.tsx +260 -0
  14. package/daemon/client/RevisionSelect.tsx +97 -0
  15. package/daemon/client/SessionContext.tsx +3 -0
  16. package/daemon/client/SessionSwitcher.tsx +72 -0
  17. package/daemon/client/annotationContext.ts +114 -0
  18. package/daemon/client/components/Callout.tsx +25 -0
  19. package/daemon/client/components/Checklist.tsx +37 -0
  20. package/daemon/client/components/Choice.tsx +164 -0
  21. package/daemon/client/components/CodeBlock.tsx +35 -0
  22. package/daemon/client/components/Diff.tsx +65 -0
  23. package/daemon/client/components/FilePreview.tsx +88 -0
  24. package/daemon/client/components/Image.tsx +45 -0
  25. package/daemon/client/components/Mermaid.tsx +277 -0
  26. package/daemon/client/components/Note.tsx +13 -0
  27. package/daemon/client/components/Priority.tsx +19 -0
  28. package/daemon/client/components/ResponseNote.tsx +24 -0
  29. package/daemon/client/components/Section.tsx +32 -0
  30. package/daemon/client/components/Table.tsx +35 -0
  31. package/daemon/client/components/Task.tsx +75 -0
  32. package/daemon/client/components/UserInput.tsx +180 -0
  33. package/daemon/client/components/index.ts +15 -0
  34. package/daemon/client/domDiff.ts +336 -0
  35. package/daemon/client/exportMarkdown.ts +267 -0
  36. package/daemon/client/generateMarkdown.ts +194 -0
  37. package/daemon/client/highlightRange.ts +233 -0
  38. package/daemon/client/index.html +26 -0
  39. package/daemon/client/lcs.ts +19 -0
  40. package/daemon/client/popoverPosition.ts +31 -0
  41. package/daemon/client/runtime.ts +89 -0
  42. package/daemon/client/styles.css +306 -0
  43. package/daemon/client/theme.css +66 -0
  44. package/daemon/client/unifiedDiff.ts +604 -0
  45. package/daemon/dist/client.css +2 -0
  46. package/daemon/dist/client.js +68 -0
  47. package/daemon/dist/components.js +5 -0
  48. package/daemon/dist/index.html +108 -0
  49. package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
  50. package/daemon/dist/jsx-runtime-shim.js +17 -0
  51. package/daemon/dist/react-dom-client-shim.js +4 -0
  52. package/daemon/dist/react-dom-shim.js +4 -0
  53. package/daemon/dist/react-shim.js +4 -0
  54. package/daemon/dist/runtime.js +1 -0
  55. package/daemon/dist/theme.css +66 -0
  56. package/daemon/src/compiler.ts +92 -0
  57. package/daemon/src/paths.ts +22 -0
  58. package/daemon/src/server.ts +448 -0
  59. package/daemon/src/session.ts +274 -0
  60. package/daemon/src/watcher.ts +59 -0
  61. package/hooks/session-start.sh +11 -0
  62. package/package.json +39 -0
  63. package/skills/canvas/SKILL.md +174 -0
  64. package/skills/canvas/components.md +349 -0
  65. package/skills/canvas/flows.md +406 -0
@@ -0,0 +1,333 @@
1
+ import React, { useEffect, useState, useRef, useCallback } from "react";
2
+ import { RevisionSelect } from "./RevisionSelect";
3
+ import { runDomDiff } from "./domDiff";
4
+ import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
5
+
6
+ interface CompareViewProps {
7
+ initialLeft: number;
8
+ initialRight: number;
9
+ sessionId: string;
10
+ onExit: () => void;
11
+ }
12
+
13
+ type CompareMode = "unified" | "side-by-side";
14
+
15
+ export function CompareView({ initialLeft, initialRight, sessionId, onExit }: CompareViewProps) {
16
+ const [leftRev, setLeftRev] = useState(initialLeft);
17
+ const [rightRev, setRightRev] = useState(initialRight);
18
+ const [mode, setMode] = useState<CompareMode>("unified");
19
+ const [hasChanges, setHasChanges] = useState(true);
20
+ const [diffDone, setDiffDone] = useState(false);
21
+
22
+ return (
23
+ <div className="flex flex-col h-screen">
24
+ {/* Header */}
25
+ <div className="flex items-center justify-between px-6 py-3 border-b border-border-subtle bg-bg-surface flex-shrink-0">
26
+ <div className="flex items-center gap-3">
27
+ <span className="text-[13px] font-body font-medium text-text-primary">Compare</span>
28
+ <RevisionSelect value={leftRev} onChange={setLeftRev} accent="red" />
29
+ <span className="text-text-tertiary text-[13px]">&rarr;</span>
30
+ <RevisionSelect value={rightRev} onChange={setRightRev} accent="green" />
31
+ {diffDone && !hasChanges && (
32
+ <span className="text-[11px] font-body text-text-tertiary bg-bg-elevated px-2 py-0.5 rounded">
33
+ No changes
34
+ </span>
35
+ )}
36
+ </div>
37
+ <div className="flex items-center gap-4">
38
+ {/* Mode toggle */}
39
+ <div className="flex items-center bg-bg-elevated rounded-md p-0.5">
40
+ <button
41
+ onClick={() => setMode("unified")}
42
+ className={`text-[11px] font-body px-2.5 py-1 rounded transition-colors ${
43
+ mode === "unified"
44
+ ? "bg-bg-surface text-text-primary font-medium shadow-sm"
45
+ : "text-text-tertiary hover:text-text-secondary"
46
+ }`}
47
+ >
48
+ Unified
49
+ </button>
50
+ <button
51
+ onClick={() => setMode("side-by-side")}
52
+ className={`text-[11px] font-body px-2.5 py-1 rounded transition-colors ${
53
+ mode === "side-by-side"
54
+ ? "bg-bg-surface text-text-primary font-medium shadow-sm"
55
+ : "text-text-tertiary hover:text-text-secondary"
56
+ }`}
57
+ >
58
+ Side-by-side
59
+ </button>
60
+ </div>
61
+
62
+ {/* Sync scroll — only in side-by-side mode */}
63
+ {mode === "side-by-side" && <SyncScrollCheckbox />}
64
+
65
+ <button
66
+ onClick={onExit}
67
+ className="text-[12px] font-body font-medium text-text-secondary hover:text-text-primary px-3 py-1.5 rounded-md hover:bg-bg-elevated transition-colors"
68
+ >
69
+ Exit Compare
70
+ </button>
71
+ </div>
72
+ </div>
73
+
74
+ {mode === "unified" ? (
75
+ <UnifiedView
76
+ key={`unified-${leftRev}-${rightRev}`}
77
+ sessionId={sessionId}
78
+ leftRev={leftRev}
79
+ rightRev={rightRev}
80
+ onDiffResult={(done, changes) => { setDiffDone(done); setHasChanges(changes); }}
81
+ />
82
+ ) : (
83
+ <SideBySideView
84
+ key={`sbs-${leftRev}-${rightRev}`}
85
+ sessionId={sessionId}
86
+ leftRev={leftRev}
87
+ rightRev={rightRev}
88
+ onDiffResult={(done, changes) => { setDiffDone(done); setHasChanges(changes); }}
89
+ />
90
+ )}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ /* ── Sync scroll checkbox (lifted out to avoid re-render issues) ── */
96
+
97
+ function SyncScrollCheckbox() {
98
+ const [checked, setChecked] = useState(true);
99
+ // Store in a ref accessible to SideBySideView via DOM attribute
100
+ useEffect(() => {
101
+ document.documentElement.dataset.syncScroll = checked ? "1" : "0";
102
+ return () => { delete document.documentElement.dataset.syncScroll; };
103
+ }, [checked]);
104
+
105
+ return (
106
+ <label className="flex items-center gap-1.5 cursor-pointer select-none">
107
+ <input
108
+ type="checkbox"
109
+ checked={checked}
110
+ onChange={(e) => setChecked(e.target.checked)}
111
+ className="accent-accent-amber w-3.5 h-3.5"
112
+ />
113
+ <span className="text-[11px] font-body text-text-secondary">Sync scroll</span>
114
+ </label>
115
+ );
116
+ }
117
+
118
+ /* ── Unified view ── */
119
+
120
+ interface ViewProps {
121
+ sessionId: string;
122
+ leftRev: number;
123
+ rightRev: number;
124
+ onDiffResult: (done: boolean, hasChanges: boolean) => void;
125
+ }
126
+
127
+ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps) {
128
+ const oldRef = useRef<HTMLDivElement>(null);
129
+ const newRef = useRef<HTMLDivElement>(null);
130
+ const unifiedRef = useRef<HTMLDivElement>(null);
131
+ const [leftReady, setLeftReady] = useState(false);
132
+ const [rightReady, setRightReady] = useState(false);
133
+ const [done, setDone] = useState(false);
134
+
135
+ useEffect(() => {
136
+ if (!leftReady || !rightReady || done) return;
137
+ if (!oldRef.current || !newRef.current) return;
138
+
139
+ requestAnimationFrame(() => {
140
+ requestAnimationFrame(() => {
141
+ if (!oldRef.current || !newRef.current || !unifiedRef.current) return;
142
+
143
+ const oldTree = extractBlockTree(oldRef.current);
144
+ const newTree = extractBlockTree(newRef.current);
145
+ const matches = matchBlocks(oldTree, newTree);
146
+ const { fragment, hasChanges } = buildUnifiedDom(matches);
147
+
148
+ unifiedRef.current.innerHTML = "";
149
+ unifiedRef.current.appendChild(fragment);
150
+ setDone(true);
151
+ onDiffResult(true, hasChanges);
152
+ });
153
+ });
154
+ }, [leftReady, rightReady, done]);
155
+
156
+ return (
157
+ <div className="flex-1 min-h-0 overflow-y-auto">
158
+ {/* Offscreen source panels */}
159
+ <div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
160
+ <ComparePanelRenderer
161
+ ref={oldRef}
162
+ sessionId={sessionId}
163
+ revision={leftRev}
164
+ onReady={() => setLeftReady(true)}
165
+ />
166
+ <ComparePanelRenderer
167
+ ref={newRef}
168
+ sessionId={sessionId}
169
+ revision={rightRev}
170
+ onReady={() => setRightReady(true)}
171
+ />
172
+ </div>
173
+
174
+ {/* Visible output */}
175
+ <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
176
+ {!done && (
177
+ <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">
178
+ Loading...
179
+ </div>
180
+ )}
181
+ <div ref={unifiedRef} />
182
+ </div>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ /* ── Side-by-side view ── */
188
+
189
+ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps) {
190
+ const oldRef = useRef<HTMLDivElement>(null);
191
+ const newRef = useRef<HTMLDivElement>(null);
192
+ const [leftReady, setLeftReady] = useState(false);
193
+ const [rightReady, setRightReady] = useState(false);
194
+ const [done, setDone] = useState(false);
195
+
196
+ const leftPanelRef = useRef<HTMLDivElement>(null);
197
+ const rightPanelRef = useRef<HTMLDivElement>(null);
198
+ const isSyncing = useRef(false);
199
+
200
+ // Run diff
201
+ useEffect(() => {
202
+ if (!leftReady || !rightReady || done) return;
203
+ if (!oldRef.current || !newRef.current) return;
204
+
205
+ requestAnimationFrame(() => {
206
+ requestAnimationFrame(() => {
207
+ if (!oldRef.current || !newRef.current) return;
208
+ const changed = runDomDiff(oldRef.current, newRef.current);
209
+ setDone(true);
210
+ onDiffResult(true, changed);
211
+ });
212
+ });
213
+ }, [leftReady, rightReady, done]);
214
+
215
+ // Sync scroll
216
+ useEffect(() => {
217
+ const left = leftPanelRef.current;
218
+ const right = rightPanelRef.current;
219
+ if (!left || !right) return;
220
+
221
+ const handleSync = (source: HTMLDivElement, target: HTMLDivElement) => {
222
+ if (document.documentElement.dataset.syncScroll === "0") return;
223
+ if (isSyncing.current) return;
224
+ isSyncing.current = true;
225
+ const sourceMax = source.scrollHeight - source.clientHeight;
226
+ const targetMax = target.scrollHeight - target.clientHeight;
227
+ if (sourceMax > 0 && targetMax > 0) {
228
+ target.scrollTop = (source.scrollTop / sourceMax) * targetMax;
229
+ }
230
+ requestAnimationFrame(() => { isSyncing.current = false; });
231
+ };
232
+
233
+ const onLeft = () => handleSync(left, right);
234
+ const onRight = () => handleSync(right, left);
235
+ left.addEventListener("scroll", onLeft);
236
+ right.addEventListener("scroll", onRight);
237
+ return () => {
238
+ left.removeEventListener("scroll", onLeft);
239
+ right.removeEventListener("scroll", onRight);
240
+ };
241
+ }, []);
242
+
243
+ return (
244
+ <div className="flex flex-1 min-h-0">
245
+ <div ref={leftPanelRef} className="w-1/2 overflow-y-auto border-r border-border-subtle">
246
+ <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
247
+ <ComparePanelRenderer
248
+ ref={oldRef}
249
+ sessionId={sessionId}
250
+ revision={leftRev}
251
+ onReady={() => setLeftReady(true)}
252
+ />
253
+ </div>
254
+ </div>
255
+ <div ref={rightPanelRef} className="w-1/2 overflow-y-auto">
256
+ <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
257
+ <ComparePanelRenderer
258
+ ref={newRef}
259
+ sessionId={sessionId}
260
+ revision={rightRev}
261
+ onReady={() => setRightReady(true)}
262
+ />
263
+ </div>
264
+ </div>
265
+ </div>
266
+ );
267
+ }
268
+
269
+ /* ── Panel renderer ── */
270
+
271
+ interface ComparePanelRendererProps {
272
+ sessionId: string;
273
+ revision: number;
274
+ onReady: () => void;
275
+ }
276
+
277
+ const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
278
+ ({ sessionId, revision, onReady }, ref) => {
279
+ const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
280
+ const [error, setError] = useState<string | null>(null);
281
+ const [loading, setLoading] = useState(true);
282
+
283
+ useEffect(() => {
284
+ setLoading(true);
285
+ setError(null);
286
+ import(`/api/session/${sessionId}/plan.js?rev=${revision}&t=${Date.now()}`)
287
+ .then((mod) => {
288
+ setPlanComponent(() => mod.default);
289
+ setLoading(false);
290
+ })
291
+ .catch((e) => {
292
+ setError(e.message);
293
+ setLoading(false);
294
+ });
295
+ }, [sessionId, revision]);
296
+
297
+ useEffect(() => {
298
+ if (!PlanComponent) return;
299
+ requestAnimationFrame(() => {
300
+ requestAnimationFrame(() => {
301
+ onReady();
302
+ });
303
+ });
304
+ }, [PlanComponent]);
305
+
306
+ if (loading) {
307
+ return (
308
+ <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">
309
+ Loading...
310
+ </div>
311
+ );
312
+ }
313
+
314
+ if (error) {
315
+ return (
316
+ <div className="bg-accent-red-muted rounded-lg p-5">
317
+ <h3 className="font-body font-semibold text-accent-red mb-2">Error</h3>
318
+ <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{error}</pre>
319
+ </div>
320
+ );
321
+ }
322
+
323
+ if (!PlanComponent) {
324
+ return <div className="text-text-tertiary text-center py-8 font-body text-body">No plan loaded</div>;
325
+ }
326
+
327
+ return (
328
+ <div ref={ref}>
329
+ <PlanComponent />
330
+ </div>
331
+ );
332
+ }
333
+ );
@@ -0,0 +1,188 @@
1
+ import React, { useEffect, useState, useContext, useCallback } from "react";
2
+ import { SessionContext } from "#canvas/runtime";
3
+ import { useAnnotations } from "./AnnotationProvider";
4
+ import { ActiveViewContext } from "./App";
5
+ import { FileIcon } from "./FileIcon";
6
+
7
+ interface TreeEntry { name: string; type: "file" | "dir"; }
8
+ interface DirState { entries: TreeEntry[]; expanded: boolean; loaded: boolean; }
9
+
10
+ export function FileBrowser({ embedded }: { embedded?: boolean } = {}) {
11
+ const sessionId = useContext(SessionContext);
12
+ const { annotations, addAnnotation } = useAnnotations();
13
+ const { setActiveView } = useContext(ActiveViewContext);
14
+ const [collapsed, setCollapsed] = useState(false);
15
+ const [dirs, setDirs] = useState<Record<string, DirState>>({});
16
+
17
+ const fileAnnotations = annotations.filter((a) => a.filePath);
18
+ const annCountMap = new Map<string, number>();
19
+ for (const a of fileAnnotations) {
20
+ annCountMap.set(a.filePath!, (annCountMap.get(a.filePath!) || 0) + 1);
21
+ }
22
+ const annotatedFiles = [...annCountMap.keys()];
23
+
24
+ const loadDir = async (path: string) => {
25
+ const res = await fetch(`/api/tree?session=${sessionId}&path=${encodeURIComponent(path)}`);
26
+ const data = await res.json() as any;
27
+ if (data.entries) setDirs((prev) => ({ ...prev, [path]: { entries: data.entries, expanded: true, loaded: true } }));
28
+ };
29
+
30
+ useEffect(() => { if (!collapsed && !dirs[""]) loadDir(""); }, [collapsed]);
31
+
32
+ const toggleDir = (path: string) => {
33
+ if (dirs[path]?.loaded) setDirs((prev) => ({ ...prev, [path]: { ...prev[path], expanded: !prev[path].expanded } }));
34
+ else loadDir(path);
35
+ };
36
+
37
+ const openFile = (path: string) => {
38
+ setActiveView({ type: "file", path });
39
+ };
40
+
41
+ const addWholeFile = async (path: string) => {
42
+ const res = await fetch(`/api/file?session=${sessionId}&path=${encodeURIComponent(path)}`);
43
+ const data = await res.json() as any;
44
+ if (!data.error) {
45
+ const preview = data.content.split("\n").slice(0, 3).join("\n") + (data.content.split("\n").length > 3 ? "\n..." : "");
46
+ addAnnotation(preview, "Include this file for context", path);
47
+ }
48
+ };
49
+
50
+ const addWholeDir = async (path: string) => {
51
+ // Load dir if needed, then add all files
52
+ let entries: TreeEntry[] = [];
53
+ if (dirs[path]?.loaded) {
54
+ entries = dirs[path].entries;
55
+ } else {
56
+ const res = await fetch(`/api/tree?session=${sessionId}&path=${encodeURIComponent(path)}`);
57
+ const data = await res.json() as any;
58
+ if (data.entries) entries = data.entries;
59
+ }
60
+ await Promise.all(
61
+ entries
62
+ .filter((entry) => entry.type === "file")
63
+ .map((entry) => addWholeFile(path ? `${path}/${entry.name}` : entry.name))
64
+ );
65
+ };
66
+
67
+ const getAnnCount = (path: string) => annCountMap.get(path) || 0;
68
+
69
+ const renderTree = (basePath: string, depth: number = 0): React.ReactNode => {
70
+ const dir = dirs[basePath];
71
+ if (!dir?.expanded) return null;
72
+ return dir.entries.map((entry) => {
73
+ const fullPath = basePath ? `${basePath}/${entry.name}` : entry.name;
74
+ const annCount = getAnnCount(fullPath);
75
+
76
+ return (
77
+ <div key={fullPath}>
78
+ <div
79
+ className="group/item flex items-center py-[3px] hover:bg-bg-elevated text-[13px] font-body cursor-default transition-colors duration-100"
80
+ style={{ paddingLeft: `${16 + depth * 16}px`, paddingRight: "8px" }}
81
+ >
82
+ {entry.type === "dir" ? (
83
+ <>
84
+ <span onClick={() => toggleDir(fullPath)} className="flex items-center gap-1.5 flex-1 text-text-secondary hover:text-text-primary cursor-pointer truncate">
85
+ <FileIcon name={entry.name} type="dir" expanded={dirs[fullPath]?.expanded} />
86
+ <span className="truncate">{entry.name}</span>
87
+ </span>
88
+ <button
89
+ onClick={(e) => { e.stopPropagation(); addWholeDir(fullPath); }}
90
+ className="text-[11px] text-text-tertiary hover:text-text-secondary opacity-0 group-hover/item:opacity-100 transition-opacity px-1 flex-shrink-0"
91
+ title="Add all files in folder"
92
+ >
93
+ +
94
+ </button>
95
+ </>
96
+ ) : (
97
+ <>
98
+ <span
99
+ onClick={() => openFile(fullPath)}
100
+ className={`flex-1 truncate cursor-pointer flex items-center gap-1.5 ${annCount > 0 ? "text-text-primary" : "text-text-secondary hover:text-text-primary"}`}
101
+ >
102
+ <FileIcon name={entry.name} type="file" />
103
+ <span className="truncate">{entry.name}</span>
104
+ </span>
105
+ {/* Right side: annotation badge + add button */}
106
+ <div className="flex items-center gap-1 flex-shrink-0 ml-1">
107
+ {annCount > 0 && (
108
+ <span className="text-[10px] font-medium bg-badge-bg text-accent-amber px-1.5 py-px rounded-full min-w-[18px] text-center">
109
+ {annCount}
110
+ </span>
111
+ )}
112
+ <button
113
+ onClick={(e) => { e.stopPropagation(); addWholeFile(fullPath); }}
114
+ className="text-[11px] text-text-tertiary hover:text-text-secondary opacity-0 group-hover/item:opacity-100 transition-opacity px-1"
115
+ title="Add file to context"
116
+ >
117
+ +
118
+ </button>
119
+ </div>
120
+ </>
121
+ )}
122
+ </div>
123
+ {entry.type === "dir" && renderTree(fullPath, depth + 1)}
124
+ </div>
125
+ );
126
+ });
127
+ };
128
+
129
+ if (embedded) {
130
+ return (
131
+ <>
132
+ <div className="flex items-center justify-between px-4 pt-3 pb-2 flex-shrink-0">
133
+ <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body">Files</span>
134
+ </div>
135
+ <div className="flex-1 overflow-y-auto">{renderTree("")}</div>
136
+ {annotatedFiles.length > 0 && (
137
+ <div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
138
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body mb-1.5">Referenced</div>
139
+ {annotatedFiles.map((f) => (
140
+ <div key={f} onClick={() => openFile(f)} className="flex items-center justify-between text-[12px] font-body text-text-secondary hover:text-text-primary cursor-pointer py-0.5">
141
+ <span className="truncate">{f.split("/").pop()}</span>
142
+ <span className="text-[10px] font-medium bg-badge-bg text-accent-amber px-1.5 py-px rounded-full min-w-[18px] text-center flex-shrink-0 ml-1">
143
+ {getAnnCount(f)}
144
+ </span>
145
+ </div>
146
+ ))}
147
+ </div>
148
+ )}
149
+ </>
150
+ );
151
+ }
152
+
153
+ if (collapsed) {
154
+ return (
155
+ <div className="hidden lg:flex w-8 border-r border-border-subtle bg-bg-surface flex-col items-center flex-shrink-0">
156
+ <button onClick={() => setCollapsed(false)} className="mt-3 p-1 text-text-tertiary hover:text-text-secondary transition-colors">
157
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
158
+ </button>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ return (
164
+ <div className="hidden lg:flex w-60 border-r border-border-subtle bg-bg-surface flex-col flex-shrink-0 overflow-hidden">
165
+ <div className="flex items-center justify-between px-4 pt-4 pb-3 flex-shrink-0">
166
+ <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body">Files</span>
167
+ <button onClick={() => setCollapsed(true)} className="text-text-tertiary hover:text-text-secondary transition-colors p-0.5">
168
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M9 3l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
169
+ </button>
170
+ </div>
171
+ <div className="flex-1 overflow-y-auto">{renderTree("")}</div>
172
+
173
+ {annotatedFiles.length > 0 && (
174
+ <div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
175
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body mb-1.5">Referenced</div>
176
+ {annotatedFiles.map((f) => (
177
+ <div key={f} onClick={() => openFile(f)} className="flex items-center justify-between text-[12px] font-body text-text-secondary hover:text-text-primary cursor-pointer py-0.5">
178
+ <span className="truncate">{f.split("/").pop()}</span>
179
+ <span className="text-[10px] font-medium bg-badge-bg text-accent-amber px-1.5 py-px rounded-full min-w-[18px] text-center flex-shrink-0 ml-1">
180
+ {getAnnCount(f)}
181
+ </span>
182
+ </div>
183
+ ))}
184
+ </div>
185
+ )}
186
+ </div>
187
+ );
188
+ }
@@ -0,0 +1,76 @@
1
+ import React from "react";
2
+
3
+ interface FileIconProps {
4
+ name: string;
5
+ type: "file" | "dir";
6
+ expanded?: boolean;
7
+ className?: string;
8
+ }
9
+
10
+ const extColors: Record<string, string> = {
11
+ // TypeScript / JavaScript
12
+ ts: "#3178c6", tsx: "#3178c6", js: "#e8a32e", jsx: "#4ba0c8",
13
+ // Web
14
+ html: "#e34c26", css: "#264de4", scss: "#c69",
15
+ // Data
16
+ json: "#7a7568", yaml: "#cb171e", yml: "#cb171e", toml: "#9c4221",
17
+ // Systems
18
+ rs: "#dea584", go: "#00add8", c: "#6a6a6a", cpp: "#f34b7d", h: "#6a6a6a",
19
+ // Scripting
20
+ py: "#3572a5", rb: "#cc342d", sh: "#5a8c3c", bash: "#5a8c3c",
21
+ // Markup / docs
22
+ md: "#3573b5", txt: "#6b6560",
23
+ // Config
24
+ lock: "#6b6560", gitignore: "#6b6560",
25
+ // Images
26
+ svg: "#d9952e", png: "#6a9c2e", jpg: "#6a9c2e", gif: "#6a9c2e",
27
+ };
28
+
29
+ const extLabels: Record<string, string> = {
30
+ ts: "TS", tsx: "TX", js: "JS", jsx: "JX",
31
+ py: "PY", rs: "RS", go: "GO", rb: "RB",
32
+ json: "{}", yaml: "YM", yml: "YM", toml: "TM",
33
+ html: "<>", css: "#", scss: "#",
34
+ md: "M↓", sh: "$", bash: "$",
35
+ c: "C", cpp: "C+", h: "H",
36
+ svg: "◇", png: "▣", jpg: "▣",
37
+ };
38
+
39
+ export function FileIcon({ name, type, expanded, className = "" }: FileIconProps) {
40
+ if (type === "dir") {
41
+ return (
42
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" className={`flex-shrink-0 ${className}`}>
43
+ {expanded ? (
44
+ <path d="M1.5 3.5h5l1.5 1.5h6.5v8h-13z" style={{ fill: "var(--color-folder-fill-active)", stroke: "var(--color-folder-stroke-active)" }} strokeWidth="1" />
45
+ ) : (
46
+ <path d="M1.5 3.5h5l1.5 1.5h6.5v8h-13z" style={{ fill: "var(--color-folder-fill)", stroke: "var(--color-folder-stroke)" }} strokeWidth="1" />
47
+ )}
48
+ </svg>
49
+ );
50
+ }
51
+
52
+ const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
53
+ const color = extColors[ext] || "#6b6560";
54
+ const label = extLabels[ext];
55
+
56
+ return (
57
+ <svg width="14" height="14" viewBox="0 0 16 16" className={`flex-shrink-0 ${className}`}>
58
+ {/* File shape with folded corner */}
59
+ <path d="M2 1.5h8l3.5 3.5v9.5a1 1 0 01-1 1H3a1 1 0 01-1-1v-12a1 1 0 011-1z" fill={color} opacity="0.85" />
60
+ <path d="M10 1.5v2.5a1 1 0 001 1h2.5" fill="none" stroke="white" strokeWidth="0.5" opacity="0.5" />
61
+ {label && (
62
+ <text
63
+ x="8"
64
+ y="11.5"
65
+ textAnchor="middle"
66
+ fill="white"
67
+ fontSize="5.5"
68
+ fontFamily="monospace"
69
+ fontWeight="600"
70
+ >
71
+ {label}
72
+ </text>
73
+ )}
74
+ </svg>
75
+ );
76
+ }