agent-canvas 0.2.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -106
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +5 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/files-api.test.ts +0 -78
  96. package/daemon/src/remote-feedback.test.ts +0 -165
  97. package/daemon/src/secrets-api.test.ts +0 -123
  98. package/daemon/src/share-e2e.test.ts +0 -277
  99. package/daemon/src/share.test.ts +0 -289
@@ -1,494 +1,514 @@
1
- import React, { useEffect, useState, useRef, useCallback, useMemo, useContext } from "react";
2
- import { RevisionSelect } from "./RevisionSelect";
3
- import { RevisionContext, type RevisionInfo, type CanvasFileInfo } from "@fabrika/canvas-kernel/client";
4
- import { runDomDiff } from "./domDiff";
5
- import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
6
- import { useAnnotations } from "@fabrika/canvas-kernel/client";
7
- import { useTextAnnotation } from "@fabrika/annotations";
8
- import { extractContext } from "@fabrika/annotations";
1
+ import { useTextAnnotation } from '@fabrika/annotations'
2
+ import { extractContext } from '@fabrika/annotations'
3
+ import { RevisionContext, type RevisionInfo } from '@fabrika/canvas-kernel/client'
4
+ import { useAnnotations } from '@fabrika/canvas-kernel/client'
5
+ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
6
+ import { runDomDiff } from './domDiff'
7
+ import { RevisionSelect } from './RevisionSelect'
8
+ import { buildUnifiedDom, extractBlockTree, matchBlocks } from './unifiedDiff'
9
9
 
10
10
  interface CompareViewProps {
11
- initialLeft: number;
12
- initialRight: number;
13
- sessionId: string;
14
- onExit: () => void;
11
+ initialLeft: number
12
+ initialRight: number
13
+ sessionId: string
14
+ onExit: () => void
15
15
  }
16
16
 
17
- type CompareMode = "unified" | "side-by-side";
17
+ type CompareMode = 'unified' | 'side-by-side'
18
18
 
19
19
  /** Get union of canvas filenames from two revisions */
20
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();
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
27
  }
28
28
 
