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,326 +1,342 @@
1
- import React, { useState, useContext, useMemo, useRef, useEffect, useCallback } from "react";
2
- import { SessionContext } from "#canvas/runtime";
3
- import { PlanRenderer } from "@fabrika/canvas-kernel/client";
4
- import { ComparePanelRenderer } from "./CompareView";
5
- import { ActiveViewContext, RevisionContext, type CanvasFileInfo, type RevisionInfo } from "@fabrika/canvas-kernel/client";
6
- import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
7
- import { runDomDiff } from "./domDiff";
1
+ import { SessionContext } from '#canvas/runtime'
2
+ import { PlanRenderer } from '@fabrika/canvas-kernel/client'
3
+ import { ActiveViewContext, RevisionContext, type RevisionInfo } from '@fabrika/canvas-kernel/client'
4
+ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
5
+ import { ComparePanelRenderer } from './CompareView'
6
+ import { runDomDiff } from './domDiff'
7
+ import { buildUnifiedDom, extractBlockTree, matchBlocks } from './unifiedDiff'
8
8
 
9
9
  interface OverviewViewProps {
10
- revision: number;
11
- responseBanner?: React.ReactNode;
10
+ revision: number
11
+ responseBanner?: React.ReactNode
12
12
  }
13
13
 
14
14
  interface FileChange {
15
- filename: string;
16
- status: "new" | "changed" | "unchanged";
17
- diffStats?: { added: number; removed: number };
15
+ filename: string
16
+ status: 'new' | 'changed' | 'unchanged'
17
+ diffStats?: { added: number; removed: number }
18
18
  }
19
19
 
20
20
  export function categorizeChanges(
21
- currentRev: RevisionInfo | undefined,
22
- previousRev: RevisionInfo | undefined,
21
+ currentRev: RevisionInfo | undefined,
22
+ previousRev: RevisionInfo | undefined,
23
23
  ): FileChange[] {
24
- if (!currentRev) return [];
24
+ if (!currentRev) return []
25
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
- });
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
35
  }
36
36
 
37
37
  export function getAffectedFiles(changes: FileChange[]): string[] {
38
- return changes.filter(c => c.status !== "unchanged").map(c => c.filename);
38
+ return changes.filter(c => c.status !== 'unchanged').map(c => c.filename)
39
39
  }
40
40
 
41
41
  export function OverviewView({ revision, responseBanner }: OverviewViewProps) {
42
- const { setActiveView } = useContext(ActiveViewContext);
43
- const { revisions } = useContext(RevisionContext);
42
+ const { setActiveView } = useContext(ActiveViewContext)
43
+ const { revisions } = useContext(RevisionContext)
44
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]);
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
48
 
49
- const affected = changes.filter(c => c.status !== "unchanged");
50
- const unchanged = changes.filter(c => c.status === "unchanged");
49
+ const affected = changes.filter(c => c.status !== 'unchanged')
50
+ const unchanged = changes.filter(c => c.status === 'unchanged')
51
51
 
52
- return (
53
- <div className="max-w-[720px] mx-auto px-6 pt-12 pb-32">
54
- {responseBanner}
52
+ return (
53
+ <div className="max-w-[720px] mx-auto px-6 pt-12 pb-32">
54
+ {responseBanner}
55
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
- ))}
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
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
- );
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
83
  }
84
84
 
85
- type ViewMode = "rendered" | "unified" | "side-by-side";
85
+ type ViewMode = 'rendered' | 'unified' | 'side-by-side'
86
86
 
87
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
- );
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
106
  }
107
107
 
