agent-canvas 0.1.13 → 0.1.15

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.
@@ -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
- {mode === "unified" ? (
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,72 +200,113 @@ 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);
134
211
  const [leftReady, setLeftReady] = useState(false);
135
212
  const [rightReady, setRightReady] = useState(false);
136
213
  const [done, setDone] = useState(false);
214
+ const [loadError, setLoadError] = useState<string | null>(null);
215
+
216
+ const [slow, setSlow] = useState(false);
137
217
 
138
218
  const { annotations } = useAnnotations();
139
- const planAnnotations = annotations.filter(a => !a.filePath);
219
+ const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename));
140
220
  const { popovers } = useTextAnnotation({
141
221
  containerRef: unifiedRef,
142
222
  restoreKey: done,
143
- restoreAnnotations: planAnnotations,
223
+ restoreAnnotations: canvasAnnotations,
144
224
  extractContext: (range) => extractContext(range, unifiedRef.current!),
145
225
  });
146
226
 
227
+ // Detect stuck loading
228
+ useEffect(() => {
229
+ if (done || loadError) return;
230
+ const id = setTimeout(() => {
231
+ console.warn("[unified-diff] still loading after 10s", {
232
+ leftReady, rightReady, oldRef: !!oldRef.current, newRef: !!newRef.current,
233
+ });
234
+ setSlow(true);
235
+ }, 10_000);
236
+ return () => clearTimeout(id);
237
+ }, [done, loadError, leftReady, rightReady]);
238
+
147
239
  useEffect(() => {
148
240
  if (!leftReady || !rightReady || done) return;
149
241
  if (!oldRef.current || !newRef.current) return;
150
242
 
151
243
  requestAnimationFrame(() => {
152
244
  requestAnimationFrame(() => {
153
- if (!oldRef.current || !newRef.current || !unifiedRef.current) return;
154
-
155
- const oldTree = extractBlockTree(oldRef.current);
156
- const newTree = extractBlockTree(newRef.current);
157
- const matches = matchBlocks(oldTree, newTree);
158
- const { fragment, hasChanges } = buildUnifiedDom(matches);
159
-
160
- unifiedRef.current.innerHTML = "";
161
- unifiedRef.current.appendChild(fragment);
162
- setDone(true);
163
- onDiffResult(true, hasChanges);
245
+ if (!oldRef.current || !newRef.current || !unifiedRef.current) {
246
+ console.warn("[unified-diff] refs lost between rAF frames", {
247
+ old: !!oldRef.current, new: !!newRef.current, unified: !!unifiedRef.current,
248
+ });
249
+ return;
250
+ }
251
+
252
+ try {
253
+ const oldTree = extractBlockTree(oldRef.current);
254
+ const newTree = extractBlockTree(newRef.current);
255
+ const matches = matchBlocks(oldTree, newTree);
256
+ const { fragment, hasChanges } = buildUnifiedDom(matches);
257
+
258
+ unifiedRef.current.innerHTML = "";
259
+ unifiedRef.current.appendChild(fragment);
260
+ setDone(true);
261
+ onDiffResult(true, hasChanges);
262
+ } catch (e) {
263
+ console.error("[unified-diff] diff computation failed", e);
264
+ setLoadError(e instanceof Error ? e.message : String(e));
265
+ }
164
266
  });
165
267
  });
166
268
  }, [leftReady, rightReady, done]);
167
269
 