29
29
  export function CompareView({ initialLeft, initialRight, sessionId, onExit }: CompareViewProps) {
30
- const [leftRev, setLeftRev] = useState(initialLeft);
31
- const [rightRev, setRightRev] = useState(initialRight);
32
- const [mode, setMode] = useState<CompareMode>("unified");
33
- const [hasChanges, setHasChanges] = useState(true);
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]);
56
-
57
- return (
58
- <div className="flex flex-col h-screen">
59
- {/* Header */}
60
- <div className="flex items-center justify-between px-6 py-3 border-b border-border-subtle bg-bg-surface flex-shrink-0">
61
- <div className="flex items-center gap-3">
62
- <span className="text-[13px] font-body font-medium text-text-primary">Compare</span>
63
- <RevisionSelect value={leftRev} onChange={setLeftRev} accent="red" />
64
- <span className="text-text-tertiary text-[13px]">&rarr;</span>
65
- <RevisionSelect value={rightRev} onChange={setRightRev} accent="green" />
66
- {diffDone && !hasChanges && (
67
- <span className="text-[11px] font-body text-text-tertiary bg-bg-elevated px-2 py-0.5 rounded">
68
- No changes
69
- </span>
70
- )}
71
- </div>
72
- <div className="flex items-center gap-4">
73
- {/* Mode toggle */}
74
- <div className="flex items-center bg-bg-elevated rounded-md p-0.5">
75
- <button
76
- onClick={() => setMode("unified")}
77
- className={`text-[11px] font-body px-2.5 py-1 rounded transition-colors ${
78
- mode === "unified"
79
- ? "bg-bg-surface text-text-primary font-medium shadow-sm"
80
- : "text-text-tertiary hover:text-text-secondary"
81
- }`}
82
- >
83
- Unified
84
- </button>
85
- <button
86
- onClick={() => setMode("side-by-side")}
87
- className={`text-[11px] font-body px-2.5 py-1 rounded transition-colors ${
88
- mode === "side-by-side"
89
- ? "bg-bg-surface text-text-primary font-medium shadow-sm"
90
- : "text-text-tertiary hover:text-text-secondary"
91
- }`}
92
- >
93
- Side-by-side
94
- </button>
95
- </div>
96
-
97
- {/* Sync scroll — only in side-by-side mode */}
98
- {mode === "side-by-side" && <SyncScrollCheckbox />}
99
-
100
- <button
101
- onClick={onExit}
102
- className="text-[12px] font-body font-medium text-text-secondary hover:text-text-primary px-3 py-1.5 rounded-md hover:bg-bg-elevated transition-colors"
103
- >
104
- Exit Compare
105
- </button>
106
- </div>
107
- </div>
108
-
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" ? (
153
- <UnifiedView
154
- key={`unified-${leftRev}-${rightRev}-${selectedFile}`}
155
- sessionId={sessionId}
156
- leftRev={leftRev}
157
- rightRev={rightRev}
158
- filename={selectedFile}
159
- onDiffResult={(done, changes) => { setDiffDone(done); setHasChanges(changes); }}
160
- />
161
- ) : (
162
- <SideBySideView
163
- key={`sbs-${leftRev}-${rightRev}-${selectedFile}`}
164
- sessionId={sessionId}
165
- leftRev={leftRev}
166
- rightRev={rightRev}
167
- filename={selectedFile}
168
- onDiffResult={(done, changes) => { setDiffDone(done); setHasChanges(changes); }}
169
- />
170
- )}
171
- </div>
172
- );
30
+ const [leftRev, setLeftRev] = useState(initialLeft)
31
+ const [rightRev, setRightRev] = useState(initialRight)
32
+ const [mode, setMode] = useState<CompareMode>('unified')
33
+ const [hasChanges, setHasChanges] = useState(true)
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])
56
+
57
+ return (
58
+ <div className="flex flex-col h-screen">
59
+ {/* Header */}
60
+ <div className="flex items-center justify-between px-6 py-3 border-b border-border-subtle bg-bg-surface flex-shrink-0">
61
+ <div className="flex items-center gap-3">
62
+ <span className="text-[13px] font-body font-medium text-text-primary">Compare</span>
63
+ <RevisionSelect value={leftRev} onChange={setLeftRev} accent="red" />
64
+ <span className="text-text-tertiary text-[13px]">&rarr;</span>
65
+ <RevisionSelect value={rightRev} onChange={setRightRev} accent="green" />
66
+ {diffDone && !hasChanges && (
67
+ <span className="text-[11px] font-body text-text-tertiary bg-bg-elevated px-2 py-0.5 rounded">
68
+ No changes
69
+ </span>
70
+ )}
71
+ </div>
72
+ <div className="flex items-center gap-4">
73
+ {/* Mode toggle */}
74
+ <div className="flex items-center bg-bg-elevated rounded-md p-0.5">
75
+ <button
76
+ onClick={() => setMode('unified')}
77
+ className={`text-[11px] font-body px-2.5 py-1 rounded transition-colors ${
78
+ mode === 'unified'
79
+ ? 'bg-bg-surface text-text-primary font-medium shadow-sm'
80
+ : 'text-text-tertiary hover:text-text-secondary'
81
+ }`}
82
+ >
83
+ Unified
84
+ </button>
85
+ <button
86
+ onClick={() => setMode('side-by-side')}
87
+ className={`text-[11px] font-body px-2.5 py-1 rounded transition-colors ${
88
+ mode === 'side-by-side'
89
+ ? 'bg-bg-surface text-text-primary font-medium shadow-sm'
90
+ : 'text-text-tertiary hover:text-text-secondary'
91
+ }`}
92
+ >
93
+ Side-by-side
94
+ </button>
95
+ </div>
96
+
97
+ {/* Sync scroll — only in side-by-side mode */}
98
+ {mode === 'side-by-side' && <SyncScrollCheckbox />}
99
+
100
+ <button
101
+ onClick={onExit}
102
+ className="text-[12px] font-body font-medium text-text-secondary hover:text-text-primary px-3 py-1.5 rounded-md hover:bg-bg-elevated transition-colors"
103
+ >
104
+ Exit Compare
105
+ </button>
106
+ </div>
107
+ </div>
108
+
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
+ ? (
143
+ <div className="flex-1 flex items-center justify-center">
144
+ <div className="text-center text-text-tertiary font-body">
145
+ <div className="text-lg mb-2">
146
+ {!leftHasFile ? 'New file' : 'Removed file'}
147
+ </div>
148
+ <div className="text-[13px]">
149
+ {selectedFile.replace(/\.jsx$/, '')} {!leftHasFile ? 'was added' : 'was removed'} in this revision
150
+ </div>
151
+ </div>
152
+ </div>
153
+ )
154
+ : mode === 'unified'
155
+ ? (
156
+ <UnifiedView
157
+ key={`unified-${leftRev}-${rightRev}-${selectedFile}`}
158
+ sessionId={sessionId}
159
+ leftRev={leftRev}
160
+ rightRev={rightRev}
161
+ filename={selectedFile}
162
+ onDiffResult={(done, changes) => {
163
+ setDiffDone(done)
164
+ setHasChanges(changes)
165
+ }}
166
+ />
167
+ )
168
+ : (
169
+ <SideBySideView
170
+ key={`sbs-${leftRev}-${rightRev}-${selectedFile}`}
171
+ sessionId={sessionId}
172
+ leftRev={leftRev}
173
+ rightRev={rightRev}
174
+ filename={selectedFile}
175
+ onDiffResult={(done, changes) => {
176
+ setDiffDone(done)
177
+ setHasChanges(changes)
178
+ }}
179
+ />
180
+ )}
181
+ </div>
182
+ )
173
183
  }
