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,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">✕</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
|
+
✕
|
|
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
|
+
}
|