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,611 @@
1
+ import React, { useEffect, useLayoutEffect, useState, useCallback, useRef, createContext, useMemo } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { SessionContext, ActiveViewCtx } from "#canvas/runtime";
4
+ import { AnnotationProvider, useAnnotations } from "./AnnotationProvider";
5
+ import { PlanRenderer } from "./PlanRenderer";
6
+ import { AnnotationSidebar } from "./AnnotationSidebar";
7
+ import { ResponsePreview } from "./ResponsePreview";
8
+ import { FileBrowser } from "./FileBrowser";
9
+ import { FileViewer } from "./FileViewer";
10
+ import { SessionSwitcher } from "./SessionSwitcher";
11
+ import { exportCanvasToMarkdown } from "./exportMarkdown";
12
+ import { CompareView } from "./CompareView";
13
+ import { RevisionSelect } from "./RevisionSelect";
14
+
15
+ export type ActiveView = { type: "plan" } | { type: "file"; path: string };
16
+
17
+ export interface RevisionInfo {
18
+ revision: number;
19
+ label?: string;
20
+ sourceFile?: string;
21
+ createdAt: string;
22
+ hasFeedback: boolean;
23
+ }
24
+
25
+ export const ActiveViewContext = createContext<{
26
+ activeView: ActiveView;
27
+ setActiveView: (v: ActiveView) => void;
28
+ openFiles: string[];
29
+ closeFile: (path: string) => void;
30
+ }>({
31
+ activeView: { type: "plan" },
32
+ setActiveView: () => {},
33
+ openFiles: [],
34
+ closeFile: () => {},
35
+ });
36
+
37
+ export const RevisionContext = createContext<{
38
+ currentRevision: number;
39
+ selectedRevision: number;
40
+ revisions: RevisionInfo[];
41
+ setSelectedRevision: (rev: number) => void;
42
+ isReadOnly: boolean;
43
+ compareRevision: { left: number; right: number } | null;
44
+ setCompareRevision: (rev: { left: number; right: number } | null) => void;
45
+ }>({
46
+ currentRevision: 1,
47
+ selectedRevision: 1,
48
+ revisions: [],
49
+ setSelectedRevision: () => {},
50
+ isReadOnly: false,
51
+ compareRevision: null,
52
+ setCompareRevision: () => {},
53
+ });
54
+
55
+ function ThemeSwitcher() {
56
+ const [theme, setTheme] = useState(() => localStorage.getItem("canvas-theme") || document.documentElement.dataset.theme || "dark");
57
+
58
+ const toggle = () => {
59
+ const next = theme === "dark" ? "light" : "dark";
60
+ document.documentElement.dataset.theme = next;
61
+ localStorage.setItem("canvas-theme", next);
62
+ setTheme(next);
63
+ };
64
+
65
+ return (
66
+ <button
67
+ onClick={toggle}
68
+ className="w-7 h-7 flex items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
69
+ title={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"}
70
+ >
71
+ {theme === "dark" ? (
72
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
73
+ <circle cx="12" cy="12" r="5" />
74
+ <line x1="12" y1="1" x2="12" y2="3" />
75
+ <line x1="12" y1="21" x2="12" y2="23" />
76
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
77
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
78
+ <line x1="1" y1="12" x2="3" y2="12" />
79
+ <line x1="21" y1="12" x2="23" y2="12" />
80
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
81
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
82
+ </svg>
83
+ ) : (
84
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
85
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
86
+ </svg>
87
+ )}
88
+ </button>
89
+ );
90
+ }
91
+
92
+ function RevisionSelector() {
93
+ const { currentRevision, selectedRevision, revisions, setSelectedRevision, setCompareRevision } = React.useContext(RevisionContext);
94
+
95
+ if (revisions.length <= 1) {
96
+ const only = revisions[0];
97
+ const label = only?.label || `Round ${currentRevision}`;
98
+ return <span className="text-[11px] text-text-tertiary font-body">{label}</span>;
99
+ }
100
+
101
+ const isLatest = selectedRevision === currentRevision;
102
+
103
+ return (
104
+ <div className="flex items-center gap-1.5">
105
+ <RevisionSelect
106
+ value={selectedRevision}
107
+ onChange={setSelectedRevision}
108
+ accent={isLatest ? "default" : "amber"}
109
+ />
110
+ <button
111
+ onClick={() => {
112
+ const current = revisions.find((r) => r.revision === selectedRevision);
113
+ const sourceFile = current?.sourceFile;
114
+ let left: number;
115
+ if (selectedRevision === currentRevision) {
116
+ // Find previous revision with same sourceFile
117
+ const prev = [...revisions]
118
+ .reverse()
119
+ .find((r) => r.revision < currentRevision && (!sourceFile || r.sourceFile === sourceFile));
120
+ left = prev?.revision ?? Math.max(1, currentRevision - 1);
121
+ } else {
122
+ left = selectedRevision;
123
+ }
124
+ setCompareRevision({ left, right: currentRevision });
125
+ }}
126
+ className="text-[11px] font-body font-medium px-2 py-0.5 rounded-md text-accent-blue hover:bg-accent-blue-muted transition-colors"
127
+ >
128
+ Compare
129
+ </button>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ function App() {
135
+ const sessionId = window.location.pathname.replace("/s/", "") || "";
136
+ const [currentRevision, setCurrentRevision] = useState(1);
137
+ const [selectedRevision, setSelectedRevision] = useState(1);
138
+ const [revisions, setRevisions] = useState<RevisionInfo[]>([]);
139
+ const [compareRevision, setCompareRevision] = useState<{ left: number; right: number } | null>(null);
140
+ const [connected, setConnected] = useState(false);
141
+ const [previewOpen, setPreviewOpen] = useState(false);
142
+ const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: "plan" });
143
+ const [openFiles, setOpenFiles] = useState<string[]>([]);
144
+ const wsRef = useRef<WebSocket | null>(null);
145
+
146
+ const [mobileSidebar, setMobileSidebar] = useState(false);
147
+ const [leftCollapsed, setLeftCollapsed] = useState(false);
148
+ const [rightCollapsed, setRightCollapsed] = useState(false);
149
+
150
+ // Dynamic document title
151
+ useEffect(() => {
152
+ const parts = ["Agent Canvas"];
153
+ if (sessionId) parts.push(sessionId);
154
+ if (activeView.type === "file") {
155
+ parts.push(activeView.path.split("/").pop() || activeView.path);
156
+ } else if (compareRevision) {
157
+ parts.push("Compare");
158
+ }
159
+ document.title = parts.join(" — ");
160
+ }, [sessionId, activeView, compareRevision]);
161
+
162
+ // Scroll position restore per view
163
+ const scrollPositions = useRef<Map<string, number>>(new Map());
164
+
165
+ const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision);
166
+ const isReadOnly = selectedRevision !== currentRevision || !!selectedRevInfo?.hasFeedback;
167
+
168
+ const setActiveView = useCallback((v: ActiveView) => {
169
+ // Save current scroll position before switching
170
+ scrollPositions.current.set(
171
+ activeView.type === "plan" ? "plan" : `file:${activeView.path}`,
172
+ window.scrollY
173
+ );
174
+ setActiveViewRaw(v);
175
+ if (v.type === "file") {
176
+ setOpenFiles((prev) => prev.includes(v.path) ? prev : [...prev, v.path]);
177
+ }
178
+ }, [activeView]);
179
+
180
+ const closeFile = useCallback((path: string) => {
181
+ scrollPositions.current.delete(`file:${path}`);
182
+ setOpenFiles((prev) => prev.filter((p) => p !== path));
183
+ setActiveViewRaw((prev) => {
184
+ if (prev.type === "file" && prev.path === path) {
185
+ scrollPositions.current.set(`file:${path}`, window.scrollY);
186
+ return { type: "plan" };
187
+ }
188
+ return prev;
189
+ });
190
+ }, []);
191
+
192
+ // Restore scroll synchronously after DOM update (before paint)
193
+ useLayoutEffect(() => {
194
+ const key = activeView.type === "plan" ? "plan" : `file:${activeView.path}`;
195
+ window.scrollTo(0, scrollPositions.current.get(key) || 0);
196
+ }, [activeView]);
197
+
198
+ // Fetch initial meta
199
+ useEffect(() => {
200
+ if (!sessionId) return;
201
+ fetch(`/api/session/${sessionId}/meta`)
202
+ .then((r) => r.json())
203
+ .then((data: any) => {
204
+ if (data.currentRevision) {
205
+ setCurrentRevision(data.currentRevision);
206
+ setSelectedRevision(data.currentRevision);
207
+ setRevisions(data.revisions || []);
208
+ }
209
+ })
210
+ .catch(() => {});
211
+ }, [sessionId]);
212
+
213
+ useEffect(() => {
214
+ if (!sessionId) return;
215
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
216
+ const connect = () => {
217
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
218
+ const ws = new WebSocket(`${protocol}//${window.location.host}/ws/session/${sessionId}`);
219
+ wsRef.current = ws;
220
+ ws.onopen = () => setConnected(true);
221
+ ws.onclose = () => { setConnected(false); reconnectTimer = setTimeout(connect, 2000); };
222
+ ws.onmessage = (event) => {
223
+ try {
224
+ const data = JSON.parse(event.data);
225
+ if (data.type === "plan-updated") {
226
+ setCurrentRevision(data.currentRevision);
227
+ setSelectedRevision(data.currentRevision);
228
+ setCompareRevision(null);
229
+ if (data.revisions) setRevisions(data.revisions);
230
+ }
231
+ if (data.type === "revision-updated") {
232
+ if (data.revisions) setRevisions(data.revisions);
233
+ }
234
+ } catch {}
235
+ };
236
+ };
237
+ connect();
238
+ return () => { if (reconnectTimer) clearTimeout(reconnectTimer); wsRef.current?.close(); };
239
+ }, [sessionId]);
240
+
241
+ const handleSubmit = useCallback((feedback: string) => {
242
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
243
+ wsRef.current.send(JSON.stringify({ type: "submit", feedback }));
244
+ setPreviewOpen(false);
245
+ // Server will broadcast revision-updated with hasFeedback: true
246
+ // which triggers isReadOnly via revisions state
247
+ }
248
+ }, []);
249
+
250
+ if (!sessionId) {
251
+ return <div className="flex items-center justify-center h-screen text-text-tertiary font-body">No session selected.</div>;
252
+ }
253
+
254
+ return (
255
+ <SessionContext.Provider value={sessionId}>
256
+ <RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision }}>
257
+ <AnnotationProvider key={`${sessionId}:${selectedRevision}`} sessionId={sessionId} revision={selectedRevision} isReadOnly={isReadOnly}>
258
+ <ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile }}>
259
+ <ActiveViewCtx.Provider value={{ setActiveView }}>
260
+ <div className="min-h-screen bg-bg-base">
261
+ {/* Left panel — fixed to viewport */}
262
+ <LeftPanel sessionId={sessionId} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
263
+
264
+ {/* Right panel — fixed to viewport, resizable (hidden in compare mode) */}
265
+ {compareRevision === null && (
266
+ <ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
267
+ <AnnotationSidebar onPreview={() => setPreviewOpen(true)} onSubmit={handleSubmit} />
268
+ </ResizableSidebar>
269
+ )}
270
+
271
+ {/* Center content — normal document flow, browser scroll */}
272
+ {compareRevision !== null ? (
273
+ <div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} transition-[margin] duration-200`}>
274
+ <CompareView
275
+ initialLeft={compareRevision.left}
276
+ initialRight={compareRevision.right}
277
+ sessionId={sessionId}
278
+ onExit={() => setCompareRevision(null)}
279
+ />
280
+ </div>
281
+ ) : (
282
+ <div className={`relative ${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} ${rightCollapsed ? "lg:mr-0" : "lg:mr-[var(--sidebar-width,320px)]"} transition-[margin] duration-200`} id="plan-scroll-container">
283
+ {/* Mobile top bar — only visible < lg */}
284
+ <div className="lg:hidden sticky top-0 z-20 flex items-center justify-between px-4 py-2 border-b border-border-subtle bg-bg-surface">
285
+ <div className="flex items-center gap-2">
286
+ <SessionSwitcher currentSessionId={sessionId} />
287
+ <span className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-accent-green" : "bg-accent-red"}`} />
288
+ </div>
289
+ <div className="flex items-center gap-1">
290
+ <RevisionSelector />
291
+ <button
292
+ onClick={() => setMobileSidebar(!mobileSidebar)}
293
+ className="w-7 h-7 flex items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
294
+ title="Annotations"
295
+ >
296
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
297
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
298
+ </svg>
299
+ </button>
300
+ <ThemeSwitcher />
301
+ </div>
302
+ </div>
303
+
304
+ <ContentTabs />
305
+ {/* All views stay mounted; inactive ones are hidden */}
306
+ <div style={{ display: activeView.type === "plan" ? undefined : "none" }}>
307
+ <div className="relative max-w-[720px] mx-auto px-6 pt-12 pb-32">
308
+ <button
309
+ onClick={() => {
310
+ const planContent = document.querySelector(".plan-content");
311
+ if (!planContent) return;
312
+ const md = exportCanvasToMarkdown(planContent as HTMLElement);
313
+ navigator.clipboard.writeText(md).then(() => {
314
+ const btn = document.getElementById("export-md-btn");
315
+ if (btn) {
316
+ btn.setAttribute("data-copied", "true");
317
+ setTimeout(() => { btn.removeAttribute("data-copied"); }, 1500);
318
+ }
319
+ });
320
+ }}
321
+ id="export-md-btn"
322
+ className="group absolute top-3 right-6 p-1.5 text-text-tertiary hover:text-text-secondary transition-colors z-10"
323
+ title="Copy as Markdown"
324
+ >
325
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="group-[[data-copied=true]]:hidden">
326
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
327
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
328
+ </svg>
329
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="hidden group-[[data-copied=true]]:block text-green-500">
330
+ <polyline points="20 6 9 17 4 12"/>
331
+ </svg>
332
+ </button>
333
+ <PlanRenderer revision={selectedRevision} />
334
+ </div>
335
+ </div>
336
+ {openFiles.map((filePath) => (
337
+ <div key={filePath} style={{ display: activeView.type === "file" && activeView.path === filePath ? undefined : "none" }}>
338
+ <FileViewer path={filePath} />
339
+ </div>
340
+ ))}
341
+ </div>
342
+ )}
343
+
344
+ {/* Mobile sidebar overlay */}
345
+ {mobileSidebar && (
346
+ <div className="lg:hidden fixed inset-0 z-40 flex">
347
+ <div className="flex-1" onClick={() => setMobileSidebar(false)} />
348
+ <div className="w-80 max-w-[85vw] bg-bg-surface border-l border-border-subtle flex flex-col shadow-lg">
349
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border-subtle flex-shrink-0">
350
+ <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body">Sidebar</span>
351
+ <button onClick={() => setMobileSidebar(false)} className="text-text-tertiary hover:text-text-secondary w-7 h-7 flex items-center justify-center">&#x2715;</button>
352
+ </div>
353
+ <div className="flex-1 overflow-hidden">
354
+ <AnnotationSidebar onPreview={() => setPreviewOpen(true)} onSubmit={handleSubmit} />
355
+ </div>
356
+ </div>
357
+ </div>
358
+ )}
359
+
360
+ <ResponsePreview open={previewOpen} onClose={() => setPreviewOpen(false)} onSubmit={handleSubmit} />
361
+ </div>
362
+ </ActiveViewCtx.Provider>
363
+ </ActiveViewContext.Provider>
364
+ </AnnotationProvider>
365
+ </RevisionContext.Provider>
366
+ </SessionContext.Provider>
367
+ );
368
+ }
369
+
370
+ function LeftPanel({ sessionId, connected, onMobileSidebar, collapsed, onToggle }: { sessionId: string; connected: boolean; onMobileSidebar: () => void; collapsed: boolean; onToggle: () => void }) {
371
+ return (
372
+ <>
373
+ {/* Collapsed toggle button */}
374
+ {collapsed && (
375
+ <button
376
+ onClick={onToggle}
377
+ className="hidden lg:flex fixed top-3 left-3 z-20 w-7 h-7 items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors bg-bg-surface border border-border-subtle"
378
+ title="Show sidebar"
379
+ >
380
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
381
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
382
+ <line x1="9" y1="3" x2="9" y2="21"/>
383
+ </svg>
384
+ </button>
385
+ )}
386
+ <div className={`hidden lg:flex w-60 flex-col fixed top-0 left-0 bottom-0 bg-bg-surface border-r border-border-subtle z-10 transition-transform duration-200 ${collapsed ? "-translate-x-full" : ""}`}>
387
+ {/* Session + round info */}
388
+ <div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-border-subtle">
389
+ <div className="flex items-center gap-2 mb-2">
390
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-text-tertiary flex-shrink-0">
391
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
392
+ </svg>
393
+ <SessionSwitcher currentSessionId={sessionId} />
394
+ <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${connected ? "bg-accent-green" : "bg-accent-red"}`} />
395
+ <button
396
+ onClick={onToggle}
397
+ className="ml-auto w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
398
+ title="Hide sidebar"
399
+ >
400
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
401
+ <polyline points="11 17 6 12 11 7"/>
402
+ <polyline points="18 17 13 12 18 7"/>
403
+ </svg>
404
+ </button>
405
+ </div>
406
+ <RevisionSelector />
407
+ </div>
408
+
409
+ {/* File browser */}
410
+ <div className="flex-1 flex flex-col overflow-hidden">
411
+ <FileBrowser embedded />
412
+ </div>
413
+
414
+ {/* Bottom: theme switcher */}
415
+ <div className="px-4 py-3 border-t border-border-subtle flex items-center justify-between flex-shrink-0">
416
+ <span className="text-[10px] text-text-tertiary font-body uppercase tracking-widest">Theme</span>
417
+ <ThemeSwitcher />
418
+ </div>
419
+ </div>
420
+ </>
421
+ );
422
+ }
423
+
424
+ function ResizableSidebar({ children, collapsed, onToggle }: { children: React.ReactNode; collapsed: boolean; onToggle: () => void }) {
425
+ const [width, setWidth] = useState(() => {
426
+ const saved = localStorage.getItem("canvas-sidebar-width");
427
+ return saved ? parseInt(saved, 10) : 320;
428
+ });
429
+ const dragging = useRef(false);
430
+ const startX = useRef(0);
431
+ const startWidth = useRef(0);
432
+
433
+ const widthRef = useRef(width);
434
+ widthRef.current = width;
435
+
436
+ const onMouseDown = useCallback((e: React.MouseEvent) => {
437
+ e.preventDefault();
438
+ dragging.current = true;
439
+ startX.current = e.clientX;
440
+ startWidth.current = widthRef.current;
441
+
442
+ const onMouseMove = (e: MouseEvent) => {
443
+ if (!dragging.current) return;
444
+ const delta = startX.current - e.clientX;
445
+ const newWidth = Math.max(240, Math.min(600, startWidth.current + delta));
446
+ setWidth(newWidth);
447
+ widthRef.current = newWidth;
448
+ };
449
+ const onMouseUp = () => {
450
+ dragging.current = false;
451
+ localStorage.setItem("canvas-sidebar-width", String(widthRef.current));
452
+ document.removeEventListener("mousemove", onMouseMove);
453
+ document.removeEventListener("mouseup", onMouseUp);
454
+ document.body.style.cursor = "";
455
+ document.body.style.userSelect = "";
456
+ };
457
+ document.addEventListener("mousemove", onMouseMove);
458
+ document.addEventListener("mouseup", onMouseUp);
459
+ document.body.style.cursor = "col-resize";
460
+ document.body.style.userSelect = "none";
461
+ }, []);
462
+
463
+ // Set CSS variable so center content can use it for margin-right
464
+ useEffect(() => {
465
+ document.documentElement.style.setProperty("--sidebar-width", `${width}px`);
466
+ return () => { document.documentElement.style.removeProperty("--sidebar-width"); };
467
+ }, [width]);
468
+
469
+ return (
470
+ <>
471
+ {/* Collapsed toggle button */}
472
+ {collapsed && (
473
+ <button
474
+ onClick={onToggle}
475
+ className="hidden lg:flex fixed top-3 right-3 z-20 w-7 h-7 items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors bg-bg-surface border border-border-subtle"
476
+ title="Show annotations"
477
+ >
478
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
479
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
480
+ </svg>
481
+ </button>
482
+ )}
483
+ <div className={`hidden lg:flex flex-col fixed top-0 right-0 bottom-0 bg-bg-surface border-l border-border-subtle z-10 transition-transform duration-200 ${collapsed ? "translate-x-full" : ""}`} style={{ width }}>
484
+ {/* Drag handle */}
485
+ <div
486
+ onMouseDown={onMouseDown}
487
+ className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-accent-blue/30 transition-colors z-20"
488
+ style={{ marginLeft: "-2px" }}
489
+ />
490
+ {React.Children.map(children, (child) =>
491
+ React.isValidElement(child)
492
+ ? React.cloneElement(child as React.ReactElement<any>, { collapseButton: (
493
+ <button
494
+ onClick={onToggle}
495
+ className="w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
496
+ title="Hide annotations"
497
+ >
498
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
499
+ <polyline points="13 17 18 12 13 7"/>
500
+ <polyline points="6 17 11 12 6 7"/>
501
+ </svg>
502
+ </button>
503
+ )})
504
+ : child
505
+ )}
506
+ </div>
507
+ </>
508
+ );
509
+ }
510
+
511
+ function ContentTabs() {
512
+ const { activeView, setActiveView, openFiles, closeFile } = React.useContext(ActiveViewContext);
513
+ const sessionId = React.useContext(SessionContext);
514
+ const { addAnnotationWithId, setActiveAnnotationId } = useAnnotations();
515
+
516
+ const showTabs = openFiles.length > 0;
517
+ if (!showTabs) return null;
518
+
519
+ return (
520
+ <div className="flex items-center border-b border-border-medium bg-bg-surface overflow-x-auto sticky top-0 z-10">
521
+ {/* Plan tab — only show when file tabs exist */}
522
+ {showTabs && (
523
+ <button
524
+ onClick={() => setActiveView({ type: "plan" })}
525
+ className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
526
+ activeView.type === "plan"
527
+ ? "text-text-primary"
528
+ : "text-text-tertiary hover:text-text-secondary"
529
+ }`}
530
+ >
531
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" className="opacity-60">
532
+ <path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
533
+ </svg>
534
+ Plan
535
+ {activeView.type === "plan" && (
536
+ <span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
537
+ )}
538
+ </button>
539
+ )}
540
+
541
+ {showTabs && <>
542
+ {/* Separator */}
543
+ <span className="w-px h-4 bg-border-medium flex-shrink-0" />
544
+
545
+ {/* File tabs */}
546
+ {openFiles.map((path, i) => {
547
+ const name = path.split("/").pop() || path;
548
+ const isActive = activeView.type === "file" && activeView.path === path;
549
+ return (
550
+ <React.Fragment key={path}>
551
+ <div className="flex items-center">
552
+ <button
553
+ onClick={() => setActiveView({ type: "file", path })}
554
+ className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-mono whitespace-nowrap transition-colors relative ${
555
+ isActive
556
+ ? "text-text-primary"
557
+ : "text-text-tertiary hover:text-text-secondary"
558
+ }`}
559
+ >
560
+ {name}
561
+ {isActive && (
562
+ <span className="absolute bottom-0 left-3 right-1 h-[2px] bg-text-primary rounded-full" />
563
+ )}
564
+ </button>
565
+ <button
566
+ onClick={(e) => { e.stopPropagation(); closeFile(path); }}
567
+ className="text-text-tertiary hover:text-text-secondary px-1.5 py-1 text-[10px] transition-colors rounded hover:bg-bg-input"
568
+ >
569
+ &#x2715;
570
+ </button>
571
+ </div>
572
+ {i < openFiles.length - 1 && (
573
+ <span className="w-px h-4 bg-border-medium flex-shrink-0" />
574
+ )}
575
+ </React.Fragment>
576
+ );
577
+ })}
578
+ </>}
579
+
580
+ {/* Right-aligned actions */}
581
+ <div className="ml-auto flex items-center gap-1 flex-shrink-0">
582
+ {activeView.type === "file" && (
583
+ <button
584
+ onClick={async () => {
585
+ const path = activeView.path;
586
+ try {
587
+ const res = await fetch(`/api/file?session=${sessionId}&path=${encodeURIComponent(path)}`);
588
+ const data = await res.json() as any;
589
+ if (!data.error) {
590
+ const preview = data.content.split("\n").slice(0, 3).join("\n") + (data.content.split("\n").length > 3 ? "\n..." : "");
591
+ const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
592
+ addAnnotationWithId(id, preview, "", path);
593
+ setActiveAnnotationId(id);
594
+ }
595
+ } catch {}
596
+ }}
597
+ className="flex items-center gap-1 px-3 py-1.5 text-[11px] font-medium font-body text-text-tertiary hover:text-text-secondary whitespace-nowrap transition-colors"
598
+ >
599
+ <span className="text-[13px] leading-none">+</span>
600
+ Add to context
601
+ </button>
602
+ )}
603
+ </div>
604
+ </div>
605
+ );
606
+ }
607
+
608
+ const root = document.getElementById("root");
609
+ if (root) {
610
+ createRoot(root).render(<App />);
611
+ }