174
184
 
175
185
  /* ── Sync scroll checkbox (lifted out to avoid re-render issues) ── */
176
186
 
177
187
  function SyncScrollCheckbox() {
178
- const [checked, setChecked] = useState(true);
179
- useEffect(() => {
180
- document.documentElement.dataset.syncScroll = checked ? "1" : "0";
181
- return () => { delete document.documentElement.dataset.syncScroll; };
182
- }, [checked]);
183
-
184
- return (
185
- <label className="flex items-center gap-1.5 cursor-pointer select-none">
186
- <input
187
- type="checkbox"
188
- checked={checked}
189
- onChange={(e) => setChecked(e.target.checked)}
190
- className="accent-accent-amber w-3.5 h-3.5"
191
- />
192
- <span className="text-[11px] font-body text-text-secondary">Sync scroll</span>
193
- </label>
194
- );
188
+ const [checked, setChecked] = useState(true)
189
+ useEffect(() => {
190
+ document.documentElement.dataset.syncScroll = checked ? '1' : '0'
191
+ return () => {
192
+ delete document.documentElement.dataset.syncScroll
193
+ }
194
+ }, [checked])
195
+
196
+ return (
197
+ <label className="flex items-center gap-1.5 cursor-pointer select-none">
198
+ <input
199
+ type="checkbox"
200
+ checked={checked}
201
+ onChange={(e) => setChecked(e.target.checked)}
202
+ className="accent-accent-amber w-3.5 h-3.5"
203
+ />
204
+ <span className="text-[11px] font-body text-text-secondary">Sync scroll</span>
205
+ </label>
206
+ )
195
207
  }
196
208
 
197
209
  /* ── Unified view ── */
198
210
 
199
211
  interface ViewProps {
200
- sessionId: string;
201
- leftRev: number;
202
- rightRev: number;
203
- filename: string;
204
- onDiffResult: (done: boolean, hasChanges: boolean) => void;
212
+ sessionId: string
213
+ leftRev: number
214
+ rightRev: number
215
+ filename: string
216
+ onDiffResult: (done: boolean, hasChanges: boolean) => void
205
217
  }
206
218
 
207
219
  function UnifiedView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
