agent-canvas 0.1.8 → 0.1.10
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/client/AnnotationEditor.tsx +307 -0
- package/daemon/client/AnnotationProvider.tsx +15 -3
- package/daemon/client/AnnotationSidebar.tsx +89 -37
- package/daemon/client/App.tsx +144 -13
- package/daemon/client/CompareView.tsx +54 -23
- package/daemon/client/FileBrowser.tsx +2 -1
- package/daemon/client/FileViewer.tsx +41 -161
- package/daemon/client/PlanRenderer.tsx +32 -149
- package/daemon/client/Popover.tsx +168 -108
- package/daemon/client/ResponsePreview.tsx +7 -1
- package/daemon/client/RevisionSelect.tsx +8 -1
- package/daemon/client/annotationContext.ts +1 -1
- package/daemon/client/annotationDom.ts +104 -0
- package/daemon/client/components/Mermaid.tsx +2 -2
- package/daemon/client/generateMarkdown.ts +21 -2
- package/daemon/client/highlightRange.ts +1 -22
- 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/client/utils.ts +3 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +61 -85
- package/daemon/dist/components.js +54 -54
- package/daemon/dist/index.html +1 -1
- 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 +5 -6
- package/hooks/session-start.sh +0 -13
package/daemon/client/App.tsx
CHANGED
|
@@ -7,12 +7,16 @@ 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";
|
|
13
14
|
import { CompareView } from "./CompareView";
|
|
14
15
|
import { RevisionSelect } from "./RevisionSelect";
|
|
15
|
-
import { generateAnnotationId } from "./utils";
|
|
16
|
+
import { generateAnnotationId, RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
17
|
+
import { wrapRangeWithMark, restoreMarks, renameMarkId, unwrapMarks, updateAllMarkStates } from "./highlightRange";
|
|
18
|
+
import { extractContext } from "./annotationContext";
|
|
19
|
+
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
16
20
|
|
|
17
21
|
export type ActiveView = { type: "plan" } | { type: "file"; path: string };
|
|
18
22
|
|
|
@@ -23,6 +27,7 @@ export interface RevisionInfo {
|
|
|
23
27
|
createdAt: string;
|
|
24
28
|
hasFeedback: boolean;
|
|
25
29
|
response?: string;
|
|
30
|
+
diffStats?: { added: number; removed: number };
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export const ActiveViewContext = createContext<{
|
|
@@ -271,16 +276,14 @@ function App() {
|
|
|
271
276
|
{/* Left panel — fixed to viewport */}
|
|
272
277
|
<LeftPanel sessionId={sessionId} projectRoot={projectRoot} connected={connected} onMobileSidebar={() => setMobileSidebar(!mobileSidebar)} collapsed={leftCollapsed} onToggle={() => setLeftCollapsed((c) => !c)} />
|
|
273
278
|
|
|
274
|
-
{/* Right panel — fixed to viewport, resizable
|
|
275
|
-
{
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
</ResizableSidebar>
|
|
279
|
-
)}
|
|
279
|
+
{/* Right panel — fixed to viewport, resizable */}
|
|
280
|
+
<ResizableSidebar collapsed={rightCollapsed} onToggle={() => setRightCollapsed((c) => !c)}>
|
|
281
|
+
<AnnotationSidebar onPreview={() => setPreviewOpen(true)} onSubmit={handleSubmit} />
|
|
282
|
+
</ResizableSidebar>
|
|
280
283
|
|
|
281
284
|
{/* Center content — normal document flow, browser scroll */}
|
|
282
285
|
{compareRevision !== null ? (
|
|
283
|
-
<div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} transition-[margin] duration-200`}>
|
|
286
|
+
<div className={`${leftCollapsed ? "lg:ml-0" : "lg:ml-60"} ${rightCollapsed ? "lg:mr-0" : "lg:mr-[var(--sidebar-width,320px)]"} transition-[margin] duration-200`}>
|
|
284
287
|
<CompareView
|
|
285
288
|
initialLeft={compareRevision.left}
|
|
286
289
|
initialRight={compareRevision.right}
|
|
@@ -524,12 +527,95 @@ function ResizableSidebar({ children, collapsed, onToggle }: { children: React.R
|
|
|
524
527
|
function ResponseBanner({ markdown }: { markdown: string }) {
|
|
525
528
|
const [dismissed, setDismissed] = useState(false);
|
|
526
529
|
const html = useMemo(() => marked.parse(markdown, { async: false }) as string, [markdown]);
|
|
530
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
531
|
+
const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
532
|
+
const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx: any } | null>(null);
|
|
533
|
+
const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
|
|
534
|
+
|
|
535
|
+
const responseAnnotations = useMemo(() => annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH), [annotations]);
|
|
527
536
|
|
|
528
537
|
// Reset dismissed state when markdown changes
|
|
529
538
|
useEffect(() => { setDismissed(false); }, [markdown]);
|
|
530
539
|
|
|
540
|
+
// Restore marks after content renders
|
|
541
|
+
useEffect(() => {
|
|
542
|
+
if (!contentRef.current || responseAnnotations.length === 0) return;
|
|
543
|
+
const timer = setTimeout(() => {
|
|
544
|
+
if (contentRef.current) restoreMarks(contentRef.current, responseAnnotations);
|
|
545
|
+
}, 50);
|
|
546
|
+
return () => clearTimeout(timer);
|
|
547
|
+
}, [html, responseAnnotations.length]);
|
|
548
|
+
|
|
549
|
+
// Update mark active states
|
|
550
|
+
const prevActiveRef = useRef<string | null>(null);
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
|
|
553
|
+
prevActiveRef.current = activeAnnotationId;
|
|
554
|
+
}, [activeAnnotationId]);
|
|
555
|
+
|
|
556
|
+
// Click/hover handlers on marks
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
const container = contentRef.current;
|
|
559
|
+
if (!container) return;
|
|
560
|
+
const handleClick = (e: MouseEvent) => {
|
|
561
|
+
const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
|
|
562
|
+
if (!mark) return;
|
|
563
|
+
e.stopPropagation();
|
|
564
|
+
const annId = mark.getAttribute("data-annotation-id")!;
|
|
565
|
+
if (annId === activeAnnotationId) {
|
|
566
|
+
setEditPopover({ anchorEl: mark, annId });
|
|
567
|
+
} else {
|
|
568
|
+
setActiveAnnotationId(annId);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
const handleMouseOver = (e: MouseEvent) => {
|
|
572
|
+
const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
|
|
573
|
+
if (mark) setActiveAnnotationId(mark.getAttribute("data-annotation-id")!);
|
|
574
|
+
};
|
|
575
|
+
const handleMouseOut = (e: MouseEvent) => {
|
|
576
|
+
const mark = (e.target as HTMLElement).closest("[data-annotation-id]");
|
|
577
|
+
const relatedMark = (e.relatedTarget as HTMLElement | null)?.closest?.("[data-annotation-id]");
|
|
578
|
+
if (mark && !relatedMark) setActiveAnnotationId(null);
|
|
579
|
+
};
|
|
580
|
+
container.addEventListener("click", handleClick);
|
|
581
|
+
container.addEventListener("mouseover", handleMouseOver);
|
|
582
|
+
container.addEventListener("mouseout", handleMouseOut);
|
|
583
|
+
return () => {
|
|
584
|
+
container.removeEventListener("click", handleClick);
|
|
585
|
+
container.removeEventListener("mouseover", handleMouseOver);
|
|
586
|
+
container.removeEventListener("mouseout", handleMouseOut);
|
|
587
|
+
};
|
|
588
|
+
}, [html, activeAnnotationId]);
|
|
589
|
+
|
|
590
|
+
// Text selection → annotation
|
|
591
|
+
useEffect(() => {
|
|
592
|
+
const handler = () => {
|
|
593
|
+
const sel = window.getSelection();
|
|
594
|
+
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
|
|
595
|
+
if (!contentRef.current) return;
|
|
596
|
+
const range = sel.getRangeAt(0);
|
|
597
|
+
if (!contentRef.current.contains(range.startContainer)) return;
|
|
598
|
+
if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
|
|
599
|
+
const snippet = sel.toString().trim();
|
|
600
|
+
if (snippet.length < 2) return;
|
|
601
|
+
const savedRange = range.cloneRange();
|
|
602
|
+
const ctx = extractContext(range, contentRef.current);
|
|
603
|
+
const tempId = `__pending_${Date.now()}`;
|
|
604
|
+
try { wrapRangeWithMark(savedRange, tempId); } catch {}
|
|
605
|
+
window.getSelection()?.removeAllRanges();
|
|
606
|
+
const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`);
|
|
607
|
+
const lastMark = marks[marks.length - 1] as HTMLElement | undefined;
|
|
608
|
+
if (!lastMark) return;
|
|
609
|
+
setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
|
|
610
|
+
};
|
|
611
|
+
document.addEventListener("mouseup", handler);
|
|
612
|
+
return () => document.removeEventListener("mouseup", handler);
|
|
613
|
+
}, []);
|
|
614
|
+
|
|
531
615
|
if (dismissed) return null;
|
|
532
616
|
|
|
617
|
+
const scrollContainer = document.getElementById("plan-scroll-container");
|
|
618
|
+
|
|
533
619
|
return (
|
|
534
620
|
<div className="mb-6 rounded-lg border border-accent-blue/20 bg-accent-blue/5 relative">
|
|
535
621
|
<button
|
|
@@ -545,8 +631,52 @@ function ResponseBanner({ markdown }: { markdown: string }) {
|
|
|
545
631
|
</svg>
|
|
546
632
|
<span className="text-[11px] font-medium uppercase tracking-widest text-accent-blue font-body">Agent Response</span>
|
|
547
633
|
</div>
|
|
548
|
-
<div className="prose-canvas text-sm" dangerouslySetInnerHTML={{ __html: html }} />
|
|
634
|
+
<div ref={contentRef} className="prose-canvas text-sm" dangerouslySetInnerHTML={{ __html: html }} />
|
|
549
635
|
</div>
|
|
636
|
+
|
|
637
|
+
{createPopover && (
|
|
638
|
+
<AnnotationCreatePopover
|
|
639
|
+
anchorEl={createPopover.anchorEl}
|
|
640
|
+
scrollContainer={scrollContainer}
|
|
641
|
+
snippet={createPopover.snippet}
|
|
642
|
+
truncateAt={80}
|
|
643
|
+
onAdd={(note, images) => {
|
|
644
|
+
const id = generateAnnotationId();
|
|
645
|
+
renameMarkId(createPopover.tempId, id);
|
|
646
|
+
addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx, images);
|
|
647
|
+
setCreatePopover(null);
|
|
648
|
+
}}
|
|
649
|
+
onCancel={() => {
|
|
650
|
+
unwrapMarks(createPopover.tempId);
|
|
651
|
+
setCreatePopover(null);
|
|
652
|
+
}}
|
|
653
|
+
/>
|
|
654
|
+
)}
|
|
655
|
+
|
|
656
|
+
{editPopover && (() => {
|
|
657
|
+
const ann = annotations.find((a) => a.id === editPopover.annId);
|
|
658
|
+
if (!ann) return null;
|
|
659
|
+
return (
|
|
660
|
+
<AnnotationEditPopover
|
|
661
|
+
anchorEl={editPopover.anchorEl}
|
|
662
|
+
scrollContainer={scrollContainer}
|
|
663
|
+
initialNote={ann.note}
|
|
664
|
+
initialImages={ann.images}
|
|
665
|
+
onUpdate={(note, images) => {
|
|
666
|
+
updateAnnotation(editPopover.annId, note);
|
|
667
|
+
const current = ann.images || [];
|
|
668
|
+
for (const img of images) {
|
|
669
|
+
if (!current.includes(img)) addAnnotationImage(editPopover.annId, img);
|
|
670
|
+
}
|
|
671
|
+
for (const img of current) {
|
|
672
|
+
if (!images.includes(img)) removeAnnotationImage(editPopover.annId, img);
|
|
673
|
+
}
|
|
674
|
+
}}
|
|
675
|
+
onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
|
|
676
|
+
onClose={() => setEditPopover(null)}
|
|
677
|
+
/>
|
|
678
|
+
);
|
|
679
|
+
})()}
|
|
550
680
|
</div>
|
|
551
681
|
);
|
|
552
682
|
}
|
|
@@ -571,10 +701,10 @@ function ContentTabs() {
|
|
|
571
701
|
: "text-text-tertiary hover:text-text-secondary"
|
|
572
702
|
}`}
|
|
573
703
|
>
|
|
574
|
-
<svg width="
|
|
575
|
-
<path d="M12
|
|
704
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="opacity-60">
|
|
705
|
+
<path d="M12 7L2 12l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
576
706
|
</svg>
|
|
577
|
-
|
|
707
|
+
Canvas
|
|
578
708
|
{activeView.type === "plan" && (
|
|
579
709
|
<span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
|
|
580
710
|
)}
|
|
@@ -594,12 +724,13 @@ function ContentTabs() {
|
|
|
594
724
|
<div className="flex items-center">
|
|
595
725
|
<button
|
|
596
726
|
onClick={() => setActiveView({ type: "file", path })}
|
|
597
|
-
className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-
|
|
727
|
+
className={`flex items-center gap-1.5 pl-3 pr-1 py-2.5 text-[13px] font-body whitespace-nowrap transition-colors relative ${
|
|
598
728
|
isActive
|
|
599
729
|
? "text-text-primary"
|
|
600
730
|
: "text-text-tertiary hover:text-text-secondary"
|
|
601
731
|
}`}
|
|
602
732
|
>
|
|
733
|
+
<FileIcon name={name} type="file" />
|
|
603
734
|
{name}
|
|
604
735
|
{isActive && (
|
|
605
736
|
<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">
|
|
@@ -321,13 +352,13 @@ const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRender
|
|
|
321
352
|
}
|
|
322
353
|
|
|
323
354
|
if (!PlanComponent) {
|
|
324
|
-
return <div className="text-text-tertiary text-center py-8 font-body text-body">No
|
|
355
|
+
return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>;
|
|
325
356
|
}
|
|
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
|
-
|