108
108
  function OverviewFileSection({
109
- change,
110
- revision,
111
- previousRevision,
112
- onOpenTab,
109
+ change,
110
+ revision,
111
+ previousRevision,
112
+ onOpenTab,
113
113
  }: {
114
- change: FileChange;
115
- revision: number;
116
- previousRevision?: number;
117
- onOpenTab: () => void;
114
+ change: FileChange
115
+ revision: number
116
+ previousRevision?: number
117
+ onOpenTab: () => void
118
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");
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
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>
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
130
+ className={`text-[11px] font-mono px-1.5 py-0.5 rounded ${
131
+ isNew
132
+ ? 'bg-accent-green/10 text-accent-green border border-accent-green/20'
133
+ : 'bg-accent-amber/10 text-accent-amber border border-accent-amber/20'
134
+ }`}
135
+ >
136
+ {isNew ? 'new' : 'modified'}
137
+ </span>
138
+ <span className="text-[14px] font-body font-medium text-text-primary">{label}</span>
139
+ {diffStats && (diffStats.added > 0 || diffStats.removed > 0) && (
140
+ <span className="text-[11px] font-mono text-text-tertiary">
141
+ <span className="text-accent-green">+{diffStats.added}</span> <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
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
- );
153
+ {/* Content based on view mode */}
154
+ {viewMode === 'rendered'
155
+ ? (
156
+ <div data-canvas-file={change.filename}>
157
+ <PlanRenderer revision={revision} filename={change.filename} />
158
+ </div>
159
+ )
160
+ : canDiff && previousRevision != null
161
+ ? (
162
+ <InlineDiff
163
+ key={`${viewMode}-${revision}-${previousRevision}`}
164
+ filename={change.filename}
165
+ leftRev={previousRevision}
166
+ rightRev={revision}
167
+ mode={viewMode}
168
+ />
169
+ )
170
+ : null}
171
+ </div>
172
+ )
169
173
  }
170
174
 
171
175
  /* ── Inline diff for overview ── */
172
176
 
173
177
  function InlineDiff({
174
- filename,
175
- leftRev,
176
- rightRev,
177
- mode,
178
+ filename,
179
+ leftRev,
180
+ rightRev,
181
+ mode,
178
182
  }: {
179
- filename: string;
180
- leftRev: number;
181
- rightRev: number;
182
- mode: "unified" | "side-by-side";
183
+ filename: string
184
+ leftRev: number
185
+ rightRev: number
186
+ mode: 'unified' | 'side-by-side'
183
187
  }) {
184
- const sessionId = useContext(SessionContext);
188
+ const sessionId = useContext(SessionContext)
185
189
 
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
+ if (mode === 'unified') {
191
+ return <InlineUnifiedDiff sessionId={sessionId} filename={filename} leftRev={leftRev} rightRev={rightRev} />
192
+ }
193
+ return <InlineSideBySideDiff sessionId={sessionId} filename={filename} leftRev={leftRev} rightRev={rightRev} />
190
194
  }
191
195
 
192
196
  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);
197
+ const oldRef = useRef<HTMLDivElement>(null)
198
+ const newRef = useRef<HTMLDivElement>(null)
199
+ const unifiedRef = useRef<HTMLDivElement>(null)
200
+ const [leftReady, setLeftReady] = useState(false)
201
+ const [rightReady, setRightReady] = useState(false)
202
+ const [done, setDone] = useState(false)
203
+ const [error, setError] = useState<string | null>(null)
200
204
 
201
- useEffect(() => {
202
- if (!leftReady || !rightReady || done) return;
203
- if (!oldRef.current || !newRef.current) return;
205
+ useEffect(() => {
206
+ if (!leftReady || !rightReady || done) return
207
+ if (!oldRef.current || !newRef.current) return
204
208
 
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]);
209
+ requestAnimationFrame(() => {
210
+ requestAnimationFrame(() => {
211
+ if (!oldRef.current || !newRef.current || !unifiedRef.current) return
212
+ try {
213
+ const oldTree = extractBlockTree(oldRef.current)
214
+ const newTree = extractBlockTree(newRef.current)
215
+ const matches = matchBlocks(oldTree, newTree)
216
+ const { fragment } = buildUnifiedDom(matches)
217
+ unifiedRef.current.innerHTML = ''
218
+ unifiedRef.current.appendChild(fragment)
219
+ setDone(true)
220
+ } catch (e) {
221
+ setError(e instanceof Error ? e.message : String(e))
222
+ }
223
+ })
224
+ })
225
+ }, [leftReady, rightReady, done])
222
226
 
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
- );
227
+ return (
228
+ <div>
229
+ {/* Offscreen panels for diff computation */}
230
+ {!done && !error && (
231
+ <div style={{ position: 'absolute', left: -9999, visibility: 'hidden' as const, pointerEvents: 'none' as const, width: 720 }}>
232
+ <ComparePanelRenderer
233
+ ref={oldRef}
234
+ sessionId={sessionId}
235
+ revision={leftRev}
236
+ filename={filename}
237
+ onReady={() => setLeftReady(true)}
238
+ onError={(msg) => setError(msg)}
239
+ />
240
+ <ComparePanelRenderer
241
+ ref={newRef}
242
+ sessionId={sessionId}
243
+ revision={rightRev}
244
+ filename={filename}
245
+ onReady={() => setRightReady(true)}
246
+ onError={(msg) => setError(msg)}
247
+ />
248
+ </div>
249
+ )}
250
+ {!done && !error && <div className="flex items-center justify-center h-24 text-text-tertiary font-body text-[13px]">Computing diff...</div>}
251
+ {error && <div className="text-accent-red text-[13px] font-body p-3 bg-accent-red-muted rounded-lg">{error}</div>}
252
+ <div ref={unifiedRef} />
253
+ </div>
254
+ )
241
255
  }