208
- const oldRef = useRef<HTMLDivElement>(null);
209
- const newRef = useRef<HTMLDivElement>(null);
210
- const unifiedRef = useRef<HTMLDivElement>(null);
211
- const [leftReady, setLeftReady] = useState(false);
212
- const [rightReady, setRightReady] = useState(false);
213
- const [done, setDone] = useState(false);
214
- const [loadError, setLoadError] = useState<string | null>(null);
215
-
216
- const [slow, setSlow] = useState(false);
217
-
218
- const { annotations } = useAnnotations();
219
- const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename));
220
- const { popovers } = useTextAnnotation({
221
- containerRef: unifiedRef,
222
- restoreKey: done,
223
- restoreAnnotations: canvasAnnotations,
224
- extractContext: (range) => extractContext(range, unifiedRef.current!),
225
- });
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
-
239
- useEffect(() => {
240
- if (!leftReady || !rightReady || done) return;
241
- if (!oldRef.current || !newRef.current) return;
242
-
243
- requestAnimationFrame(() => {
244
- requestAnimationFrame(() => {
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
- }
266
- });
267
- });
268
- }, [leftReady, rightReady, done]);
269
-
270
- return (
271
- <div className="flex-1 min-h-0 overflow-y-auto">
272
- {/* Offscreen source panels */}
273
- {!done && !loadError && (
274
- <div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
275
- <ComparePanelRenderer
276
- ref={oldRef}
277
- sessionId={sessionId}
278
- revision={leftRev}
279
- filename={filename}
280
- onReady={() => setLeftReady(true)}
281
- onError={(msg) => setLoadError(`Rev ${leftRev}: ${msg}`)}
282
- />
283
- <ComparePanelRenderer
284
- ref={newRef}
285
- sessionId={sessionId}
286
- revision={rightRev}
287
- filename={filename}
288
- onReady={() => setRightReady(true)}
289
- onError={(msg) => setLoadError(`Rev ${rightRev}: ${msg}`)}
290
- />
291
- </div>
292
- )}
293
-
294
- {/* Visible output */}
295
- <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
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>
310
- </div>
311
- )}
312
- <div ref={unifiedRef} />
313
- </div>
314
- {popovers}
315
- </div>
316
- );
220
+ const oldRef = useRef<HTMLDivElement>(null)
221
+ const newRef = useRef<HTMLDivElement>(null)
222
+ const unifiedRef = useRef<HTMLDivElement>(null)
223
+ const [leftReady, setLeftReady] = useState(false)
224
+ const [rightReady, setRightReady] = useState(false)
225
+ const [done, setDone] = useState(false)
226
+ const [loadError, setLoadError] = useState<string | null>(null)
227
+
228
+ const [slow, setSlow] = useState(false)
229
+
230
+ const { annotations } = useAnnotations()
231
+ const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename))
232
+ const { popovers } = useTextAnnotation({
233
+ containerRef: unifiedRef,
234
+ restoreKey: done,
235
+ restoreAnnotations: canvasAnnotations,
236
+ extractContext: (range) => extractContext(range, unifiedRef.current!),
237
+ })
238
+
239
+ // Detect stuck loading
240
+ useEffect(() => {
241
+ if (done || loadError) return
242
+ const id = setTimeout(() => {
243
+ console.warn('[unified-diff] still loading after 10s', {
244
+ leftReady,
245
+ rightReady,
246
+ oldRef: !!oldRef.current,
247
+ newRef: !!newRef.current,
248
+ })
249
+ setSlow(true)
250
+ }, 10_000)
251
+ return () => clearTimeout(id)
252
+ }, [done, loadError, leftReady, rightReady])
253
+
254
+ useEffect(() => {
255
+ if (!leftReady || !rightReady || done) return
256
+ if (!oldRef.current || !newRef.current) return
257
+
258
+ requestAnimationFrame(() => {
259
+ requestAnimationFrame(() => {
260
+ if (!oldRef.current || !newRef.current || !unifiedRef.current) {
261
+ console.warn('[unified-diff] refs lost between rAF frames', {
262
+ old: !!oldRef.current,
263
+ new: !!newRef.current,
264
+ unified: !!unifiedRef.current,
265
+ })
266
+ return
267
+ }
268
+
269
+ try {
270
+ const oldTree = extractBlockTree(oldRef.current)
271
+ const newTree = extractBlockTree(newRef.current)
272
+ const matches = matchBlocks(oldTree, newTree)
273
+ const { fragment, hasChanges } = buildUnifiedDom(matches)
274
+
275
+ unifiedRef.current.innerHTML = ''
276
+ unifiedRef.current.appendChild(fragment)
277
+ setDone(true)
278
+ onDiffResult(true, hasChanges)
279
+ } catch (e) {
280
+ console.error('[unified-diff] diff computation failed', e)
281
+ setLoadError(e instanceof Error ? e.message : String(e))
282
+ }
283
+ })
284
+ })
285
+ }, [leftReady, rightReady, done])
286
+
287
+ return (
288
+ <div className="flex-1 min-h-0 overflow-y-auto">
289
+ {/* Offscreen source panels */}
290
+ {!done && !loadError && (
291
+ <div style={{ position: 'absolute', left: -9999, visibility: 'hidden' as const, pointerEvents: 'none' as const, width: 720 }}>
292
+ <ComparePanelRenderer
293
+ ref={oldRef}
294
+ sessionId={sessionId}
295
+ revision={leftRev}
296
+ filename={filename}
297
+ onReady={() =>
298
+ setLeftReady(true)}
299
+ onError={(msg) => setLoadError(`Rev ${leftRev}: ${msg}`)}
300
+ />
301
+ <ComparePanelRenderer
302
+ ref={newRef}
303
+ sessionId={sessionId}
304
+ revision={rightRev}
305
+ filename={filename}
306
+ onReady={() => setRightReady(true)}
307
+ onError={(msg) => setLoadError(`Rev ${rightRev}: ${msg}`)}
308
+ />
309
+ </div>
310
+ )}
311
+
312
+ {/* Visible output */}
313
+ <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
314
+ {!done && !loadError && (
315
+ <div className="flex flex-col items-center justify-center h-64 text-text-tertiary font-body text-body gap-2">
316
+ <span>Loading...</span>
317
+ {slow && (
318
+ <span className="text-[11px]">
319
+ Taking longer than expected — check Network tab for pending requests
320
+ </span>
321
+ )}
322
+ </div>
323
+ )}
324
+ {loadError && (
325
+ <div className="bg-accent-red-muted rounded-lg p-5 mt-8">
326
+ <h3 className="font-body font-semibold text-accent-red mb-2">Failed to load revision</h3>
327
+ <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{loadError}</pre>
328
+ </div>
329
+ )}
330
+ <div ref={unifiedRef} />
331
+ </div>
332
+ {popovers}
333
+ </div>
334
+ )
317
335
  }
