agent-canvas 0.1.8 → 0.1.9
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/daemon/client/AnnotationSidebar.tsx +35 -9
- package/daemon/client/App.tsx +123 -3
- package/daemon/client/CompareView.tsx +1 -1
- package/daemon/client/PlanRenderer.tsx +2 -2
- package/daemon/client/annotationContext.ts +1 -1
- package/daemon/client/generateMarkdown.ts +13 -2
- package/daemon/client/utils.ts +3 -0
- package/daemon/dist/client.js +49 -49
- package/package.json +1 -1
- package/skills/canvas/SKILL.md +5 -6
|
@@ -6,7 +6,7 @@ import { RevisionContext, ActiveViewContext, type ActiveView } from "./App";
|
|
|
6
6
|
import { SessionContext } from "#canvas/runtime";
|
|
7
7
|
import { MarkdownPreview } from "./ResponsePreview";
|
|
8
8
|
import { FileIcon } from "./FileIcon";
|
|
9
|
-
import { autoResizeTextarea } from "./utils";
|
|
9
|
+
import { autoResizeTextarea, RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
10
10
|
|
|
11
11
|
interface AnnotationSidebarProps {
|
|
12
12
|
onPreview: () => void;
|
|
@@ -166,8 +166,9 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
166
166
|
setActiveAnnotationId: (id: string | null) => void;
|
|
167
167
|
setActiveView: (view: ActiveView) => void;
|
|
168
168
|
}) {
|
|
169
|
+
const responseAnnotations = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
|
|
169
170
|
const planAnnotations = annotations.filter((a) => !a.filePath);
|
|
170
|
-
const fileAnnotations = annotations.filter((a) => a.filePath);
|
|
171
|
+
const fileAnnotations = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
|
|
171
172
|
const fileGroups: Record<string, Annotation[]> = {};
|
|
172
173
|
for (const ann of fileAnnotations) {
|
|
173
174
|
const key = ann.filePath!;
|
|
@@ -225,13 +226,28 @@ function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId,
|
|
|
225
226
|
</div>
|
|
226
227
|
);
|
|
227
228
|
|
|
229
|
+
const hasMultipleGroups = [responseAnnotations, planAnnotations, fileAnnotations].filter((g) => g.length > 0).length > 1;
|
|
230
|
+
|
|
228
231
|
return (
|
|
229
232
|
<>
|
|
230
233
|
<div className="flex-1 overflow-y-auto">
|
|
231
|
-
{
|
|
232
|
-
|
|
234
|
+
{responseAnnotations.length > 0 && (
|
|
235
|
+
<>
|
|
236
|
+
{hasMultipleGroups && (
|
|
237
|
+
<div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Agent Response</div>
|
|
238
|
+
)}
|
|
239
|
+
{responseAnnotations.map(renderAnnotation)}
|
|
240
|
+
</>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{planAnnotations.length > 0 && (
|
|
244
|
+
<>
|
|
245
|
+
{hasMultipleGroups && (
|
|
246
|
+
<div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Canvas</div>
|
|
247
|
+
)}
|
|
248
|
+
{planAnnotations.map(renderAnnotation)}
|
|
249
|
+
</>
|
|
233
250
|
)}
|
|
234
|
-
{planAnnotations.map(renderAnnotation)}
|
|
235
251
|
|
|
236
252
|
{Object.entries(fileGroups).map(([filePath, anns]) => (
|
|
237
253
|
<div key={filePath}>
|
|
@@ -271,8 +287,9 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
271
287
|
const listRef = useRef<HTMLDivElement>(null);
|
|
272
288
|
const annRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
273
289
|
|
|
290
|
+
const responseAnnotations = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
|
|
274
291
|
const planAnnotations = annotations.filter((a) => !a.filePath);
|
|
275
|
-
const fileAnnotations = annotations.filter((a) => a.filePath);
|
|
292
|
+
const fileAnnotations = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
|
|
276
293
|
const fileGroups: Record<string, Annotation[]> = {};
|
|
277
294
|
for (const ann of fileAnnotations) {
|
|
278
295
|
const key = ann.filePath!;
|
|
@@ -387,12 +404,21 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
387
404
|
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
388
405
|
{annotations.length === 0 && (
|
|
389
406
|
<p className="text-[12px] text-text-tertiary px-3 py-4 leading-relaxed font-body">
|
|
390
|
-
Select text in the
|
|
407
|
+
Select text in the canvas or in files to add annotations.
|
|
391
408
|
</p>
|
|
392
409
|
)}
|
|
393
410
|
|
|
394
|
-
{
|
|
395
|
-
|
|
411
|
+
{responseAnnotations.length > 0 && (
|
|
412
|
+
<>
|
|
413
|
+
{(planAnnotations.length > 0 || fileAnnotations.length > 0) && (
|
|
414
|
+
<div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Agent Response</div>
|
|
415
|
+
)}
|
|
416
|
+
{responseAnnotations.map(renderAnnotation)}
|
|
417
|
+
</>
|
|
418
|
+
)}
|
|
419
|
+
|
|
420
|
+
{planAnnotations.length > 0 && (responseAnnotations.length > 0 || fileAnnotations.length > 0) && (
|
|
421
|
+
<div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Canvas</div>
|
|
396
422
|
)}
|
|
397
423
|
{planAnnotations.map(renderAnnotation)}
|
|
398
424
|
|
package/daemon/client/App.tsx
CHANGED
|
@@ -12,7 +12,10 @@ import { SessionSwitcher } from "./SessionSwitcher";
|
|
|
12
12
|
import { exportCanvasToMarkdown } from "./exportMarkdown";
|
|
13
13
|
import { CompareView } from "./CompareView";
|
|
14
14
|
import { RevisionSelect } from "./RevisionSelect";
|
|
15
|
-
import { generateAnnotationId } from "./utils";
|
|
15
|
+
import { generateAnnotationId, RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
16
|
+
import { wrapRangeWithMark, restoreMarks, renameMarkId, unwrapMarks, updateAllMarkStates } from "./highlightRange";
|
|
17
|
+
import { extractContext } from "./annotationContext";
|
|
18
|
+
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
16
19
|
|
|
17
20
|
export type ActiveView = { type: "plan" } | { type: "file"; path: string };
|
|
18
21
|
|
|
@@ -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, 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,42 @@ 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) => {
|
|
644
|
+
const id = generateAnnotationId();
|
|
645
|
+
renameMarkId(createPopover.tempId, id);
|
|
646
|
+
addAnnotationWithId(id, createPopover.snippet, note, RESPONSE_ANNOTATION_PATH, createPopover.ctx);
|
|
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
|
+
onUpdate={(note) => updateAnnotation(editPopover.annId, note)}
|
|
665
|
+
onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
|
|
666
|
+
onClose={() => setEditPopover(null)}
|
|
667
|
+
/>
|
|
668
|
+
);
|
|
669
|
+
})()}
|
|
550
670
|
</div>
|
|
551
671
|
);
|
|
552
672
|
}
|
|
@@ -574,7 +694,7 @@ function ContentTabs() {
|
|
|
574
694
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" className="opacity-60">
|
|
575
695
|
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
576
696
|
</svg>
|
|
577
|
-
|
|
697
|
+
Canvas
|
|
578
698
|
{activeView.type === "plan" && (
|
|
579
699
|
<span className="absolute bottom-0 left-3 right-3 h-[2px] bg-text-primary rounded-full" />
|
|
580
700
|
)}
|
|
@@ -321,7 +321,7 @@ const ComparePanelRenderer = React.forwardRef<HTMLDivElement, ComparePanelRender
|
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
if (!PlanComponent) {
|
|
324
|
-
return <div className="text-text-tertiary text-center py-8 font-body text-body">No
|
|
324
|
+
return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>;
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
return (
|
|
@@ -333,7 +333,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
|
333
333
|
const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent]);
|
|
334
334
|
|
|
335
335
|
if (loading) {
|
|
336
|
-
return <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">Loading
|
|
336
|
+
return <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">Loading canvas...</div>;
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
if (error) {
|
|
@@ -346,7 +346,7 @@ export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
if (!PlanComponent) {
|
|
349
|
-
return <div className="text-text-tertiary text-center py-8 font-body text-body">No
|
|
349
|
+
return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>;
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
const scrollContainer = document.getElementById("plan-scroll-container");
|
|
@@ -105,7 +105,7 @@ export function formatSnippetInContext(ann: { snippet: string; context?: Annotat
|
|
|
105
105
|
if (ctx && isShort && (ctx.before || ctx.after)) {
|
|
106
106
|
const parts: string[] = [];
|
|
107
107
|
if (ctx.before) parts.push(`...${ctx.before} `);
|
|
108
|
-
parts.push(snippet);
|
|
108
|
+
parts.push(`**${snippet}**`);
|
|
109
109
|
if (ctx.after) parts.push(` ${ctx.after}...`);
|
|
110
110
|
return parts.join("");
|
|
111
111
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Annotation, PlanResponse, FeedbackEntry } from "./AnnotationProvider";
|
|
2
2
|
import { formatSnippetInContext } from "./annotationContext";
|
|
3
|
+
import { RESPONSE_ANNOTATION_PATH } from "./utils";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Generate human-readable markdown feedback from annotations and responses.
|
|
@@ -11,8 +12,9 @@ export function generateMarkdown(
|
|
|
11
12
|
feedbackEntries?: Map<string, FeedbackEntry>,
|
|
12
13
|
): string {
|
|
13
14
|
const parts: string[] = [];
|
|
15
|
+
const responseAnns = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
|
|
14
16
|
const planAnns = annotations.filter((a) => !a.filePath);
|
|
15
|
-
const fileAnns = annotations.filter((a) => a.filePath);
|
|
17
|
+
const fileAnns = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
|
|
16
18
|
const resps = responses ? Array.from(responses.values()).filter((r) => hasValue(r)) : [];
|
|
17
19
|
const fbEntries = feedbackEntries
|
|
18
20
|
? Array.from(feedbackEntries.values()).filter((e) => e.markdown.trim())
|
|
@@ -32,9 +34,18 @@ export function generateMarkdown(
|
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
// Response annotations (on agent response banner)
|
|
38
|
+
if (responseAnns.length > 0) {
|
|
39
|
+
parts.push("## Annotations on agent response");
|
|
40
|
+
parts.push("");
|
|
41
|
+
for (const ann of responseAnns) {
|
|
42
|
+
parts.push(renderAnnotation(ann));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
// Plan annotations grouped by hierarchy
|
|
36
47
|
if (planAnns.length > 0) {
|
|
37
|
-
parts.push("##
|
|
48
|
+
parts.push("## Canvas annotations");
|
|
38
49
|
parts.push("");
|
|
39
50
|
const grouped = groupByHierarchy(planAnns);
|
|
40
51
|
parts.push(renderHierarchyGroup(grouped));
|
package/daemon/client/utils.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Sentinel filePath for annotations on the agent response banner */
|
|
2
|
+
export const RESPONSE_ANNOTATION_PATH = "__agent-response__";
|
|
3
|
+
|
|
1
4
|
/** Generate a unique annotation ID */
|
|
2
5
|
export function generateAnnotationId(): string {
|
|
3
6
|
return `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|