242
256
 
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);
257
+ function InlineSideBySideDiff(
258
+ { sessionId, filename, leftRev, rightRev }: { sessionId: string; filename: string; leftRev: number; rightRev: number },
259
+ ) {
260
+ const oldRef = useRef<HTMLDivElement>(null)
261
+ const newRef = useRef<HTMLDivElement>(null)
262
+ const [leftReady, setLeftReady] = useState(false)
263
+ const [rightReady, setRightReady] = useState(false)
264
+ const [done, setDone] = useState(false)
249
265
 
250
- const handleLeftReady = useCallback(() => setLeftReady(true), []);
251
- const handleRightReady = useCallback(() => setRightReady(true), []);
266
+ const handleLeftReady = useCallback(() => setLeftReady(true), [])
267
+ const handleRightReady = useCallback(() => setRightReady(true), [])
252
268
 
253
- useEffect(() => {
254
- if (!leftReady || !rightReady || done) return;
255
- if (!oldRef.current || !newRef.current) return;
269
+ useEffect(() => {
270
+ if (!leftReady || !rightReady || done) return
271
+ if (!oldRef.current || !newRef.current) return
256
272
 
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]);
273
+ requestAnimationFrame(() => {
274
+ requestAnimationFrame(() => {
275
+ if (!oldRef.current || !newRef.current) return
276
+ runDomDiff(oldRef.current, newRef.current)
277
+ setDone(true)
278
+ })
279
+ })
280
+ }, [leftReady, rightReady, done])
265
281
 
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
- );
282
+ return (
283
+ <div className="flex gap-0 border-y border-border-subtle overflow-hidden w-screen relative left-1/2 -translate-x-1/2">
284
+ <div className="w-1/2 border-r border-border-subtle">
285
+ <div className="px-2 py-1 text-[10px] font-body font-medium text-text-tertiary bg-bg-elevated border-b border-border-subtle">
286
+ Rev {leftRev}
287
+ </div>
288
+ <div className="px-6 py-4 max-w-[600px] mx-auto">
289
+ <ComparePanelRenderer ref={oldRef} sessionId={sessionId} revision={leftRev} filename={filename} onReady={handleLeftReady} />
290
+ </div>
291
+ </div>
292
+ <div className="w-1/2">
293
+ <div className="px-2 py-1 text-[10px] font-body font-medium text-text-tertiary bg-bg-elevated border-b border-border-subtle">
294
+ Rev {rightRev}
295
+ </div>
296
+ <div className="px-6 py-4 max-w-[600px] mx-auto">
297
+ <ComparePanelRenderer ref={newRef} sessionId={sessionId} revision={rightRev} filename={filename} onReady={handleRightReady} />
298
+ </div>
299
+ </div>
300
+ </div>
301
+ )
286
302
  }
287
303
 
288
304
  function UnchangedFileRow({
289
- change,
290
- revision,
291
- onOpenTab,
305
+ change,
306
+ revision,
307
+ onOpenTab,
292
308
  }: {
293
- change: FileChange;
294
- revision: number;
295
- onOpenTab: () => void;
309
+ change: FileChange
310
+ revision: number
311
+ onOpenTab: () => void
296
312
  }) {
297
- const [expanded, setExpanded] = useState(false);
298
- const label = change.filename.replace(/\.jsx$/, "");
313
+ const [expanded, setExpanded] = useState(false)
314
+ const label = change.filename.replace(/\.jsx$/, '')
299
315
 
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
- );
316
+ return (
317
+ <div className="mb-2">
318
+ <div className="flex items-center gap-2 py-1.5">
319
+ <button
320
+ onClick={() => setExpanded(!expanded)}
321
+ className="text-text-tertiary hover:text-text-secondary transition-colors"
322
+ >
323
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" className={`transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}>
324
+ <path d="M3.5 2L6.5 5L3.5 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
325
+ </svg>
326
+ </button>
327
+ <span className="text-[13px] font-body text-text-secondary">{label}</span>
328
+ <button
329
+ onClick={onOpenTab}
330
+ className="ml-auto text-[11px] font-body text-text-tertiary hover:text-accent-blue transition-colors"
331
+ >
332
+ Open in tab
333
+ </button>
334
+ </div>
335
+ {expanded && (
336
+ <div className="ml-5 mt-1 mb-3" data-canvas-file={change.filename}>
337
+ <PlanRenderer revision={revision} filename={change.filename} />
338
+ </div>
339
+ )}
340
+ </div>
341
+ )
326
342
  }