168
270
  return (
169
271
  <div className="flex-1 min-h-0 overflow-y-auto">
170
- {/* Offscreen source panels — unmount after diff is built */}
171
- {!done && (
272
+ {/* Offscreen source panels */}
273
+ {!done && !loadError && (
172
274
  <div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
173
275
  <ComparePanelRenderer
174
276
  ref={oldRef}
175
277
  sessionId={sessionId}
176
278
  revision={leftRev}
279
+ filename={filename}
177
280
  onReady={() => setLeftReady(true)}
281
+ onError={(msg) => setLoadError(`Rev ${leftRev}: ${msg}`)}
178
282
  />
179
283
  <ComparePanelRenderer
180
284
  ref={newRef}
181
285
  sessionId={sessionId}
182
286
  revision={rightRev}
287
+ filename={filename}
183
288
  onReady={() => setRightReady(true)}
289
+ onError={(msg) => setLoadError(`Rev ${rightRev}: ${msg}`)}
184
290
  />
185
291
  </div>
186
292
  )}
187
293
 
188
294
  {/* Visible output */}
189
295
  <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
190
- {!done && (
191
- <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">
192
- Loading...
296
+ {!done && !loadError && (
297
+ <div className="flex flex-col items-center justify-center h-64 text-text-tertiary font-body text-body gap-2">
298
+ <span>Loading...</span>
299
+ {slow && (
300
+ <span className="text-[11px]">
301
+ Taking longer than expected — check Network tab for pending requests
302
+ </span>
303
+ )}
304
+ </div>
305
+ )}
306
+ {loadError && (
307
+ <div className="bg-accent-red-muted rounded-lg p-5 mt-8">
308
+ <h3 className="font-body font-semibold text-accent-red mb-2">Failed to load revision</h3>
309
+ <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{loadError}</pre>
193
310
  </div>
194
311
  )}
195
312
  <div ref={unifiedRef} />
@@ -201,7 +318,7 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
201
318
 
202
319
  /* ── Side-by-side view ── */
203
320
 
204
- function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps) {
321
+ function SideBySideView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
205
322
  const oldRef = useRef<HTMLDivElement>(null);
206
323
  const newRef = useRef<HTMLDivElement>(null);
207
324
  const [leftReady, setLeftReady] = useState(false);
@@ -217,11 +334,11 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
217
334
  const handleRightReady = useCallback(() => setRightReady(true), []);
218
335
 
219
336
  const { annotations } = useAnnotations();
220
- const planAnnotations = annotations.filter(a => !a.filePath);
337
+ const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename));
221
338
  const { popovers } = useTextAnnotation({
222
339
  containerRef: viewRef,
223
340
  restoreKey: done,
224
- restoreAnnotations: planAnnotations,
341
+ restoreAnnotations: canvasAnnotations,
225
342
  extractContext: (range) => extractContext(range, viewRef.current!),
226
343
  });
227
344
 
@@ -276,6 +393,7 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
276
393
  ref={oldRef}
277
394
  sessionId={sessionId}
278
395
  revision={leftRev}
396
+ filename={filename}
279
397
  onReady={handleLeftReady}
280
398
  />
281
399
  </div>
@@ -286,6 +404,7 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
286
404
  ref={newRef}
287
405
  sessionId={sessionId}
288
406
  revision={rightRev}
407
+ filename={filename}
289
408
  onReady={handleRightReady}
290
409
  />
291
410
  </div>
@@ -300,11 +419,13 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
300
419
  interface ComparePanelRendererProps {
301
420
  sessionId: string;
302
421
  revision: number;
422
+ filename: string;
303
423
  onReady: () => void;
424
+ onError?: (message: string) => void;
304
425
  }
305
426
 
306
- const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
307
- ({ sessionId, revision, onReady }, ref) => {
427
+ export const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
428
+ ({ sessionId, revision, filename, onReady, onError }, ref) => {
308
429
  const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
309
430
  const [error, setError] = useState<string | null>(null);
310
431
  const [loading, setLoading] = useState(true);
@@ -312,16 +433,25 @@ const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, Compare
312
433
  useEffect(() => {
313
434
  setLoading(true);
314
435
  setError(null);
315
- import(`/api/session/${sessionId}/plan.js?rev=${revision}&t=${Date.now()}`)
436
+ const jsName = filename.replace(/\.jsx$/, ".js");
437
+ import(`/api/session/${sessionId}/canvas/${encodeURIComponent(jsName)}?rev=${revision}&t=${Date.now()}`)
316
438
  .then((mod) => {
439
+ if (!mod.default) {
440
+ const msg = "Module has no default export";
441
+ setError(msg);
442
+ setLoading(false);
443
+ onError?.(msg);
444
+ return;
445
+ }
317
446
  setPlanComponent(() => mod.default);
318
447
  setLoading(false);
319
448
  })
320
449
  .catch((e) => {
321
450
  setError(e.message);
322
451
  setLoading(false);
452
+ onError?.(e.message);
323
453
  });
324
- }, [sessionId, revision]);
454
+ }, [sessionId, revision, filename]);
325
455
 
326
456
  useEffect(() => {
327
457
  if (!PlanComponent) return;
@@ -0,0 +1,326 @@
1
+ import React, { useState, useContext, useMemo, useRef, useEffect, useCallback } from "react";
2
+ import { SessionContext } from "#canvas/runtime";
3
+ import { PlanRenderer } from "./PlanRenderer";
4
+ import { ComparePanelRenderer } from "./CompareView";
5
+ import { ActiveViewContext, RevisionContext, type CanvasFileInfo, type RevisionInfo } from "./App";
6
+ import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
7
+ import { runDomDiff } from "./domDiff";
8
+
9
+ interface OverviewViewProps {
10
+ revision: number;
11
+ responseBanner?: React.ReactNode;
12
+ }
13
+
14
+ interface FileChange {
15
+ filename: string;
16
+ status: "new" | "changed" | "unchanged";
17
+ diffStats?: { added: number; removed: number };
18
+ }
19
+
20
+ export function categorizeChanges(
21
+ currentRev: RevisionInfo | undefined,
22
+ previousRev: RevisionInfo | undefined,
23
+ ): FileChange[] {
24
+ if (!currentRev) return [];
25
+
26
+ return currentRev.canvasFiles.map(cf => {
27
+ if (!cf.diffStats) {
28
+ return { filename: cf.filename, status: "new" as const };
29
+ }
30
+ if (cf.diffStats.added === 0 && cf.diffStats.removed === 0) {
31
+ return { filename: cf.filename, status: "unchanged" as const, diffStats: cf.diffStats };
32
+ }
33
+ return { filename: cf.filename, status: "changed" as const, diffStats: cf.diffStats };
34
+ });
35
+ }
36
+
37
+ export function getAffectedFiles(changes: FileChange[]): string[] {
38
+ return changes.filter(c => c.status !== "unchanged").map(c => c.filename);
39
+ }
40
+
41
+ export function OverviewView({ revision, responseBanner }: OverviewViewProps) {
42
+ const { setActiveView } = useContext(ActiveViewContext);
43
+ const { revisions } = useContext(RevisionContext);
44
+
45
+ const currentRev = revisions.find(r => r.revision === revision);
46
+ const previousRev = revisions.find(r => r.revision === revision - 1);
47
+ const changes = useMemo(() => categorizeChanges(currentRev, previousRev), [currentRev, previousRev]);
48
+
49
+ const affected = changes.filter(c => c.status !== "unchanged");
50
+ const unchanged = changes.filter(c => c.status === "unchanged");
51
+
52
+ return (
53
+ <div className="max-w-[720px] mx-auto px-6 pt-12 pb-32">
54
+ {responseBanner}
55
+
56
+ {affected.map(change => (
57
+ <OverviewFileSection
58
+ key={change.filename}
59
+ change={change}
60
+ revision={revision}
61
+ previousRevision={previousRev?.revision}
62
+ onOpenTab={() => setActiveView({ type: "canvas", filename: change.filename })}
63
+ />
64
+ ))}
65
+
66
+ {unchanged.length > 0 && (
67
+ <div className="mt-8 border-t border-border-subtle pt-4">
68
+ <div className="text-[11px] font-body font-medium uppercase tracking-widest text-text-tertiary mb-3">
69
+ Unchanged
70
+ </div>
71
+ {unchanged.map(change => (
72
+ <UnchangedFileRow
73
+ key={change.filename}
74
+ change={change}
75
+ revision={revision}
76
+ onOpenTab={() => setActiveView({ type: "canvas", filename: change.filename })}
77
+ />
78
+ ))}
79
+ </div>
80
+ )}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ type ViewMode = "rendered" | "unified" | "side-by-side";
86
+
87
+ function ViewModeToggle({ mode, onChange, showDiff }: { mode: ViewMode; onChange: (m: ViewMode) => void; showDiff: boolean }) {
88
+ if (!showDiff) return null;
89
+ return (
90
+ <div className="flex items-center bg-bg-elevated rounded-md p-0.5">
91
+ {(["rendered", "unified", "side-by-side"] as ViewMode[]).map(m => (
92
+ <button
93
+ key={m}
94
+ onClick={() => onChange(m)}
95
+ className={`text-[10px] font-body px-2 py-0.5 rounded transition-colors ${
96
+ mode === m
97
+ ? "bg-bg-surface text-text-primary font-medium shadow-sm"
98
+ : "text-text-tertiary hover:text-text-secondary"
99
+ }`}
100
+ >
101
+ {m === "rendered" ? "Full" : m === "unified" ? "Unified" : "Split"}
102
+ </button>
103
+ ))}
104
+ </div>
105
+ );
106
+ }
107
+
108
+ function OverviewFileSection({
109
+ change,
110
+ revision,
111
+ previousRevision,
112
+ onOpenTab,
113
+ }: {
114
+ change: FileChange;
115
+ revision: number;
116
+ previousRevision?: number;
117
+ onOpenTab: () => void;
118
+ }) {
119
+ const label = change.filename.replace(/\.jsx$/, "");
120
+ const isNew = change.status === "new";
121
+ const diffStats = change.diffStats;
122
+ const canDiff = change.status === "changed" && previousRevision != null;
123
+ const [viewMode, setViewMode] = useState<ViewMode>("rendered");
124
+
125
+ return (
126
+ <div className="mb-8">
127
+ {/* File header */}
128
+ <div className="flex items-center gap-2 mb-3 pb-2 border-b border-border-subtle">
129
+ <span className={`text-[11px] font-mono px-1.5 py-0.5 rounded ${
130
+ isNew
131
+ ? "bg-accent-green/10 text-accent-green border border-accent-green/20"
132
+ : "bg-accent-amber/10 text-accent-amber border border-accent-amber/20"
133
+ }`}>
134
+ {isNew ? "new" : "modified"}
135
+ </span>
136
+ <span className="text-[14px] font-body font-medium text-text-primary">{label}</span>
137
+ {diffStats && (diffStats.added > 0 || diffStats.removed > 0) && (
138
+ <span className="text-[11px] font-mono text-text-tertiary">
139
+ <span className="text-accent-green">+{diffStats.added}</span>
140
+ {" "}
141
+ <span className="text-accent-red">-{diffStats.removed}</span>
142
+ </span>
143
+ )}
144
+ <ViewModeToggle mode={viewMode} onChange={setViewMode} showDiff={canDiff} />
145
+ <button
146
+ onClick={onOpenTab}
147
+ className={`${canDiff ? "" : "ml-auto"} text-[11px] font-body font-medium text-accent-blue hover:text-accent-blue/80 transition-colors`}
148
+ >
149
+ Open in tab
150
+ </button>
151
+ </div>
152
+
153
+ {/* Content based on view mode */}
154
+ {viewMode === "rendered" ? (
155
+ <div data-canvas-file={change.filename}>
156
+ <PlanRenderer revision={revision} filename={change.filename} />
157
+ </div>
158
+ ) : canDiff && previousRevision != null ? (
159
+ <InlineDiff
160
+ key={`${viewMode}-${revision}-${previousRevision}`}
161
+ filename={change.filename}
162
+ leftRev={previousRevision}
163
+ rightRev={revision}
164
+ mode={viewMode}
165
+ />
166
+ ) : null}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ /* ── Inline diff for overview ── */
172
+
173
+ function InlineDiff({
174
+ filename,
175
+ leftRev,
176
+ rightRev,
177
+ mode,
178
+ }: {
179
+ filename: string;
180
+ leftRev: number;
181
+ rightRev: number;
182
+ mode: "unified" | "side-by-side";
183
+ }) {
184
+ const sessionId = useContext(SessionContext);
185
+
186
+ if (mode === "unified") {
187
+ return <InlineUnifiedDiff sessionId={sessionId} filename={filename} leftRev={leftRev} rightRev={rightRev} />;
188
+ }
189
+ return <InlineSideBySideDiff sessionId={sessionId} filename={filename} leftRev={leftRev} rightRev={rightRev} />;
190
+ }
191
+
192
+ function InlineUnifiedDiff({ sessionId, filename, leftRev, rightRev }: { sessionId: string; filename: string; leftRev: number; rightRev: number }) {
193
+ const oldRef = useRef<HTMLDivElement>(null);
194
+ const newRef = useRef<HTMLDivElement>(null);
195
+ const unifiedRef = useRef<HTMLDivElement>(null);
196
+ const [leftReady, setLeftReady] = useState(false);
197
+ const [rightReady, setRightReady] = useState(false);
198
+ const [done, setDone] = useState(false);
199
+ const [error, setError] = useState<string | null>(null);
200
+
201
+ useEffect(() => {
202
+ if (!leftReady || !rightReady || done) return;
203
+ if (!oldRef.current || !newRef.current) return;
204
+
205
+ requestAnimationFrame(() => {
206
+ requestAnimationFrame(() => {
207
+ if (!oldRef.current || !newRef.current || !unifiedRef.current) return;
208
+ try {
209
+ const oldTree = extractBlockTree(oldRef.current);
210
+ const newTree = extractBlockTree(newRef.current);
211
+ const matches = matchBlocks(oldTree, newTree);
212
+ const { fragment } = buildUnifiedDom(matches);
213
+ unifiedRef.current.innerHTML = "";
214
+ unifiedRef.current.appendChild(fragment);
215
+ setDone(true);
216
+ } catch (e) {
217
+ setError(e instanceof Error ? e.message : String(e));
218
+ }
219
+ });
220
+ });
221
+ }, [leftReady, rightReady, done]);
222
+
223
+ return (
224
+ <div>
225
+ {/* Offscreen panels for diff computation */}
226
+ {!done && !error && (
227
+ <div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
228
+ <ComparePanelRenderer ref={oldRef} sessionId={sessionId} revision={leftRev} filename={filename} onReady={() => setLeftReady(true)} onError={(msg) => setError(msg)} />
229
+ <ComparePanelRenderer ref={newRef} sessionId={sessionId} revision={rightRev} filename={filename} onReady={() => setRightReady(true)} onError={(msg) => setError(msg)} />
230
+ </div>
231
+ )}
232
+ {!done && !error && (
233
+ <div className="flex items-center justify-center h-24 text-text-tertiary font-body text-[13px]">Computing diff...</div>
234
+ )}
235
+ {error && (
236
+ <div className="text-accent-red text-[13px] font-body p-3 bg-accent-red-muted rounded-lg">{error}</div>
237
+ )}
238
+ <div ref={unifiedRef} />
239
+ </div>
240
+ );
241
+ }
242
+
243
+ function InlineSideBySideDiff({ sessionId, filename, leftRev, rightRev }: { sessionId: string; filename: string; leftRev: number; rightRev: number }) {
244
+ const oldRef = useRef<HTMLDivElement>(null);
245
+ const newRef = useRef<HTMLDivElement>(null);
246
+ const [leftReady, setLeftReady] = useState(false);
247
+ const [rightReady, setRightReady] = useState(false);
248
+ const [done, setDone] = useState(false);
249
+
250
+ const handleLeftReady = useCallback(() => setLeftReady(true), []);
251
+ const handleRightReady = useCallback(() => setRightReady(true), []);
252
+
253
+ useEffect(() => {
254
+ if (!leftReady || !rightReady || done) return;
255
+ if (!oldRef.current || !newRef.current) return;
256
+
257
+ requestAnimationFrame(() => {
258
+ requestAnimationFrame(() => {
259
+ if (!oldRef.current || !newRef.current) return;
260
+ runDomDiff(oldRef.current, newRef.current);
261
+ setDone(true);
262
+ });
263
+ });
264
+ }, [leftReady, rightReady, done]);
265
+
266
+ return (
267
+ <div className="flex gap-0 border-y border-border-subtle overflow-hidden w-screen relative left-1/2 -translate-x-1/2">
268
+ <div className="w-1/2 border-r border-border-subtle">
269
+ <div className="px-2 py-1 text-[10px] font-body font-medium text-text-tertiary bg-bg-elevated border-b border-border-subtle">
270
+ Rev {leftRev}
271
+ </div>
272
+ <div className="px-6 py-4 max-w-[600px] mx-auto">
273
+ <ComparePanelRenderer ref={oldRef} sessionId={sessionId} revision={leftRev} filename={filename} onReady={handleLeftReady} />
274
+ </div>
275
+ </div>
276
+ <div className="w-1/2">
277
+ <div className="px-2 py-1 text-[10px] font-body font-medium text-text-tertiary bg-bg-elevated border-b border-border-subtle">
278
+ Rev {rightRev}
279
+ </div>
280
+ <div className="px-6 py-4 max-w-[600px] mx-auto">
281
+ <ComparePanelRenderer ref={newRef} sessionId={sessionId} revision={rightRev} filename={filename} onReady={handleRightReady} />
282
+ </div>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ function UnchangedFileRow({
289
+ change,
290
+ revision,
291
+ onOpenTab,
292
+ }: {
293
+ change: FileChange;
294
+ revision: number;
295
+ onOpenTab: () => void;
296
+ }) {
297
+ const [expanded, setExpanded] = useState(false);
298
+ const label = change.filename.replace(/\.jsx$/, "");
299
+
300
+ return (
301
+ <div className="mb-2">
302
+ <div className="flex items-center gap-2 py-1.5">
303
+ <button
304
+ onClick={() => setExpanded(!expanded)}
305
+ className="text-text-tertiary hover:text-text-secondary transition-colors"
306
+ >
307
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" className={`transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}>
308
+ <path d="M3.5 2L6.5 5L3.5 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
309
+ </svg>
310
+ </button>
311
+ <span className="text-[13px] font-body text-text-secondary">{label}</span>
312
+ <button
313
+ onClick={onOpenTab}
314
+ className="ml-auto text-[11px] font-body text-text-tertiary hover:text-accent-blue transition-colors"
315
+ >
316
+ Open in tab
317
+ </button>
318
+ </div>
319
+ {expanded && (
320
+ <div className="ml-5 mt-1 mb-3" data-canvas-file={change.filename}>
321
+ <PlanRenderer revision={revision} filename={change.filename} />
322
+ </div>
323
+ )}
324
+ </div>
325
+ );
326
+ }