318
336
 
319
337
  /* ── Side-by-side view ── */
320
338
 
321
339
  function SideBySideView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
322
- const oldRef = useRef<HTMLDivElement>(null);
323
- const newRef = useRef<HTMLDivElement>(null);
324
- const [leftReady, setLeftReady] = useState(false);
325
- const [rightReady, setRightReady] = useState(false);
326
- const [done, setDone] = useState(false);
327
-
328
- const viewRef = useRef<HTMLDivElement>(null);
329
- const leftPanelRef = useRef<HTMLDivElement>(null);
330
- const rightPanelRef = useRef<HTMLDivElement>(null);
331
- const isSyncing = useRef(false);
332
-
333
- const handleLeftReady = useCallback(() => setLeftReady(true), []);
334
- const handleRightReady = useCallback(() => setRightReady(true), []);
335
-
336
- const { annotations } = useAnnotations();
337
- const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename));
338
- const { popovers } = useTextAnnotation({
339
- containerRef: viewRef,
340
- restoreKey: done,
341
- restoreAnnotations: canvasAnnotations,
342
- extractContext: (range) => extractContext(range, viewRef.current!),
343
- });
344
-
345
- // Run diff
346
- useEffect(() => {
347
- if (!leftReady || !rightReady || done) return;
348
- if (!oldRef.current || !newRef.current) return;
349
-
350
- requestAnimationFrame(() => {
351
- requestAnimationFrame(() => {
352
- if (!oldRef.current || !newRef.current) return;
353
- const changed = runDomDiff(oldRef.current, newRef.current);
354
- setDone(true);
355
- onDiffResult(true, changed);
356
- });
357
- });
358
- }, [leftReady, rightReady, done]);
359
-
360
- // Sync scroll
361
- useEffect(() => {
362
- const left = leftPanelRef.current;
363
- const right = rightPanelRef.current;
364
- if (!left || !right) return;
365
-
366
- const handleSync = (source: HTMLDivElement, target: HTMLDivElement) => {
367
- if (document.documentElement.dataset.syncScroll === "0") return;
368
- if (isSyncing.current) return;
369
- isSyncing.current = true;
370
- const sourceMax = source.scrollHeight - source.clientHeight;
371
- const targetMax = target.scrollHeight - target.clientHeight;
372
- if (sourceMax > 0 && targetMax > 0) {
373
- target.scrollTop = (source.scrollTop / sourceMax) * targetMax;
374
- }
375
- requestAnimationFrame(() => { isSyncing.current = false; });
376
- };
377
-
378
- const onLeft = () => handleSync(left, right);
379
- const onRight = () => handleSync(right, left);
380
- left.addEventListener("scroll", onLeft);
381
- right.addEventListener("scroll", onRight);
382
- return () => {
383
- left.removeEventListener("scroll", onLeft);
384
- right.removeEventListener("scroll", onRight);
385
- };
386
- }, []);
387
-
388
- return (
389
- <div ref={viewRef} className="flex flex-1 min-h-0">
390
- <div ref={leftPanelRef} className="w-1/2 overflow-y-auto border-r border-border-subtle">
391
- <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
392
- <ComparePanelRenderer
393
- ref={oldRef}
394
- sessionId={sessionId}
395
- revision={leftRev}
396
- filename={filename}
397
- onReady={handleLeftReady}
398
- />
399
- </div>
400
- </div>
401
- <div ref={rightPanelRef} className="w-1/2 overflow-y-auto">
402
- <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
403
- <ComparePanelRenderer
404
- ref={newRef}
405
- sessionId={sessionId}
406
- revision={rightRev}
407
- filename={filename}
408
- onReady={handleRightReady}
409
- />
410
- </div>
411
- </div>
412
- {popovers}
413
- </div>
414
- );
340
+ const oldRef = useRef<HTMLDivElement>(null)
341
+ const newRef = useRef<HTMLDivElement>(null)
342
+ const [leftReady, setLeftReady] = useState(false)
343
+ const [rightReady, setRightReady] = useState(false)
344
+ const [done, setDone] = useState(false)
345
+
346
+ const viewRef = useRef<HTMLDivElement>(null)
347
+ const leftPanelRef = useRef<HTMLDivElement>(null)
348
+ const rightPanelRef = useRef<HTMLDivElement>(null)
349
+ const isSyncing = useRef(false)
350
+
351
+ const handleLeftReady = useCallback(() => setLeftReady(true), [])
352
+ const handleRightReady = useCallback(() => setRightReady(true), [])
353
+
354
+ const { annotations } = useAnnotations()
355
+ const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename))
356
+ const { popovers } = useTextAnnotation({
357
+ containerRef: viewRef,
358
+ restoreKey: done,
359
+ restoreAnnotations: canvasAnnotations,
360
+ extractContext: (range) => extractContext(range, viewRef.current!),
361
+ })
362
+
363
+ // Run diff
364
+ useEffect(() => {
365
+ if (!leftReady || !rightReady || done) return
366
+ if (!oldRef.current || !newRef.current) return
367
+
368
+ requestAnimationFrame(() => {
369
+ requestAnimationFrame(() => {
370
+ if (!oldRef.current || !newRef.current) return
371
+ const changed = runDomDiff(oldRef.current, newRef.current)
372
+ setDone(true)
373
+ onDiffResult(true, changed)
374
+ })
375
+ })
376
+ }, [leftReady, rightReady, done])
377
+
378
+ // Sync scroll
379
+ useEffect(() => {
380
+ const left = leftPanelRef.current
381
+ const right = rightPanelRef.current
382
+ if (!left || !right) return
383
+
384
+ const handleSync = (source: HTMLDivElement, target: HTMLDivElement) => {
385
+ if (document.documentElement.dataset.syncScroll === '0') return
386
+ if (isSyncing.current) return
387
+ isSyncing.current = true
388
+ const sourceMax = source.scrollHeight - source.clientHeight
389
+ const targetMax = target.scrollHeight - target.clientHeight
390
+ if (sourceMax > 0 && targetMax > 0) {
391
+ target.scrollTop = (source.scrollTop / sourceMax) * targetMax
392
+ }
393
+ requestAnimationFrame(() => {
394
+ isSyncing.current = false
395
+ })
396
+ }
397
+
398
+ const onLeft = () => handleSync(left, right)
399
+ const onRight = () => handleSync(right, left)
400
+ left.addEventListener('scroll', onLeft)
401
+ right.addEventListener('scroll', onRight)
402
+ return () => {
403
+ left.removeEventListener('scroll', onLeft)
404
+ right.removeEventListener('scroll', onRight)
405
+ }
406
+ }, [])
407
+
408
+ return (
409
+ <div ref={viewRef} className="flex flex-1 min-h-0">
410
+ <div ref={leftPanelRef} className="w-1/2 overflow-y-auto border-r border-border-subtle">
411
+ <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
412
+ <ComparePanelRenderer
413
+ ref={oldRef}
414
+ sessionId={sessionId}
415
+ revision={leftRev}
416
+ filename={filename}
417
+ onReady={handleLeftReady}
418
+ />
419
+ </div>
420
+ </div>
421
+ <div ref={rightPanelRef} className="w-1/2 overflow-y-auto">
422
+ <div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
423
+ <ComparePanelRenderer
424
+ ref={newRef}
425
+ sessionId={sessionId}
426
+ revision={rightRev}
427
+ filename={filename}
428
+ onReady={handleRightReady}
429
+ />
430
+ </div>
431
+ </div>
432
+ {popovers}
433
+ </div>
434
+ )
415
435
  }
