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.
- package/bin/agent-canvas.js +14 -0
- package/bin/agent-canvas.ts +377 -0
- package/daemon/build.ts +185 -0
- package/daemon/client/AnnotationProvider.tsx +185 -0
- package/daemon/client/AnnotationSidebar.tsx +314 -0
- package/daemon/client/App.tsx +611 -0
- package/daemon/client/CompareView.tsx +333 -0
- package/daemon/client/FileBrowser.tsx +188 -0
- package/daemon/client/FileIcon.tsx +76 -0
- package/daemon/client/FileViewer.tsx +207 -0
- package/daemon/client/PlanRenderer.tsx +665 -0
- package/daemon/client/Popover.tsx +285 -0
- package/daemon/client/ResponsePreview.tsx +260 -0
- package/daemon/client/RevisionSelect.tsx +97 -0
- package/daemon/client/SessionContext.tsx +3 -0
- package/daemon/client/SessionSwitcher.tsx +72 -0
- package/daemon/client/annotationContext.ts +114 -0
- package/daemon/client/components/Callout.tsx +25 -0
- package/daemon/client/components/Checklist.tsx +37 -0
- package/daemon/client/components/Choice.tsx +164 -0
- package/daemon/client/components/CodeBlock.tsx +35 -0
- package/daemon/client/components/Diff.tsx +65 -0
- package/daemon/client/components/FilePreview.tsx +88 -0
- package/daemon/client/components/Image.tsx +45 -0
- package/daemon/client/components/Mermaid.tsx +277 -0
- package/daemon/client/components/Note.tsx +13 -0
- package/daemon/client/components/Priority.tsx +19 -0
- package/daemon/client/components/ResponseNote.tsx +24 -0
- package/daemon/client/components/Section.tsx +32 -0
- package/daemon/client/components/Table.tsx +35 -0
- package/daemon/client/components/Task.tsx +75 -0
- package/daemon/client/components/UserInput.tsx +180 -0
- package/daemon/client/components/index.ts +15 -0
- package/daemon/client/domDiff.ts +336 -0
- package/daemon/client/exportMarkdown.ts +267 -0
- package/daemon/client/generateMarkdown.ts +194 -0
- package/daemon/client/highlightRange.ts +233 -0
- package/daemon/client/index.html +26 -0
- package/daemon/client/lcs.ts +19 -0
- package/daemon/client/popoverPosition.ts +31 -0
- package/daemon/client/runtime.ts +89 -0
- package/daemon/client/styles.css +306 -0
- package/daemon/client/theme.css +66 -0
- package/daemon/client/unifiedDiff.ts +604 -0
- package/daemon/dist/client.css +2 -0
- package/daemon/dist/client.js +68 -0
- package/daemon/dist/components.js +5 -0
- package/daemon/dist/index.html +108 -0
- package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
- package/daemon/dist/jsx-runtime-shim.js +17 -0
- package/daemon/dist/react-dom-client-shim.js +4 -0
- package/daemon/dist/react-dom-shim.js +4 -0
- package/daemon/dist/react-shim.js +4 -0
- package/daemon/dist/runtime.js +1 -0
- package/daemon/dist/theme.css +66 -0
- package/daemon/src/compiler.ts +92 -0
- package/daemon/src/paths.ts +22 -0
- package/daemon/src/server.ts +448 -0
- package/daemon/src/session.ts +274 -0
- package/daemon/src/watcher.ts +59 -0
- package/hooks/session-start.sh +11 -0
- package/package.json +39 -0
- package/skills/canvas/SKILL.md +174 -0
- package/skills/canvas/components.md +349 -0
- 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]">→</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
|
+
}
|