agent-canvas 0.1.14 → 0.1.16
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/lib/commands/push.ts +26 -10
- package/bun.lock +3 -0
- package/daemon/client/AnnotationProvider.tsx +2 -2
- package/daemon/client/App.tsx +125 -47
- package/daemon/client/CompareView.tsx +100 -17
- package/daemon/client/OverviewView.tsx +326 -0
- package/daemon/client/PlanRenderer.tsx +13 -8
- package/daemon/client/RevisionSelect.tsx +16 -7
- package/daemon/client/components/Mermaid.tsx +3 -2
- package/daemon/client/generateMarkdown.ts +24 -5
- package/daemon/client/runtime.ts +9 -2
- package/daemon/client/unifiedDiff.ts +12 -1
- package/daemon/client/useTextAnnotation.tsx +6 -2
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +58 -58
- package/daemon/dist/components.js +23 -23
- package/daemon/dist/runtime.js +1 -1
- package/daemon/src/compiler.ts +100 -0
- package/daemon/src/handlers/api.ts +57 -26
- package/daemon/src/session.ts +140 -41
- package/daemon/src/watcher.ts +42 -30
- package/package.json +4 -3
- package/skills/canvas/SKILL.md +24 -19
- package/skills/canvas/flows.md +1 -1
package/bin/lib/commands/push.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "util";
|
|
2
|
-
import {
|
|
3
|
-
import { resolve } from "path";
|
|
2
|
+
import { existsSync, statSync } from "fs";
|
|
3
|
+
import { resolve, basename } from "path";
|
|
4
4
|
import { BASE_URL } from "../config.ts";
|
|
5
5
|
import { ensureDaemon } from "../daemon-lifecycle.ts";
|
|
6
6
|
import { getSessionId, openBrowser } from "../helpers.ts";
|
|
@@ -16,30 +16,40 @@ export async function handlePush(args: string[]) {
|
|
|
16
16
|
allowPositionals: true,
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
const
|
|
20
|
-
if (!
|
|
21
|
-
console.error("Error: No
|
|
19
|
+
const target = positionals[0];
|
|
20
|
+
if (!target) {
|
|
21
|
+
console.error("Error: No directory specified. Usage: agent-canvas push <directory>");
|
|
22
22
|
process.exit(1);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const resolvedPath = resolve(
|
|
25
|
+
const resolvedPath = resolve(target);
|
|
26
26
|
if (!existsSync(resolvedPath)) {
|
|
27
|
-
console.error(`Error:
|
|
27
|
+
console.error(`Error: Path not found: ${resolvedPath}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (statSync(resolvedPath).isFile()) {
|
|
32
|
+
console.error(`Error: Expected a directory, got a file: ${resolvedPath}`);
|
|
33
|
+
console.error("Usage: agent-canvas push <directory>");
|
|
28
34
|
process.exit(1);
|
|
29
35
|
}
|
|
30
|
-
const jsx = readFileSync(resolvedPath, "utf-8");
|
|
31
36
|
|
|
32
37
|
const sessionId = getSessionId(values.session);
|
|
33
38
|
const projectRoot = process.env.CANVAS_PROJECT_ROOT || process.cwd();
|
|
34
39
|
const label = values.label;
|
|
35
|
-
const autoLabel = !label ? resolvedPath
|
|
40
|
+
const autoLabel = !label ? basename(resolvedPath) : undefined;
|
|
36
41
|
|
|
37
42
|
await ensureDaemon();
|
|
38
43
|
|
|
39
44
|
const response = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
|
|
40
45
|
method: "POST",
|
|
41
46
|
headers: { "Content-Type": "application/json" },
|
|
42
|
-
body: JSON.stringify({
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
directory: resolvedPath,
|
|
49
|
+
projectRoot,
|
|
50
|
+
label: label || autoLabel,
|
|
51
|
+
...(values.response ? { response: values.response } : {}),
|
|
52
|
+
}),
|
|
43
53
|
});
|
|
44
54
|
|
|
45
55
|
const result = await response.json() as any;
|
|
@@ -50,6 +60,11 @@ export async function handlePush(args: string[]) {
|
|
|
50
60
|
console.log(result.unconsumedFeedback);
|
|
51
61
|
} else {
|
|
52
62
|
console.error(`Compilation error:\n${result.error}`);
|
|
63
|
+
if (result.errors) {
|
|
64
|
+
for (const [file, err] of Object.entries(result.errors)) {
|
|
65
|
+
console.error(` ${file}: ${err}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
53
68
|
}
|
|
54
69
|
process.exit(1);
|
|
55
70
|
}
|
|
@@ -63,5 +78,6 @@ export async function handlePush(args: string[]) {
|
|
|
63
78
|
browserUrl: result.browserUrl,
|
|
64
79
|
revision: result.revision,
|
|
65
80
|
sessionId,
|
|
81
|
+
canvasFiles: result.canvasFiles,
|
|
66
82
|
}));
|
|
67
83
|
}
|
package/bun.lock
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"marked": "^17.0.4",
|
|
9
9
|
"preact": "^10.25.0",
|
|
10
|
+
"preact-render-to-string": "^6.6.6",
|
|
10
11
|
},
|
|
11
12
|
"devDependencies": {
|
|
12
13
|
"@tailwindcss/cli": "4",
|
|
@@ -151,6 +152,8 @@
|
|
|
151
152
|
|
|
152
153
|
"preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="],
|
|
153
154
|
|
|
155
|
+
"preact-render-to-string": ["preact-render-to-string@6.6.6", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-EfqZJytnjJldV+YaaqhthU2oXsEf5e+6rDv957p+zxAvNfFLQOPfvBOTncscQ+akzu6Wrl7s3Pa0LjUQmWJsGQ=="],
|
|
156
|
+
|
|
154
157
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
|
155
158
|
|
|
156
159
|
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
|
@@ -82,8 +82,8 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }
|
|
|
82
82
|
return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
|
|
83
83
|
}, [annotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly]);
|
|
84
84
|
|
|
85
|
-
const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[]) => {
|
|
86
|
-
setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}) }]);
|
|
85
|
+
const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => {
|
|
86
|
+
setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}), ...(canvasFile ? { canvasFile } : {}) }]);
|
|
87
87
|
}, []);
|
|
88
88
|
|
|
89
89
|
const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
|
package/daemon/client/App.tsx
CHANGED
|
@@ -12,22 +12,27 @@ import { FileViewer } from "./FileViewer";
|
|
|
12
12
|
import { SessionSwitcher } from "./SessionSwitcher";
|
|
13
13
|
import { exportCanvasToMarkdown } from "./exportMarkdown";
|
|
14
14
|
import { CompareView } from "./CompareView";
|
|
15
|
+
import { OverviewView, categorizeChanges, getAffectedFiles } from "./OverviewView";
|
|
15
16
|
import { RevisionSelect } from "./RevisionSelect";
|
|
16
17
|
import { generateAnnotationId, RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
17
18
|
import { wrapRangeWithMark, restoreMarks, renameMarkId, unwrapMarks, updateAllMarkStates } from "./highlightRange";
|
|
18
19
|
import { extractContext } from "./annotationContext";
|
|
19
20
|
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
20
21
|
|
|
21
|
-
export type ActiveView = { type: "
|
|
22
|
+
export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
|
|
23
|
+
|
|
24
|
+
export interface CanvasFileInfo {
|
|
25
|
+
filename: string;
|
|
26
|
+
diffStats?: { added: number; removed: number };
|
|
27
|
+
}
|
|
22
28
|
|
|
23
29
|
export interface RevisionInfo {
|
|
24
30
|
revision: number;
|
|
25
31
|
label?: string;
|
|
26
|
-
|
|
32
|
+
canvasFiles: CanvasFileInfo[];
|
|
27
33
|
createdAt: string;
|
|
28
34
|
hasFeedback: boolean;
|
|
29
35
|
response?: string;
|
|
30
|
-
diffStats?: { added: number; removed: number };
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export const ActiveViewContext = createContext<{
|
|
@@ -35,11 +40,13 @@ export const ActiveViewContext = createContext<{
|
|
|
35
40
|
setActiveView: (v: ActiveView) => void;
|
|
36
41
|
openFiles: string[];
|
|
37
42
|
closeFile: (path: string) => void;
|
|
43
|
+
canvasFiles: string[];
|
|
38
44
|
}>({
|
|
39
|
-
activeView: { type: "
|
|
45
|
+
activeView: { type: "overview" },
|
|
40
46
|
setActiveView: () => {},
|
|
41
47
|
openFiles: [],
|
|
42
48
|
closeFile: () => {},
|
|
49
|
+
canvasFiles: [],
|
|
43
50
|
});
|
|
44
51
|
|
|
45
52
|
export const RevisionContext = createContext<{
|
|
@@ -146,14 +153,11 @@ function RevisionSelector() {
|
|
|
146
153
|
/>
|
|
147
154
|
<button
|
|
148
155
|
onClick={() => {
|
|
149
|
-
const current = revisions.find((r) => r.revision === selectedRevision);
|
|
150
|
-
const sourceFile = current?.sourceFile;
|
|
151
156
|
let left: number;
|
|
152
157
|
if (selectedRevision === currentRevision) {
|
|
153
|
-
// Find previous revision with same sourceFile
|
|
154
158
|
const prev = [...revisions]
|
|
155
159
|
.reverse()
|
|
156
|
-
.find((r) => r.revision < currentRevision
|
|
160
|
+
.find((r) => r.revision < currentRevision);
|
|
157
161
|
left = prev?.revision ?? Math.max(1, currentRevision - 1);
|
|
158
162
|
} else {
|
|
159
163
|
left = selectedRevision;
|
|
@@ -176,7 +180,8 @@ function App() {
|
|
|
176
180
|
const [compareRevision, setCompareRevision] = useState<{ left: number; right: number } | null>(null);
|
|
177
181
|
const [connected, setConnected] = useState(false);
|
|
178
182
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
179
|
-
const [
|
|
183
|
+
const [canvasFiles, setCanvasFiles] = useState<string[]>([]);
|
|
184
|
+
const [activeView, setActiveViewRaw] = useState<ActiveView>({ type: "overview" });
|
|
180
185
|
const [openFiles, setOpenFiles] = useState<string[]>([]);
|
|
181
186
|
const wsRef = useRef<WebSocket | null>(null);
|
|
182
187
|
|
|
@@ -196,6 +201,10 @@ function App() {
|
|
|
196
201
|
}
|
|
197
202
|
if (activeView.type === "file") {
|
|
198
203
|
parts.push(activeView.path.split("/").pop() || activeView.path);
|
|
204
|
+
} else if (activeView.type === "overview") {
|
|
205
|
+
parts.push("Overview");
|
|
206
|
+
} else if (activeView.type === "canvas" && canvasFiles.length > 1 && activeView.filename) {
|
|
207
|
+
parts.push(activeView.filename.replace(/\.jsx$/, ""));
|
|
199
208
|
} else if (compareRevision) {
|
|
200
209
|
parts.push("Compare");
|
|
201
210
|
}
|
|
@@ -210,10 +219,8 @@ function App() {
|
|
|
210
219
|
|
|
211
220
|
const setActiveView = useCallback((v: ActiveView) => {
|
|
212
221
|
// Save current scroll position before switching
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
window.scrollY
|
|
216
|
-
);
|
|
222
|
+
const key = activeView.type === "overview" ? "overview" : activeView.type === "canvas" ? `canvas:${activeView.filename}` : `file:${activeView.path}`;
|
|
223
|
+
scrollPositions.current.set(key, window.scrollY);
|
|
217
224
|
setActiveViewRaw(v);
|
|
218
225
|
if (v.type === "file") {
|
|
219
226
|
setOpenFiles((prev) => prev.includes(v.path) ? prev : [...prev, v.path]);
|
|
@@ -225,16 +232,15 @@ function App() {
|
|
|
225
232
|
setOpenFiles((prev) => prev.filter((p) => p !== path));
|
|
226
233
|
setActiveViewRaw((prev) => {
|
|
227
234
|
if (prev.type === "file" && prev.path === path) {
|
|
228
|
-
|
|
229
|
-
return { type: "plan" };
|
|
235
|
+
return canvasFiles.length > 1 ? { type: "overview" } : { type: "canvas", filename: canvasFiles[0] || "" };
|
|
230
236
|
}
|
|
231
237
|
return prev;
|
|
232
238
|
});
|
|
233
|
-
}, []);
|
|
239
|
+
}, [canvasFiles]);
|
|
234
240
|
|
|
235
241
|
// Restore scroll synchronously after DOM update (before paint)
|
|
236
242
|
useLayoutEffect(() => {
|
|
237
|
-
const key = activeView.type === "
|
|
243
|
+
const key = activeView.type === "overview" ? "overview" : activeView.type === "canvas" ? `canvas:${activeView.filename}` : `file:${activeView.path}`;
|
|
238
244
|
window.scrollTo(0, scrollPositions.current.get(key) || 0);
|
|
239
245
|
}, [activeView]);
|
|
240
246
|
|
|
@@ -249,6 +255,20 @@ function App() {
|
|
|
249
255
|
setSelectedRevision(data.currentRevision);
|
|
250
256
|
setRevisions(data.revisions || []);
|
|
251
257
|
}
|
|
258
|
+
if (data.canvasFiles) {
|
|
259
|
+
const files = (data.canvasFiles as string[]).sort();
|
|
260
|
+
setCanvasFiles(files);
|
|
261
|
+
setActiveViewRaw((prev) => {
|
|
262
|
+
if (prev.type === "overview") {
|
|
263
|
+
// On initial load: overview if multiple files, direct if single
|
|
264
|
+
return files.length > 1 ? prev : { type: "canvas", filename: files[0] || "" };
|
|
265
|
+
}
|
|
266
|
+
if (prev.type === "canvas" && (!prev.filename || !files.includes(prev.filename))) {
|
|
267
|
+
return { type: "canvas", filename: files[0] || "" };
|
|
268
|
+
}
|
|
269
|
+
return prev;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
252
272
|
if (data.projectRoot) setProjectRoot(data.projectRoot);
|
|
253
273
|
})
|
|
254
274
|
.catch(() => {});
|
|
@@ -270,7 +290,26 @@ function App() {
|
|
|
270
290
|
setCurrentRevision(data.currentRevision);
|
|
271
291
|
setSelectedRevision(data.currentRevision);
|
|
272
292
|
setCompareRevision(null);
|
|
273
|
-
if (data.revisions)
|
|
293
|
+
if (data.revisions) {
|
|
294
|
+
const allRevisions = data.revisions as RevisionInfo[];
|
|
295
|
+
setRevisions(allRevisions);
|
|
296
|
+
const latest = allRevisions.find((r: RevisionInfo) => r.revision === data.currentRevision);
|
|
297
|
+
if (latest?.canvasFiles) {
|
|
298
|
+
const files = latest.canvasFiles.map((cf: CanvasFileInfo) => cf.filename).sort();
|
|
299
|
+
setCanvasFiles(files);
|
|
300
|
+
// Determine what changed and auto-navigate
|
|
301
|
+
const previous = allRevisions.find((r: RevisionInfo) => r.revision === data.currentRevision - 1);
|
|
302
|
+
const changes = categorizeChanges(latest, previous);
|
|
303
|
+
const affected = getAffectedFiles(changes);
|
|
304
|
+
if (affected.length === 1) {
|
|
305
|
+
setActiveViewRaw({ type: "canvas", filename: affected[0] });
|
|
306
|
+
} else if (affected.length > 1 || files.length > 1) {
|
|
307
|
+
setActiveViewRaw({ type: "overview" });
|
|
308
|
+
} else {
|
|
309
|
+
setActiveViewRaw({ type: "canvas", filename: files[0] || "" });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
274
313
|
}
|
|
275
314
|
if (data.type === "revision-updated") {
|
|
276
315
|
if (data.revisions) setRevisions(data.revisions);
|
|
@@ -299,7 +338,7 @@ function App() {
|
|
|
299
338
|
<SessionContext.Provider value={sessionId}>
|
|
300
339
|
<RevisionContext.Provider value={{ currentRevision, selectedRevision, revisions, setSelectedRevision, isReadOnly, compareRevision, setCompareRevision }}>
|
|
301
340
|
<AnnotationProvider key={`${sessionId}:${selectedRevision}`} sessionId={sessionId} revision={selectedRevision} isReadOnly={isReadOnly}>
|
|
302
|
-
<ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile }}>
|
|
341
|
+
<ActiveViewContext.Provider value={{ activeView, setActiveView, openFiles, closeFile, canvasFiles }}>
|
|
303
342
|
<ActiveViewCtx.Provider value={{ setActiveView }}>
|
|
304
343
|
<div className="min-h-screen bg-bg-base">
|
|
305
344
|
{/* Left panel — fixed to viewport */}
|
|
@@ -344,12 +383,19 @@ function App() {
|
|
|
344
383
|
</div>
|
|
345
384
|
|
|
346
385
|
<ContentTabs />
|
|
347
|
-
{/*
|
|
348
|
-
|
|
386
|
+
{/* Overview — shows all affected canvases inline */}
|
|
387
|
+
{activeView.type === "overview" && (
|
|
388
|
+
<OverviewView
|
|
389
|
+
revision={selectedRevision}
|
|
390
|
+
responseBanner={selectedRevInfo?.response ? <ResponseBanner markdown={selectedRevInfo.response} /> : undefined}
|
|
391
|
+
/>
|
|
392
|
+
)}
|
|
393
|
+
{/* Individual canvas view — only mounted when active */}
|
|
394
|
+
{activeView.type === "canvas" && canvasFiles.includes(activeView.filename) && (
|
|
349
395
|
<div className="relative max-w-[720px] mx-auto px-6 pt-12 pb-32">
|
|
350
396
|
<button
|
|
351
397
|
onClick={() => {
|
|
352
|
-
const planContent = document.querySelector(".plan-content
|
|
398
|
+
const planContent = document.querySelector(`[data-canvas-file="${activeView.filename}"] .plan-content`);
|
|
353
399
|
if (!planContent) return;
|
|
354
400
|
const md = exportCanvasToMarkdown(planContent as HTMLElement);
|
|
355
401
|
navigator.clipboard.writeText(md).then(() => {
|
|
@@ -372,12 +418,15 @@ function App() {
|
|
|
372
418
|
<polyline points="20 6 9 17 4 12"/>
|
|
373
419
|
</svg>
|
|
374
420
|
</button>
|
|
375
|
-
{
|
|
421
|
+
{/* Show response banner on individual canvas only when there's no overview (single canvas) */}
|
|
422
|
+
{canvasFiles.length <= 1 && selectedRevInfo?.response && (
|
|
376
423
|
<ResponseBanner markdown={selectedRevInfo.response} />
|
|
377
424
|
)}
|
|
378
|
-
<
|
|
425
|
+
<div data-canvas-file={activeView.filename}>
|
|
426
|
+
<PlanRenderer key={activeView.filename} revision={selectedRevision} filename={activeView.filename} />
|
|
427
|
+
</div>
|
|
379
428
|
</div>
|
|
380
|
-
|
|
429
|
+
)}
|
|
381
430
|
{openFiles.map((filePath) => (
|
|
382
431
|
<div key={filePath} style={{ display: activeView.type === "file" && activeView.path === filePath ? undefined : "none" }}>
|
|
383
432
|
<FileViewer path={filePath} />
|
|
@@ -715,37 +764,66 @@ function ResponseBanner({ markdown }: { markdown: string }) {
|
|
|
715
764
|
}
|
|
716
765
|
|
|
717
766
|
function ContentTabs() {
|
|
718
|
-
const { activeView, setActiveView, openFiles, closeFile } = React.useContext(ActiveViewContext);
|
|
767
|
+
const { activeView, setActiveView, openFiles, closeFile, canvasFiles } = React.useContext(ActiveViewContext);
|
|
719
768
|
const sessionId = React.useContext(SessionContext);
|
|
720
769
|
const { addAnnotationWithId, setActiveAnnotationId } = useAnnotations();
|
|
721
770
|
|
|
722
|
-
const showTabs = openFiles.length > 0;
|
|
771
|
+
const showTabs = canvasFiles.length > 1 || openFiles.length > 0;
|
|
723
772
|
if (!showTabs) return null;
|
|
724
773
|
|
|
725
774
|
return (
|
|
726
775
|
<div className="flex items-center border-b border-border-medium bg-bg-surface overflow-x-auto sticky top-0 z-10">
|
|
727
|
-
{/*
|
|
728
|
-
{
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
<
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
776
|
+
{/* Overview tab — only when multiple canvases */}
|
|
777
|
+
{canvasFiles.length > 1 && (
|
|
778
|
+
<>
|
|
779
|
+
<button
|
|
780
|
+
onClick={() => setActiveView({ type: "overview" })}
|
|
781
|
+
className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
|
|
782
|
+
activeView.type === "overview"
|
|
783
|
+
? "text-text-primary"
|
|
784
|
+
: "text-text-tertiary hover:text-text-secondary"
|
|
785
|
+
}`}
|
|
786
|
+
>
|
|
787
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="opacity-60">
|
|
788
|
+
<path d="M12 7L2 12l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
789
|
+
</svg>
|
|
790
|
+
Overview
|
|
791
|
+
{activeView.type === "overview" && (
|
|
792
|
+
<span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
|
|
793
|
+
)}
|
|
794
|
+
</button>
|
|
795
|
+
<span className="w-px h-4 bg-border-medium flex-shrink-0" />
|
|
796
|
+
</>
|
|
745
797
|
)}
|
|
746
798
|
|
|
747
|
-
{
|
|
748
|
-
{
|
|
799
|
+
{/* Canvas tabs */}
|
|
800
|
+
{canvasFiles.map((filename, i) => {
|
|
801
|
+
const label = filename.replace(/\.jsx$/, "");
|
|
802
|
+
const isActive = activeView.type === "canvas" && activeView.filename === filename;
|
|
803
|
+
return (
|
|
804
|
+
<React.Fragment key={filename}>
|
|
805
|
+
<button
|
|
806
|
+
onClick={() => setActiveView({ type: "canvas", filename })}
|
|
807
|
+
className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
|
|
808
|
+
isActive
|
|
809
|
+
? "text-text-primary"
|
|
810
|
+
: "text-text-tertiary hover:text-text-secondary"
|
|
811
|
+
}`}
|
|
812
|
+
>
|
|
813
|
+
{label}
|
|
814
|
+
{isActive && (
|
|
815
|
+
<span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
|
|
816
|
+
)}
|
|
817
|
+
</button>
|
|
818
|
+
{i < canvasFiles.length - 1 && (
|
|
819
|
+
<span className="w-px h-4 bg-border-medium flex-shrink-0" />
|
|
820
|
+
)}
|
|
821
|
+
</React.Fragment>
|
|
822
|
+
);
|
|
823
|
+
})}
|
|
824
|
+
|
|
825
|
+
{openFiles.length > 0 && <>
|
|
826
|
+
{/* Separator between canvas and file tabs */}
|
|
749
827
|
<span className="w-px h-4 bg-border-medium flex-shrink-0" />
|
|
750
828
|
|
|
751
829
|
{/* File tabs */}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
|
1
|
+
import React, { useEffect, useState, useRef, useCallback, useMemo, useContext } from "react";
|
|
2
2
|
import { RevisionSelect } from "./RevisionSelect";
|
|
3
|
+
import { RevisionContext, type RevisionInfo, type CanvasFileInfo } from "./App";
|
|
3
4
|
import { runDomDiff } from "./domDiff";
|
|
4
5
|
import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
|
|
5
6
|
import { useAnnotations } from "./AnnotationProvider";
|
|
@@ -15,12 +16,43 @@ interface CompareViewProps {
|
|
|
15
16
|
|
|
16
17
|
type CompareMode = "unified" | "side-by-side";
|
|
17
18
|
|
|
19
|
+
/** Get union of canvas filenames from two revisions */
|
|
20
|
+
function getCompareFiles(revisions: RevisionInfo[], leftRev: number, rightRev: number): string[] {
|
|
21
|
+
const left = revisions.find(r => r.revision === leftRev);
|
|
22
|
+
const right = revisions.find(r => r.revision === rightRev);
|
|
23
|
+
const files = new Set<string>();
|
|
24
|
+
for (const cf of left?.canvasFiles || []) files.add(cf.filename);
|
|
25
|
+
for (const cf of right?.canvasFiles || []) files.add(cf.filename);
|
|
26
|
+
return [...files].sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
export function CompareView({ initialLeft, initialRight, sessionId, onExit }: CompareViewProps) {
|
|
19
30
|
const [leftRev, setLeftRev] = useState(initialLeft);
|
|
20
31
|
const [rightRev, setRightRev] = useState(initialRight);
|
|
21
32
|
const [mode, setMode] = useState<CompareMode>("unified");
|
|
22
33
|
const [hasChanges, setHasChanges] = useState(true);
|
|
23
34
|
const [diffDone, setDiffDone] = useState(false);
|
|
35
|
+
const { revisions } = useContext(RevisionContext);
|
|
36
|
+
|
|
37
|
+
const compareFiles = useMemo(() => getCompareFiles(revisions, leftRev, rightRev), [revisions, leftRev, rightRev]);
|
|
38
|
+
const [selectedFile, setSelectedFile] = useState<string>(() => compareFiles[0] || "plan.jsx");
|
|
39
|
+
|
|
40
|
+
// Reset selected file when revisions change
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!compareFiles.includes(selectedFile)) {
|
|
43
|
+
setSelectedFile(compareFiles[0] || "plan.jsx");
|
|
44
|
+
}
|
|
45
|
+
}, [compareFiles, selectedFile]);
|
|
46
|
+
|
|
47
|
+
// Check if file exists in each revision
|
|
48
|
+
const leftHasFile = useMemo(() => {
|
|
49
|
+
const rev = revisions.find(r => r.revision === leftRev);
|
|
50
|
+
return rev?.canvasFiles.some(cf => cf.filename === selectedFile) ?? false;
|
|
51
|
+
}, [revisions, leftRev, selectedFile]);
|
|
52
|
+
const rightHasFile = useMemo(() => {
|
|
53
|
+
const rev = revisions.find(r => r.revision === rightRev);
|
|
54
|
+
return rev?.canvasFiles.some(cf => cf.filename === selectedFile) ?? false;
|
|
55
|
+
}, [revisions, rightRev, selectedFile]);
|
|
24
56
|
|
|
25
57
|
return (
|
|
26
58
|
<div className="flex flex-col h-screen">
|
|
@@ -74,20 +106,65 @@ export function CompareView({ initialLeft, initialRight, sessionId, onExit }: Co
|
|
|
74
106
|
</div>
|
|
75
107
|
</div>
|
|
76
108
|
|
|
77
|
-
{
|
|
109
|
+
{/* Canvas file selector — only show when multiple files */}
|
|
110
|
+
{compareFiles.length > 1 && (
|
|
111
|
+
<div className="flex items-center gap-1 px-6 py-2 border-b border-border-subtle bg-bg-surface flex-shrink-0 overflow-x-auto">
|
|
112
|
+
{compareFiles.map(filename => {
|
|
113
|
+
const label = filename.replace(/\.jsx$/, "");
|
|
114
|
+
const isSelected = filename === selectedFile;
|
|
115
|
+
const inLeft = revisions.find(r => r.revision === leftRev)?.canvasFiles.some(cf => cf.filename === filename);
|
|
116
|
+
const inRight = revisions.find(r => r.revision === rightRev)?.canvasFiles.some(cf => cf.filename === filename);
|
|
117
|
+
const statusLabel = !inLeft ? " (added)" : !inRight ? " (removed)" : "";
|
|
118
|
+
return (
|
|
119
|
+
<button
|
|
120
|
+
key={filename}
|
|
121
|
+
onClick={() => setSelectedFile(filename)}
|
|
122
|
+
className={`text-[12px] font-body px-3 py-1 rounded-md transition-colors whitespace-nowrap ${
|
|
123
|
+
isSelected
|
|
124
|
+
? "bg-bg-elevated text-text-primary font-medium"
|
|
125
|
+
: "text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated"
|
|
126
|
+
}`}
|
|
127
|
+
>
|
|
128
|
+
{label}
|
|
129
|
+
{statusLabel && (
|
|
130
|
+
<span className={`ml-1 text-[10px] ${!inLeft ? "text-accent-green" : "text-accent-red"}`}>
|
|
131
|
+
{statusLabel}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
134
|
+
</button>
|
|
135
|
+
);
|
|
136
|
+
})}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{/* Show placeholder for files that only exist in one revision */}
|
|
141
|
+
{!leftHasFile || !rightHasFile ? (
|
|
142
|
+
<div className="flex-1 flex items-center justify-center">
|
|
143
|
+
<div className="text-center text-text-tertiary font-body">
|
|
144
|
+
<div className="text-lg mb-2">
|
|
145
|
+
{!leftHasFile ? "New file" : "Removed file"}
|
|
146
|
+
</div>
|
|
147
|
+
<div className="text-[13px]">
|
|
148
|
+
{selectedFile.replace(/\.jsx$/, "")} {!leftHasFile ? "was added" : "was removed"} in this revision
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
) : mode === "unified" ? (
|
|
78
153
|
<UnifiedView
|
|
79
|
-
key={`unified-${leftRev}-${rightRev}`}
|
|
154
|
+
key={`unified-${leftRev}-${rightRev}-${selectedFile}`}
|
|
80
155
|
sessionId={sessionId}
|
|
81
156
|
leftRev={leftRev}
|
|
82
157
|
rightRev={rightRev}
|
|
158
|
+
filename={selectedFile}
|
|
83
159
|
onDiffResult={(done, changes) => { setDiffDone(done); setHasChanges(changes); }}
|
|
84
160
|
/>
|
|
85
161
|
) : (
|
|
86
162
|
<SideBySideView
|
|
87
|
-
key={`sbs-${leftRev}-${rightRev}`}
|
|
163
|
+
key={`sbs-${leftRev}-${rightRev}-${selectedFile}`}
|
|
88
164
|
sessionId={sessionId}
|
|
89
165
|
leftRev={leftRev}
|
|
90
166
|
rightRev={rightRev}
|
|
167
|
+
filename={selectedFile}
|
|
91
168
|
onDiffResult={(done, changes) => { setDiffDone(done); setHasChanges(changes); }}
|
|
92
169
|
/>
|
|
93
170
|
)}
|
|
@@ -99,7 +176,6 @@ export function CompareView({ initialLeft, initialRight, sessionId, onExit }: Co
|
|
|
99
176
|
|
|
100
177
|
function SyncScrollCheckbox() {
|
|
101
178
|
const [checked, setChecked] = useState(true);
|
|
102
|
-
// Store in a ref accessible to SideBySideView via DOM attribute
|
|
103
179
|
useEffect(() => {
|
|
104
180
|
document.documentElement.dataset.syncScroll = checked ? "1" : "0";
|
|
105
181
|
return () => { delete document.documentElement.dataset.syncScroll; };
|
|
@@ -124,10 +200,11 @@ interface ViewProps {
|
|
|
124
200
|
sessionId: string;
|
|
125
201
|
leftRev: number;
|
|
126
202
|
rightRev: number;
|
|
203
|
+
filename: string;
|
|
127
204
|
onDiffResult: (done: boolean, hasChanges: boolean) => void;
|
|
128
205
|
}
|
|
129
206
|
|
|
130
|
-
function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps) {
|
|
207
|
+
function UnifiedView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
|
|
131
208
|
const oldRef = useRef<HTMLDivElement>(null);
|
|
132
209
|
const newRef = useRef<HTMLDivElement>(null);
|
|
133
210
|
const unifiedRef = useRef<HTMLDivElement>(null);
|
|
@@ -139,15 +216,15 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
|
|
|
139
216
|
const [slow, setSlow] = useState(false);
|
|
140
217
|
|
|
141
218
|
const { annotations } = useAnnotations();
|
|
142
|
-
const
|
|
219
|
+
const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename));
|
|
143
220
|
const { popovers } = useTextAnnotation({
|
|
144
221
|
containerRef: unifiedRef,
|
|
145
222
|
restoreKey: done,
|
|
146
|
-
restoreAnnotations:
|
|
223
|
+
restoreAnnotations: canvasAnnotations,
|
|
147
224
|
extractContext: (range) => extractContext(range, unifiedRef.current!),
|
|
148
225
|
});
|
|
149
226
|
|
|
150
|
-
// Detect stuck loading
|
|
227
|
+
// Detect stuck loading
|
|
151
228
|
useEffect(() => {
|
|
152
229
|
if (done || loadError) return;
|
|
153
230
|
const id = setTimeout(() => {
|
|
@@ -192,13 +269,14 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
|
|
|
192
269
|
|
|
193
270
|
return (
|
|
194
271
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
195
|
-
{/* Offscreen source panels
|
|
272
|
+
{/* Offscreen source panels */}
|
|
196
273
|
{!done && !loadError && (
|
|
197
274
|
<div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
|
|
198
275
|
<ComparePanelRenderer
|
|
199
276
|
ref={oldRef}
|
|
200
277
|
sessionId={sessionId}
|
|
201
278
|
revision={leftRev}
|
|
279
|
+
filename={filename}
|
|
202
280
|
onReady={() => setLeftReady(true)}
|
|
203
281
|
onError={(msg) => setLoadError(`Rev ${leftRev}: ${msg}`)}
|
|
204
282
|
/>
|
|
@@ -206,6 +284,7 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
|
|
|
206
284
|
ref={newRef}
|
|
207
285
|
sessionId={sessionId}
|
|
208
286
|
revision={rightRev}
|
|
287
|
+
filename={filename}
|
|
209
288
|
onReady={() => setRightReady(true)}
|
|
210
289
|
onError={(msg) => setLoadError(`Rev ${rightRev}: ${msg}`)}
|
|
211
290
|
/>
|
|
@@ -239,7 +318,7 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
|
|
|
239
318
|
|
|
240
319
|
/* ── Side-by-side view ── */
|
|
241
320
|
|
|
242
|
-
function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps) {
|
|
321
|
+
function SideBySideView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
|
|
243
322
|
const oldRef = useRef<HTMLDivElement>(null);
|
|
244
323
|
const newRef = useRef<HTMLDivElement>(null);
|
|
245
324
|
const [leftReady, setLeftReady] = useState(false);
|
|
@@ -255,11 +334,11 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
255
334
|
const handleRightReady = useCallback(() => setRightReady(true), []);
|
|
256
335
|
|
|
257
336
|
const { annotations } = useAnnotations();
|
|
258
|
-
const
|
|
337
|
+
const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename));
|
|
259
338
|
const { popovers } = useTextAnnotation({
|
|
260
339
|
containerRef: viewRef,
|
|
261
340
|
restoreKey: done,
|
|
262
|
-
restoreAnnotations:
|
|
341
|
+
restoreAnnotations: canvasAnnotations,
|
|
263
342
|
extractContext: (range) => extractContext(range, viewRef.current!),
|
|
264
343
|
});
|
|
265
344
|
|
|
@@ -314,6 +393,7 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
314
393
|
ref={oldRef}
|
|
315
394
|
sessionId={sessionId}
|
|
316
395
|
revision={leftRev}
|
|
396
|
+
filename={filename}
|
|
317
397
|
onReady={handleLeftReady}
|
|
318
398
|
/>
|
|
319
399
|
</div>
|
|
@@ -324,6 +404,7 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
324
404
|
ref={newRef}
|
|
325
405
|
sessionId={sessionId}
|
|
326
406
|
revision={rightRev}
|
|
407
|
+
filename={filename}
|
|
327
408
|
onReady={handleRightReady}
|
|
328
409
|
/>
|
|
329
410
|
</div>
|
|
@@ -338,12 +419,13 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
338
419
|
interface ComparePanelRendererProps {
|
|
339
420
|
sessionId: string;
|
|
340
421
|
revision: number;
|
|
422
|
+
filename: string;
|
|
341
423
|
onReady: () => void;
|
|
342
424
|
onError?: (message: string) => void;
|
|
343
425
|
}
|
|
344
426
|
|
|
345
|
-
const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
|
|
346
|
-
({ sessionId, revision, onReady, onError }, ref) => {
|
|
427
|
+
export const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
|
|
428
|
+
({ sessionId, revision, filename, onReady, onError }, ref) => {
|
|
347
429
|
const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
|
|
348
430
|
const [error, setError] = useState<string | null>(null);
|
|
349
431
|
const [loading, setLoading] = useState(true);
|
|
@@ -351,7 +433,8 @@ const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, Compare
|
|
|
351
433
|
useEffect(() => {
|
|
352
434
|
setLoading(true);
|
|
353
435
|
setError(null);
|
|
354
|
-
|
|
436
|
+
const jsName = filename.replace(/\.jsx$/, ".js");
|
|
437
|
+
import(`/api/session/${sessionId}/canvas/${encodeURIComponent(jsName)}?rev=${revision}&t=${Date.now()}`)
|
|
355
438
|
.then((mod) => {
|
|
356
439
|
if (!mod.default) {
|
|
357
440
|
const msg = "Module has no default export";
|
|
@@ -368,7 +451,7 @@ const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, Compare
|
|
|
368
451
|
setLoading(false);
|
|
369
452
|
onError?.(e.message);
|
|
370
453
|
});
|
|
371
|
-
}, [sessionId, revision]);
|
|
454
|
+
}, [sessionId, revision, filename]);
|
|
372
455
|
|
|
373
456
|
useEffect(() => {
|
|
374
457
|
if (!PlanComponent) return;
|