416
436
 
417
437
  /* ── Panel renderer ── */
418
438
 
419
439
  interface ComparePanelRendererProps {
420
- sessionId: string;
421
- revision: number;
422
- filename: string;
423
- onReady: () => void;
424
- onError?: (message: string) => void;
440
+ sessionId: string
441
+ revision: number
442
+ filename: string
443
+ onReady: () => void
444
+ onError?: (message: string) => void
425
445
  }
426
446
 
427
447
  export const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
428
- ({ sessionId, revision, filename, onReady, onError }, ref) => {
429
- const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
430
- const [error, setError] = useState<string | null>(null);
431
- const [loading, setLoading] = useState(true);
432
-
433
- useEffect(() => {
434
- setLoading(true);
435
- setError(null);
436
- const jsName = filename.replace(/\.jsx$/, ".js");
437
- import(`/api/session/${sessionId}/canvas/${encodeURIComponent(jsName)}?rev=${revision}&t=${Date.now()}`)
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
- }
446
- setPlanComponent(() => mod.default);
447
- setLoading(false);
448
- })
449
- .catch((e) => {
450
- setError(e.message);
451
- setLoading(false);
452
- onError?.(e.message);
453
- });
454
- }, [sessionId, revision, filename]);
455
-
456
- useEffect(() => {
457
- if (!PlanComponent) return;
458
- requestAnimationFrame(() => {
459
- requestAnimationFrame(() => {
460
- onReady();
461
- });
462
- });
463
- }, [PlanComponent]);
464
-
465
- const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent]);
466
-
467
- if (loading) {
468
- return (
469
- <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">
470
- Loading...
471
- </div>
472
- );
473
- }
474
-
475
- if (error) {
476
- return (
477
- <div className="bg-accent-red-muted rounded-lg p-5">
478
- <h3 className="font-body font-semibold text-accent-red mb-2">Error</h3>
479
- <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{error}</pre>
480
- </div>
481
- );
482
- }
483
-
484
- if (!PlanComponent) {
485
- return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>;
486
- }
487
-
488
- return (
489
- <div ref={ref}>
490
- {planElement}
491
- </div>
492
- );
493
- }
494
- ));
448
+ ({ sessionId, revision, filename, onReady, onError }, ref) => {
449
+ const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null)
450
+ const [error, setError] = useState<string | null>(null)
451
+ const [loading, setLoading] = useState(true)
452
+
453
+ useEffect(() => {
454
+ setLoading(true)
455
+ setError(null)
456
+ const jsName = filename.replace(/\.jsx$/, '.js')
457
+ import(`/api/session/${sessionId}/canvas/${encodeURIComponent(jsName)}?rev=${revision}&t=${Date.now()}`)
458
+ .then((mod) => {
459
+ if (!mod.default) {
460
+ const msg = 'Module has no default export'
461
+ setError(msg)
462
+ setLoading(false)
463
+ onError?.(msg)
464
+ return
465
+ }
466
+ setPlanComponent(() => mod.default)
467
+ setLoading(false)
468
+ })
469
+ .catch((e) => {
470
+ setError(e.message)
471
+ setLoading(false)
472
+ onError?.(e.message)
473
+ })
474
+ }, [sessionId, revision, filename])
475
+
476
+ useEffect(() => {
477
+ if (!PlanComponent) return
478
+ requestAnimationFrame(() => {
479
+ requestAnimationFrame(() => {
480
+ onReady()
481
+ })
482
+ })
483
+ }, [PlanComponent])
484
+
485
+ const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent])
486
+
487
+ if (loading) {
488
+ return (
489
+ <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">
490
+ Loading...
491
+ </div>
492
+ )
493
+ }
494
+
495
+ if (error) {
496
+ return (
497
+ <div className="bg-accent-red-muted rounded-lg p-5">
498
+ <h3 className="font-body font-semibold text-accent-red mb-2">Error</h3>
499
+ <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{error}</pre>
500
+ </div>
501
+ )
502
+ }
503
+
504
+ if (!PlanComponent) {
505
+ return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>
506
+ }
507
+
508
+ return (
509
+ <div ref={ref}>
510
+ {planElement}
511
+ </div>
512
+ )
513
+ },
514
+ ))