agent-canvas 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/dark.png +0 -0
- package/.github/light.png +0 -0
- package/README.md +10 -0
- package/daemon/build.ts +1 -1
- package/daemon/client/AnnotationEditor.tsx +307 -0
- package/daemon/client/AnnotationProvider.tsx +15 -3
- package/daemon/client/AnnotationSidebar.tsx +54 -28
- package/daemon/client/App.tsx +64 -24
- package/daemon/client/CompareView.tsx +53 -22
- package/daemon/client/FileBrowser.tsx +2 -1
- package/daemon/client/FileViewer.tsx +41 -161
- package/daemon/client/PlanRenderer.tsx +30 -147
- package/daemon/client/Popover.tsx +168 -108
- package/daemon/client/ResponsePreview.tsx +7 -1
- package/daemon/client/RevisionSelect.tsx +8 -1
- package/daemon/client/annotationDom.ts +104 -0
- package/daemon/client/components/Mermaid.tsx +2 -2
- package/daemon/client/generateMarkdown.ts +8 -0
- package/daemon/client/highlightRange.ts +1 -22
- package/daemon/client/index.html +1 -1
- package/daemon/client/runtime.ts +4 -1
- package/daemon/client/styles.css +28 -2
- package/daemon/client/theme.css +1 -1
- package/daemon/client/useTextAnnotation.tsx +183 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +63 -87
- package/daemon/dist/components.js +54 -54
- package/daemon/dist/index.html +2 -2
- package/daemon/src/handlers/uploads.ts +62 -0
- package/daemon/src/paths.ts +4 -0
- package/daemon/src/server.ts +2 -0
- package/daemon/src/session.ts +39 -1
- package/package.json +1 -1
- package/skills/canvas/SKILL.md +15 -7
- package/skills/canvas/flows.md +1 -1
- package/hooks/session-start.sh +0 -13
package/daemon/client/App.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { PlanRenderer } from "./PlanRenderer";
|
|
|
7
7
|
import { AnnotationSidebar } from "./AnnotationSidebar";
|
|
8
8
|
import { ResponsePreview } from "./ResponsePreview";
|
|
9
9
|
import { FileBrowser } from "./FileBrowser";
|
|
10
|
+
import { FileIcon } from "./FileIcon";
|
|
10
11
|
import { FileViewer } from "./FileViewer";
|
|
11
12
|
import { SessionSwitcher } from "./SessionSwitcher";
|
|
12
13
|
import { exportCanvasToMarkdown } from "./exportMarkdown";
|
|
@@ -26,6 +27,7 @@ export interface RevisionInfo {
|
|
|
26
27
|
createdAt: string;
|
|
27
28
|
hasFeedback: boolean;
|
|
28
29
|
response?: string;
|
|
30
|
+
diffStats?: { added: number; removed: number };
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export const ActiveViewContext = createContext<{
|
|
@@ -58,23 +60,56 @@ export const RevisionContext = createContext<{
|
|
|
58
60
|
setCompareRevision: () => {},
|
|
59
61
|
});
|
|
60
62
|
|
|
63
|
+
function resolveTheme(pref: string): "light" | "dark" {
|
|
64
|
+
if (pref === "auto") return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
65
|
+
return pref as "light" | "dark";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function applyTheme(pref: string) {
|
|
69
|
+
document.documentElement.dataset.theme = resolveTheme(pref);
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
function ThemeSwitcher() {
|
|
62
|
-
const [
|
|
73
|
+
const [pref, setPref] = useState<"auto" | "light" | "dark">(() => {
|
|
74
|
+
const stored = localStorage.getItem("canvas-theme");
|
|
75
|
+
if (stored === "light" || stored === "dark" || stored === "auto") return stored;
|
|
76
|
+
return "auto";
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
applyTheme(pref);
|
|
81
|
+
if (pref !== "auto") return;
|
|
82
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
83
|
+
const handler = () => applyTheme("auto");
|
|
84
|
+
mq.addEventListener("change", handler);
|
|
85
|
+
return () => mq.removeEventListener("change", handler);
|
|
86
|
+
}, [pref]);
|
|
63
87
|
|
|
64
88
|
const toggle = () => {
|
|
65
|
-
const next =
|
|
66
|
-
document.documentElement.dataset.theme = next;
|
|
89
|
+
const next = pref === "auto" ? "light" : pref === "light" ? "dark" : "auto";
|
|
67
90
|
localStorage.setItem("canvas-theme", next);
|
|
68
|
-
|
|
91
|
+
setPref(next);
|
|
69
92
|
};
|
|
70
93
|
|
|
94
|
+
const titles = { auto: "Theme: system (click for light)", light: "Theme: light (click for dark)", dark: "Theme: dark (click for system)" };
|
|
95
|
+
|
|
71
96
|
return (
|
|
72
97
|
<button
|
|
73
98
|
onClick={toggle}
|
|
74
99
|
className="w-7 h-7 flex items-center justify-center rounded-md text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
|
|
75
|
-
title={
|
|
100
|
+
title={titles[pref]}
|
|
76
101
|
>
|
|
77
|
-
{
|
|
102
|
+
{pref === "auto" ? (
|
|
103
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
104
|
+
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
|
105
|
+
<line x1="8" y1="21" x2="16" y2="21" />
|
|
106
|
+
<line x1="12" y1="17" x2="12" y2="21" />
|
|
107
|
+
</svg>
|
|
108
|
+
) : pref === "dark" ? (
|
|
109
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
110
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
111
|
+
</svg>
|
|
112
|
+
) : (
|
|
78
113
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
79
114
|
<circle cx="12" cy="12" r="5" />
|
|
80
115
|
<line x1="12" y1="1" x2="12" y2="3" />
|
|
@@ -86,10 +121,6 @@ function ThemeSwitcher() {
|
|
|
86
121
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
|
87
122
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
88
123
|
</svg>
|
|
89
|
-
) : (
|
|
90
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
91
|
-
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
92
|
-
</svg>
|
|
93
124
|
)}
|
|
94
125
|
</button>
|
|
95
126
|
);
|
|
@@ -274,16 +305,14 @@ function App() {
|
|
|
274
305
|
{/* Left panel — fixed to viewport */}
|
|
275
306
|
<LeftPanel sessionId={sessionId} projectRoot={projectRoot} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
|
|
276
307
|
|
|
277
|
-
{/* Right panel — fixed to viewport, resizable
|
|
278
|
-
{
|
|
279
|
-
<
|
|
280
|
-
|
|
281
|
-
</ResizableSidebar>
|
|
282
|
-
)}
|
|
308
|
+
{/* Right panel — fixed to viewport, resizable */}
|
|
309
|
+
<ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
|
|
310
|
+
<AnnotationSidebar onPreview={() => setPreviewOpen(true)} onSubmit={handleSubmit} />
|
|
311
|
+
</ResizableSidebar>
|
|
283
312
|
|
|
284
313
|
{/* Center content — normal document flow, browser scroll */}
|
|
285
314
|
{compareRevision !== null ? (
|
|
286
|
-
<div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} transition-[margin] duration-200`}>
|
|
315
|
+
<div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} ${rightCollapsed ? "lg:mr-0" : "lg:mr-[var(--sidebar-width,320px)]"} transition-[margin] duration-200`}>
|
|
287
316
|
<CompareView
|
|
288
317
|
initialLeft={compareRevision.left}
|
|
289
318
|
initialRight={compareRevision.right}
|
|
@@ -528,7 +557,7 @@ function ResponseBanner({ markdown }: { markdown: string }) {
|
|
|
528
557
|
const [dismissed, setDismissed] = useState(false);
|
|
529
558
|
const html = useMemo(() => marked.parse(markdown, { async: false }) as string, [markdown]);
|
|
530
559
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
531
|
-
const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
560
|
+
const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
532
561
|
const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx: any } | null>(null);
|
|
533
562
|
const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
|
|
534
563
|
|
|
@@ -640,10 +669,10 @@ function ResponseBanner({ markdown }: { markdown: string }) {
|
|
|
640
669
|
scrollContainer={scrollContainer}
|
|
641
670
|
snippet={createPopover.snippet}
|
|
642
671
|
truncateAt={80}
|
|
643
|
-
onAdd={(note) => {
|
|
672
|
+
onAdd={(note, images) => {
|
|
644
673
|
const id = generateAnnotationId();
|
|
645
674
|
renameMarkId(createPopover.tempId, id);
|
|
646
|
-
addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx);
|
|
675
|
+
addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx, images);
|
|
647
676
|
setCreatePopover(null);
|
|
648
677
|
}}
|
|
649
678
|
onCancel={() => {
|
|
@@ -661,7 +690,17 @@ function ResponseBanner({ markdown }: { markdown: string }) {
|
|
|
661
690
|
anchorEl={editPopover.anchorEl}
|
|
662
691
|
scrollContainer={scrollContainer}
|
|
663
692
|
initialNote={ann.note}
|
|
664
|
-
|
|
693
|
+
initialImages={ann.images}
|
|
694
|
+
onUpdate={(note, images) => {
|
|
695
|
+
updateAnnotation(editPopover.annId, note);
|
|
696
|
+
const current = ann.images || [];
|
|
697
|
+
for (const img of images) {
|
|
698
|
+
if (!current.includes(img)) addAnnotationImage(editPopover.annId, img);
|
|
699
|
+
}
|
|
700
|
+
for (const img of current) {
|
|
701
|
+
if (!images.includes(img)) removeAnnotationImage(editPopover.annId, img);
|
|
702
|
+
}
|
|
703
|
+
}}
|
|
665
704
|
onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
|
|
666
705
|
onClose={() => setEditPopover(null)}
|
|
667
706
|
/>
|
|
@@ -691,8 +730,8 @@ function ContentTabs() {
|
|
|
691
730
|
: "text-text-tertiary hover:text-text-secondary"
|
|
692
731
|
}`}
|
|
693
732
|
>
|
|
694
|
-
<svg width="
|
|
695
|
-
<path d="M12
|
|
733
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="opacity-60">
|
|
734
|
+
<path d="M12 7L2 12l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
696
735
|
</svg>
|
|
697
736
|
Canvas
|
|
698
737
|
{activeView.type === "plan" && (
|
|
@@ -714,12 +753,13 @@ function ContentTabs() {
|
|
|
714
753
|
<div className="flex items-center">
|
|
715
754
|
<button
|
|
716
755
|
onClick={() => setActiveView({ type: "file", path })}
|
|
717
|
-
className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-
|
|
756
|
+
className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
|
|
718
757
|
isActive
|
|
719
758
|
? "text-text-primary"
|
|
720
759
|
: "text-text-tertiary hover:text-text-secondary"
|
|
721
760
|
}`}
|
|
722
761
|
>
|
|
762
|
+
<FileIcon name={name} type="file" />
|
|
723
763
|
{name}
|
|
724
764
|
{isActive && (
|
|
725
765
|
<span className="absolute bottom-0 left-3 right-1 h-[2px] bg-text-primary rounded-full" />
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import React, { useEffect, useState, useRef, useCallback } from "react";
|
|
1
|
+
import React, { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
|
2
2
|
import { RevisionSelect } from "./RevisionSelect";
|
|
3
3
|
import { runDomDiff } from "./domDiff";
|
|
4
4
|
import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
|
|
5
|
+
import { useAnnotations } from "./AnnotationProvider";
|
|
6
|
+
import { useTextAnnotation } from "./useTextAnnotation";
|
|
7
|
+
import { extractContext } from "./annotationContext";
|
|
5
8
|
|
|
6
9
|
interface CompareViewProps {
|
|
7
10
|
initialLeft: number;
|
|
@@ -132,6 +135,15 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
|
|
|
132
135
|
const [rightReady, setRightReady] = useState(false);
|
|
133
136
|
const [done, setDone] = useState(false);
|
|
134
137
|
|
|
138
|
+
const { annotations } = useAnnotations();
|
|
139
|
+
const planAnnotations = annotations.filter(a => !a.filePath);
|
|
140
|
+
const { popovers } = useTextAnnotation({
|
|
141
|
+
containerRef: unifiedRef,
|
|
142
|
+
restoreKey: done,
|
|
143
|
+
restoreAnnotations: planAnnotations,
|
|
144
|
+
extractContext: (range) => extractContext(range, unifiedRef.current!),
|
|
145
|
+
});
|
|
146
|
+
|
|
135
147
|
useEffect(() => {
|
|
136
148
|
if (!leftReady || !rightReady || done) return;
|
|
137
149
|
if (!oldRef.current || !newRef.current) return;
|
|
@@ -155,21 +167,23 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
|
|
|
155
167
|
|
|
156
168
|
return (
|
|
157
169
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
158
|
-
{/* Offscreen source panels */}
|
|
159
|
-
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
{/* Offscreen source panels — unmount after diff is built */}
|
|
171
|
+
{!done && (
|
|
172
|
+
<div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
|
|
173
|
+
<ComparePanelRenderer
|
|
174
|
+
ref={oldRef}
|
|
175
|
+
sessionId={sessionId}
|
|
176
|
+
revision={leftRev}
|
|
177
|
+
onReady={() => setLeftReady(true)}
|
|
178
|
+
/>
|
|
179
|
+
<ComparePanelRenderer
|
|
180
|
+
ref={newRef}
|
|
181
|
+
sessionId={sessionId}
|
|
182
|
+
revision={rightRev}
|
|
183
|
+
onReady={() => setRightReady(true)}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
173
187
|
|
|
174
188
|
{/* Visible output */}
|
|
175
189
|
<div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
|
|
@@ -180,6 +194,7 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
|
|
|
180
194
|
)}
|
|
181
195
|
<div ref={unifiedRef} />
|
|
182
196
|
</div>
|
|
197
|
+
{popovers}
|
|
183
198
|
</div>
|
|
184
199
|
);
|
|
185
200
|
}
|
|
@@ -193,10 +208,23 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
193
208
|
const [rightReady, setRightReady] = useState(false);
|
|
194
209
|
const [done, setDone] = useState(false);
|
|
195
210
|
|
|
211
|
+
const viewRef = useRef<HTMLDivElement>(null);
|
|
196
212
|
const leftPanelRef = useRef<HTMLDivElement>(null);
|
|
197
213
|
const rightPanelRef = useRef<HTMLDivElement>(null);
|
|
198
214
|
const isSyncing = useRef(false);
|
|
199
215
|
|
|
216
|
+
const handleLeftReady = useCallback(() => setLeftReady(true), []);
|
|
217
|
+
const handleRightReady = useCallback(() => setRightReady(true), []);
|
|
218
|
+
|
|
219
|
+
const { annotations } = useAnnotations();
|
|
220
|
+
const planAnnotations = annotations.filter(a => !a.filePath);
|
|
221
|
+
const { popovers } = useTextAnnotation({
|
|
222
|
+
containerRef: viewRef,
|
|
223
|
+
restoreKey: done,
|
|
224
|
+
restoreAnnotations: planAnnotations,
|
|
225
|
+
extractContext: (range) => extractContext(range, viewRef.current!),
|
|
226
|
+
});
|
|
227
|
+
|
|
200
228
|
// Run diff
|
|
201
229
|
useEffect(() => {
|
|
202
230
|
if (!leftReady || !rightReady || done) return;
|
|
@@ -241,14 +269,14 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
241
269
|
}, []);
|
|
242
270
|
|
|
243
271
|
return (
|
|
244
|
-
<div className="flex flex-1 min-h-0">
|
|
272
|
+
<div ref={viewRef} className="flex flex-1 min-h-0">
|
|
245
273
|
<div ref={leftPanelRef} className="w-1/2 overflow-y-auto border-r border-border-subtle">
|
|
246
274
|
<div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
|
|
247
275
|
<ComparePanelRenderer
|
|
248
276
|
ref={oldRef}
|
|
249
277
|
sessionId={sessionId}
|
|
250
278
|
revision={leftRev}
|
|
251
|
-
onReady={
|
|
279
|
+
onReady={handleLeftReady}
|
|
252
280
|
/>
|
|
253
281
|
</div>
|
|
254
282
|
</div>
|
|
@@ -258,10 +286,11 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
258
286
|
ref={newRef}
|
|
259
287
|
sessionId={sessionId}
|
|
260
288
|
revision={rightRev}
|
|
261
|
-
onReady={
|
|
289
|
+
onReady={handleRightReady}
|
|
262
290
|
/>
|
|
263
291
|
</div>
|
|
264
292
|
</div>
|
|
293
|
+
{popovers}
|
|
265
294
|
</div>
|
|
266
295
|
);
|
|
267
296
|
}
|
|
@@ -274,7 +303,7 @@ interface ComparePanelRendererProps {
|
|
|
274
303
|
onReady: () => void;
|
|
275
304
|
}
|
|
276
305
|
|
|
277
|
-
const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
|
|
306
|
+
const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
|
|
278
307
|
({ sessionId, revision, onReady }, ref) => {
|
|
279
308
|
const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
|
|
280
309
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -303,6 +332,8 @@ const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRender
|
|
|
303
332
|
});
|
|
304
333
|
}, [PlanComponent]);
|
|
305
334
|
|
|
335
|
+
const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent]);
|
|
336
|
+
|
|
306
337
|
if (loading) {
|
|
307
338
|
return (
|
|
308
339
|
<div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">
|
|
@@ -326,8 +357,8 @@ const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRender
|
|
|
326
357
|
|
|
327
358
|
return (
|
|
328
359
|
<div ref={ref}>
|
|
329
|
-
|
|
360
|
+
{planElement}
|
|
330
361
|
</div>
|
|
331
362
|
);
|
|
332
363
|
}
|
|
333
|
-
);
|
|
364
|
+
));
|
|
@@ -3,6 +3,7 @@ import { SessionContext } from "#canvas/runtime";
|
|
|
3
3
|
import { useAnnotations } from "./AnnotationProvider";
|
|
4
4
|
import { ActiveViewContext } from "./App";
|
|
5
5
|
import { FileIcon } from "./FileIcon";
|
|
6
|
+
import { RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
6
7
|
|
|
7
8
|
interface TreeEntry { name: string; type: "file" | "dir"; }
|
|
8
9
|
interface DirState { entries: TreeEntry[]; expanded: boolean; loaded: boolean; }
|
|
@@ -17,7 +18,7 @@ export function FileBrowser({ embedded }: { embedded?: boolean } = {}) {
|
|
|
17
18
|
const { annCountMap, annotatedFiles } = useMemo(() => {
|
|
18
19
|
const map = new Map<string, number>();
|
|
19
20
|
for (const a of annotations) {
|
|
20
|
-
if (a.filePath) map.set(a.filePath, (map.get(a.filePath) || 0) + 1);
|
|
21
|
+
if (a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH) map.set(a.filePath, (map.get(a.filePath) || 0) + 1);
|
|
21
22
|
}
|
|
22
23
|
return { annCountMap: map, annotatedFiles: [...map.keys()] };
|
|
23
24
|
}, [annotations]);
|
|
@@ -2,10 +2,10 @@ import React, { useEffect, useState, useContext, useCallback, useRef, useMemo }
|
|
|
2
2
|
import { SessionContext } from "#canvas/runtime";
|
|
3
3
|
import type { AnnotationContext } from "#canvas/runtime";
|
|
4
4
|
import { useAnnotations } from "./AnnotationProvider";
|
|
5
|
-
import { wrapRangeWithMark
|
|
6
|
-
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
5
|
+
import { wrapRangeWithMark } from "./highlightRange";
|
|
7
6
|
import { LANG_MAP } from "../langMap";
|
|
8
7
|
import { generateAnnotationId } from "./utils";
|
|
8
|
+
import { useTextAnnotation } from "./useTextAnnotation";
|
|
9
9
|
|
|
10
10
|
/** Walk up from a node to find the parent line div with data-line-num */
|
|
11
11
|
function findLineDiv(node: Node): HTMLElement | null {
|
|
@@ -17,22 +17,44 @@ function findLineDiv(node: Node): HTMLElement | null {
|
|
|
17
17
|
return null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Build AnnotationContext from a text selection Range inside file content */
|
|
21
|
+
function buildFileContext(range: Range): AnnotationContext {
|
|
22
|
+
const snippet = range.toString().trim();
|
|
23
|
+
const startLineDiv = findLineDiv(range.startContainer);
|
|
24
|
+
const endLineDiv = findLineDiv(range.endContainer);
|
|
25
|
+
const lineStart = startLineDiv ? parseInt(startLineDiv.getAttribute("data-line-num")!, 10) : undefined;
|
|
26
|
+
const lineEnd = endLineDiv ? parseInt(endLineDiv.getAttribute("data-line-num")!, 10) : undefined;
|
|
27
|
+
|
|
28
|
+
let before = "";
|
|
29
|
+
let after = "";
|
|
30
|
+
if (snippet.length < 30 && startLineDiv && startLineDiv === endLineDiv) {
|
|
31
|
+
const code = startLineDiv.querySelector("code");
|
|
32
|
+
if (code) {
|
|
33
|
+
const lineText = code.textContent || "";
|
|
34
|
+
const idx = lineText.indexOf(snippet);
|
|
35
|
+
if (idx >= 0) {
|
|
36
|
+
before = lineText.slice(0, idx).trimStart();
|
|
37
|
+
after = lineText.slice(idx + snippet.length).trimEnd();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { before, after, hierarchy: [], lineStart, lineEnd };
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
interface FileViewerProps {
|
|
21
46
|
path: string;
|
|
22
47
|
}
|
|
23
48
|
|
|
24
49
|
export function FileViewer({ path }: FileViewerProps) {
|
|
25
50
|
const sessionId = useContext(SessionContext);
|
|
26
|
-
const { annotations, addAnnotationWithId,
|
|
51
|
+
const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
27
52
|
const [content, setContent] = useState<string | null>(null);
|
|
28
53
|
const [language, setLanguage] = useState("text");
|
|
29
54
|
const [loading, setLoading] = useState(true);
|
|
30
55
|
const [error, setError] = useState<string | null>(null);
|
|
31
56
|
const contentRef = useRef<HTMLPreElement>(null);
|
|
32
57
|
|
|
33
|
-
const [pendingMarkId, setPendingMarkId] = useState<string | null>(null);
|
|
34
|
-
const pendingSnippetRef = useRef("");
|
|
35
|
-
|
|
36
58
|
// Line gutter selection state
|
|
37
59
|
const [lineSelectStart, setLineSelectStart] = useState<number | null>(null);
|
|
38
60
|
const [lineSelectEnd, setLineSelectEnd] = useState<number | null>(null);
|
|
@@ -40,12 +62,19 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
40
62
|
const lineSelectEndRef = useRef<number | null>(null);
|
|
41
63
|
const linesRef = useRef<string[]>([]);
|
|
42
64
|
|
|
43
|
-
// Popover state
|
|
44
|
-
const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
|
|
45
|
-
const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx?: AnnotationContext } | null>(null);
|
|
46
|
-
|
|
47
65
|
const fileAnns = annotations.filter((a) => a.filePath === path);
|
|
48
66
|
|
|
67
|
+
const scrollContainer = contentRef.current?.closest("#plan-scroll-container") as HTMLElement | null;
|
|
68
|
+
|
|
69
|
+
const { popovers, openCreatePopover } = useTextAnnotation({
|
|
70
|
+
containerRef: contentRef,
|
|
71
|
+
restoreKey: `${content}:${path}`,
|
|
72
|
+
restoreAnnotations: fileAnns,
|
|
73
|
+
extractContext: buildFileContext,
|
|
74
|
+
filePath: path,
|
|
75
|
+
scrollContainer,
|
|
76
|
+
});
|
|
77
|
+
|
|
49
78
|
useEffect(() => {
|
|
50
79
|
setLoading(true);
|
|
51
80
|
setError(null);
|
|
@@ -58,117 +87,6 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
58
87
|
.catch(() => { setError("Failed to fetch file"); setLoading(false); });
|
|
59
88
|
}, [sessionId, path]);
|
|
60
89
|
|
|
61
|
-
// Restore persisted annotation marks after file content renders
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
if (!content || !contentRef.current) return;
|
|
64
|
-
const timer = setTimeout(() => {
|
|
65
|
-
if (contentRef.current) {
|
|
66
|
-
restoreMarks(contentRef.current, fileAnns);
|
|
67
|
-
}
|
|
68
|
-
}, 50);
|
|
69
|
-
return () => clearTimeout(timer);
|
|
70
|
-
}, [content, path]);
|
|
71
|
-
|
|
72
|
-
// Update mark active states
|
|
73
|
-
const prevActiveRef = useRef<string | null>(null);
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
|
|
76
|
-
prevActiveRef.current = activeAnnotationId;
|
|
77
|
-
}, [activeAnnotationId]);
|
|
78
|
-
|
|
79
|
-
// Click handler for marks
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
const container = contentRef.current;
|
|
82
|
-
if (!container) return;
|
|
83
|
-
const handleClick = (e: MouseEvent) => {
|
|
84
|
-
const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
|
|
85
|
-
if (!mark) return;
|
|
86
|
-
e.stopPropagation();
|
|
87
|
-
const annId = mark.getAttribute("data-annotation-id")!;
|
|
88
|
-
if (annId === activeAnnotationId) {
|
|
89
|
-
setEditPopover({ anchorEl: mark, annId });
|
|
90
|
-
} else {
|
|
91
|
-
setActiveAnnotationId(annId);
|
|
92
|
-
}
|
|
93
|
-
};
|
|
94
|
-
const handleMouseOver = (e: MouseEvent) => {
|
|
95
|
-
const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
|
|
96
|
-
if (mark) setActiveAnnotationId(mark.getAttribute("data-annotation-id")!);
|
|
97
|
-
};
|
|
98
|
-
const handleMouseOut = (e: MouseEvent) => {
|
|
99
|
-
const mark = (e.target as HTMLElement).closest("[data-annotation-id]");
|
|
100
|
-
const related = (e.relatedTarget as HTMLElement | null)?.closest?.("[data-annotation-id]");
|
|
101
|
-
if (mark && !related && !document.getElementById("ann-inline-popover")) setActiveAnnotationId(null);
|
|
102
|
-
};
|
|
103
|
-
container.addEventListener("click", handleClick);
|
|
104
|
-
container.addEventListener("mouseover", handleMouseOver);
|
|
105
|
-
container.addEventListener("mouseout", handleMouseOut);
|
|
106
|
-
return () => {
|
|
107
|
-
container.removeEventListener("click", handleClick);
|
|
108
|
-
container.removeEventListener("mouseover", handleMouseOver);
|
|
109
|
-
container.removeEventListener("mouseout", handleMouseOut);
|
|
110
|
-
};
|
|
111
|
-
}, [content, annotations, activeAnnotationId]);
|
|
112
|
-
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
const handler = () => handleMouseUp();
|
|
115
|
-
document.addEventListener("mouseup", handler);
|
|
116
|
-
return () => document.removeEventListener("mouseup", handler);
|
|
117
|
-
}, []);
|
|
118
|
-
|
|
119
|
-
// Use refs so the document-level mouseup always has current values
|
|
120
|
-
const pathRef = useRef(path);
|
|
121
|
-
pathRef.current = path;
|
|
122
|
-
const addAnnotationRef = useRef(addAnnotationWithId);
|
|
123
|
-
addAnnotationRef.current = addAnnotationWithId;
|
|
124
|
-
|
|
125
|
-
const handleMouseUp = useCallback(() => {
|
|
126
|
-
const sel = window.getSelection();
|
|
127
|
-
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
|
|
128
|
-
if (!contentRef.current) return;
|
|
129
|
-
const range = sel.getRangeAt(0);
|
|
130
|
-
if (!contentRef.current.contains(range.startContainer)) return;
|
|
131
|
-
if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
|
|
132
|
-
const snippet = sel.toString().trim();
|
|
133
|
-
if (snippet.length < 2) return;
|
|
134
|
-
|
|
135
|
-
// Compute line numbers from DOM
|
|
136
|
-
const startLineDiv = findLineDiv(range.startContainer);
|
|
137
|
-
const endLineDiv = findLineDiv(range.endContainer);
|
|
138
|
-
const lineStart = startLineDiv ? parseInt(startLineDiv.getAttribute("data-line-num")!, 10) : undefined;
|
|
139
|
-
const lineEnd = endLineDiv ? parseInt(endLineDiv.getAttribute("data-line-num")!, 10) : undefined;
|
|
140
|
-
|
|
141
|
-
// Compute before/after context for short snippets on a single line
|
|
142
|
-
let before = "";
|
|
143
|
-
let after = "";
|
|
144
|
-
if (snippet.length < 30 && startLineDiv && startLineDiv === endLineDiv) {
|
|
145
|
-
const code = startLineDiv.querySelector("code");
|
|
146
|
-
if (code) {
|
|
147
|
-
const lineText = code.textContent || "";
|
|
148
|
-
const idx = lineText.indexOf(snippet);
|
|
149
|
-
if (idx >= 0) {
|
|
150
|
-
before = lineText.slice(0, idx).trimStart();
|
|
151
|
-
after = lineText.slice(idx + snippet.length).trimEnd();
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const ctx: AnnotationContext = { before, after, hierarchy: [], lineStart, lineEnd };
|
|
157
|
-
|
|
158
|
-
const tempId = `__pending_${Date.now()}`;
|
|
159
|
-
const cloned = range.cloneRange();
|
|
160
|
-
try { wrapRangeWithMark(cloned, tempId); } catch {}
|
|
161
|
-
sel.removeAllRanges();
|
|
162
|
-
|
|
163
|
-
const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`);
|
|
164
|
-
const lastMark = marks[marks.length - 1] as HTMLElement | undefined;
|
|
165
|
-
if (!lastMark) return;
|
|
166
|
-
|
|
167
|
-
pendingSnippetRef.current = snippet;
|
|
168
|
-
setPendingMarkId(tempId);
|
|
169
|
-
setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
|
|
170
|
-
}, []);
|
|
171
|
-
|
|
172
90
|
// Line gutter: mousedown starts line selection
|
|
173
91
|
const handleGutterMouseDown = useCallback((e: React.MouseEvent, lineNum: number) => {
|
|
174
92
|
e.preventDefault(); // Prevent text selection
|
|
@@ -227,9 +145,7 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
227
145
|
}
|
|
228
146
|
const anchorEl = lineDivs[endLine - 1] as HTMLElement;
|
|
229
147
|
if (anchorEl) {
|
|
230
|
-
|
|
231
|
-
setPendingMarkId(tempId);
|
|
232
|
-
setCreatePopover({ anchorEl, tempId, snippet, ctx });
|
|
148
|
+
openCreatePopover(anchorEl, tempId, snippet, ctx);
|
|
233
149
|
}
|
|
234
150
|
}
|
|
235
151
|
setLineSelectStart(null);
|
|
@@ -279,8 +195,6 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
279
195
|
setActiveAnnotationId(id);
|
|
280
196
|
};
|
|
281
197
|
|
|
282
|
-
const scrollContainer = contentRef.current?.closest("#plan-scroll-container") as HTMLElement | null;
|
|
283
|
-
|
|
284
198
|
return (
|
|
285
199
|
<div>
|
|
286
200
|
{/* Code */}
|
|
@@ -304,41 +218,7 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
304
218
|
})}
|
|
305
219
|
</pre>
|
|
306
220
|
|
|
307
|
-
{
|
|
308
|
-
const ann = annotations.find((a) => a.id === editPopover.annId);
|
|
309
|
-
if (!ann) return null;
|
|
310
|
-
return (
|
|
311
|
-
<AnnotationEditPopover
|
|
312
|
-
anchorEl={editPopover.anchorEl}
|
|
313
|
-
scrollContainer={scrollContainer}
|
|
314
|
-
initialNote={ann.note}
|
|
315
|
-
onUpdate={(note) => updateAnnotation(editPopover.annId, note)}
|
|
316
|
-
onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
|
|
317
|
-
onClose={() => setEditPopover(null)}
|
|
318
|
-
/>
|
|
319
|
-
);
|
|
320
|
-
})()}
|
|
321
|
-
|
|
322
|
-
{createPopover && (
|
|
323
|
-
<AnnotationCreatePopover
|
|
324
|
-
anchorEl={createPopover.anchorEl}
|
|
325
|
-
scrollContainer={scrollContainer}
|
|
326
|
-
snippet={createPopover.snippet}
|
|
327
|
-
onAdd={(note) => {
|
|
328
|
-
const id = generateAnnotationId();
|
|
329
|
-
renameMarkId(createPopover.tempId, id);
|
|
330
|
-
addAnnotationWithId(id, createPopover.snippet, note, path, createPopover.ctx);
|
|
331
|
-
setPendingMarkId(null);
|
|
332
|
-
setCreatePopover(null);
|
|
333
|
-
}}
|
|
334
|
-
onCancel={() => {
|
|
335
|
-
unwrapMarks(createPopover.tempId);
|
|
336
|
-
setPendingMarkId(null);
|
|
337
|
-
setCreatePopover(null);
|
|
338
|
-
}}
|
|
339
|
-
/>
|
|
340
|
-
)}
|
|
221
|
+
{popovers}
|
|
341
222
|
</div>
|
|
342
223
|
);
|
|
343
224
|
}
|
|
344
|
-
|