agent-canvas 0.1.44 → 0.2.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 (115) hide show
  1. package/README.md +14 -0
  2. package/bin/lib/commands/daemon.ts +3 -6
  3. package/bin/lib/commands/push.ts +1 -1
  4. package/bin/lib/daemon-lifecycle.ts +65 -72
  5. package/bin/lib/helpers.test.ts +1 -0
  6. package/bin/lib/helpers.ts +30 -74
  7. package/daemon/build.ts +37 -16
  8. package/daemon/client/App.tsx +27 -61
  9. package/daemon/client/CompareView.tsx +4 -4
  10. package/daemon/client/FileBrowser.tsx +4 -4
  11. package/daemon/client/FileViewer.tsx +4 -4
  12. package/daemon/client/OverviewView.tsx +2 -2
  13. package/daemon/client/RevisionSelect.tsx +1 -1
  14. package/daemon/client/canvas-runtime.ts +1 -0
  15. package/daemon/client/clientApi.ts +18 -0
  16. package/daemon/client/components/index.ts +3 -16
  17. package/daemon/client/styles.css +4 -457
  18. package/daemon/dist/annotations-runtime.js +1 -0
  19. package/daemon/dist/client.css +1 -1
  20. package/daemon/dist/client.js +76 -76
  21. package/daemon/dist/components.js +59 -59
  22. package/daemon/dist/index.html +1 -0
  23. package/daemon/dist/preact-compat.js +1 -1
  24. package/daemon/dist/runtime.js +1 -1
  25. package/daemon/src/components.ts +13 -0
  26. package/daemon/src/handlers/api.ts +13 -9
  27. package/daemon/src/handlers/files.ts +2 -3
  28. package/daemon/src/handlers/static.ts +1 -1
  29. package/daemon/src/handlers/uploads.ts +2 -3
  30. package/daemon/src/paths.ts +10 -24
  31. package/daemon/src/remote-feedback.test.ts +1 -1
  32. package/daemon/src/remote-feedback.ts +1 -1
  33. package/daemon/src/secrets-api.test.ts +2 -2
  34. package/daemon/src/server.ts +11 -10
  35. package/daemon/src/share-e2e.test.ts +1 -1
  36. package/daemon/src/share.test.ts +1 -1
  37. package/daemon/src/share.ts +1 -1
  38. package/daemon/tsconfig.json +1 -3
  39. package/package.json +11 -4
  40. package/.github/dark.png +0 -0
  41. package/.github/light.png +0 -0
  42. package/.github/workflows/ci.yml +0 -48
  43. package/.github/workflows/deploy-worker-prod.yml +0 -19
  44. package/.github/workflows/deploy-worker-stage.yml +0 -21
  45. package/.github/workflows/deploy-worker.yml +0 -72
  46. package/.github/workflows/release.yml +0 -36
  47. package/CLAUDE.md +0 -86
  48. package/bun.lock +0 -377
  49. package/daemon/client/AnnotationEditor.tsx +0 -315
  50. package/daemon/client/AnnotationProvider.tsx +0 -195
  51. package/daemon/client/AnnotationSidebar.tsx +0 -627
  52. package/daemon/client/FileIcon.tsx +0 -76
  53. package/daemon/client/PlanRenderer.tsx +0 -614
  54. package/daemon/client/Popover.tsx +0 -350
  55. package/daemon/client/RenderErrorContext.ts +0 -11
  56. package/daemon/client/ResponsePreview.tsx +0 -186
  57. package/daemon/client/SessionContext.tsx +0 -3
  58. package/daemon/client/annotationContext.ts +0 -114
  59. package/daemon/client/annotationDom.ts +0 -104
  60. package/daemon/client/annotationDraft.test.ts +0 -188
  61. package/daemon/client/annotationDraft.ts +0 -130
  62. package/daemon/client/compat/preact-all.ts +0 -4
  63. package/daemon/client/components/Callout.tsx +0 -26
  64. package/daemon/client/components/Checklist.tsx +0 -37
  65. package/daemon/client/components/Choice.tsx +0 -176
  66. package/daemon/client/components/CodeBlock.tsx +0 -35
  67. package/daemon/client/components/Diff.tsx +0 -65
  68. package/daemon/client/components/FilePreview.tsx +0 -88
  69. package/daemon/client/components/Image.tsx +0 -45
  70. package/daemon/client/components/Markdown.tsx +0 -44
  71. package/daemon/client/components/Mermaid.tsx +0 -565
  72. package/daemon/client/components/Note.tsx +0 -14
  73. package/daemon/client/components/Priority.tsx +0 -19
  74. package/daemon/client/components/ResponseNote.tsx +0 -19
  75. package/daemon/client/components/Section.tsx +0 -33
  76. package/daemon/client/components/Table.tsx +0 -35
  77. package/daemon/client/components/Task.tsx +0 -76
  78. package/daemon/client/components/UserInput.tsx +0 -174
  79. package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
  80. package/daemon/client/generateMarkdown.ts +0 -273
  81. package/daemon/client/highlightRange.ts +0 -224
  82. package/daemon/client/popoverPosition.ts +0 -31
  83. package/daemon/client/runtime.ts +0 -123
  84. package/daemon/client/theme.css +0 -66
  85. package/daemon/client/useTextAnnotation.tsx +0 -188
  86. package/daemon/client/utils.ts +0 -13
  87. package/daemon/src/compiler.test.ts +0 -57
  88. package/daemon/src/compiler.ts +0 -360
  89. package/daemon/src/handlers/utils.ts +0 -14
  90. package/daemon/src/router.ts +0 -16
  91. package/daemon/src/session-secrets.test.ts +0 -83
  92. package/daemon/src/session.ts +0 -612
  93. package/daemon/src/watcher.ts +0 -71
  94. package/daemon/src/websocket.test.ts +0 -99
  95. package/daemon/src/websocket.ts +0 -211
  96. package/tsconfig.json +0 -14
  97. package/workers/canvas-share/README.md +0 -159
  98. package/workers/canvas-share/oblaka.ts +0 -56
  99. package/workers/canvas-share/package.json +0 -25
  100. package/workers/canvas-share/public/assets/.gitkeep +0 -0
  101. package/workers/canvas-share/public/assets/client.css +0 -2
  102. package/workers/canvas-share/public/assets/client.js +0 -91
  103. package/workers/canvas-share/public/assets/components.js +0 -65
  104. package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
  105. package/workers/canvas-share/public/assets/preact-compat.js +0 -1
  106. package/workers/canvas-share/public/assets/runtime.js +0 -1
  107. package/workers/canvas-share/public/index.html +0 -385
  108. package/workers/canvas-share/public/screenshot-dark.png +0 -0
  109. package/workers/canvas-share/public/screenshot-light.png +0 -0
  110. package/workers/canvas-share/src/index.test.ts +0 -209
  111. package/workers/canvas-share/src/index.ts +0 -518
  112. package/workers/canvas-share/src/types.ts +0 -119
  113. package/workers/canvas-share/src/util.ts +0 -156
  114. package/workers/canvas-share/src/validation.ts +0 -160
  115. package/workers/canvas-share/tsconfig.json +0 -16
@@ -1,76 +0,0 @@
1
- import React from "react";
2
-
3
- interface FileIconProps {
4
- name: string;
5
- type: "file" | "dir";
6
- expanded?: boolean;
7
- className?: string;
8
- }
9
-
10
- const extColors: Record<string, string> = {
11
- // TypeScript / JavaScript
12
- ts: "#3178c6", tsx: "#3178c6", js: "#e8a32e", jsx: "#4ba0c8",
13
- // Web
14
- html: "#e34c26", css: "#264de4", scss: "#c69",
15
- // Data
16
- json: "#7a7568", yaml: "#cb171e", yml: "#cb171e", toml: "#9c4221",
17
- // Systems
18
- rs: "#dea584", go: "#00add8", c: "#6a6a6a", cpp: "#f34b7d", h: "#6a6a6a",
19
- // Scripting
20
- py: "#3572a5", rb: "#cc342d", sh: "#5a8c3c", bash: "#5a8c3c",
21
- // Markup / docs
22
- md: "#3573b5", txt: "#6b6560",
23
- // Config
24
- lock: "#6b6560", gitignore: "#6b6560",
25
- // Images
26
- svg: "#d9952e", png: "#6a9c2e", jpg: "#6a9c2e", gif: "#6a9c2e",
27
- };
28
-
29
- const extLabels: Record<string, string> = {
30
- ts: "TS", tsx: "TX", js: "JS", jsx: "JX",
31
- py: "PY", rs: "RS", go: "GO", rb: "RB",
32
- json: "{}", yaml: "YM", yml: "YM", toml: "TM",
33
- html: "<>", css: "#", scss: "#",
34
- md: "M↓", sh: "$", bash: "$",
35
- c: "C", cpp: "C+", h: "H",
36
- svg: "◇", png: "▣", jpg: "▣",
37
- };
38
-
39
- export function FileIcon({ name, type, expanded, className = "" }: FileIconProps) {
40
- if (type === "dir") {
41
- return (
42
- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" className={`flex-shrink-0 ${className}`}>
43
- {expanded ? (
44
- <path d="M1.5 3.5h5l1.5 1.5h6.5v8h-13z" style={{ fill: "var(--color-folder-fill-active)", stroke: "var(--color-folder-stroke-active)" }} strokeWidth="1" />
45
- ) : (
46
- <path d="M1.5 3.5h5l1.5 1.5h6.5v8h-13z" style={{ fill: "var(--color-folder-fill)", stroke: "var(--color-folder-stroke)" }} strokeWidth="1" />
47
- )}
48
- </svg>
49
- );
50
- }
51
-
52
- const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
53
- const color = extColors[ext] || "#6b6560";
54
- const label = extLabels[ext];
55
-
56
- return (
57
- <svg width="14" height="14" viewBox="0 0 16 16" className={`flex-shrink-0 ${className}`}>
58
- {/* File shape with folded corner */}
59
- <path d="M2 1.5h8l3.5 3.5v9.5a1 1 0 01-1 1H3a1 1 0 01-1-1v-12a1 1 0 011-1z" fill={color} opacity="0.85" />
60
- <path d="M10 1.5v2.5a1 1 0 001 1h2.5" fill="none" stroke="white" strokeWidth="0.5" opacity="0.5" />
61
- {label && (
62
- <text
63
- x="8"
64
- y="11.5"
65
- textAnchor="middle"
66
- fill="white"
67
- fontSize="5.5"
68
- fontFamily="monospace"
69
- fontWeight="600"
70
- >
71
- {label}
72
- </text>
73
- )}
74
- </svg>
75
- );
76
- }
@@ -1,614 +0,0 @@
1
- import React, { useEffect, useState, useRef, useContext, useMemo } from "react";
2
- import { createPortal } from "react-dom";
3
- import { SessionContext, CanvasFileCtx } from "#canvas/runtime";
4
- import { useAnnotations } from "./AnnotationProvider";
5
- import { extractContext } from "./annotationContext";
6
- import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
7
- import { generateAnnotationId } from "./utils";
8
- import { useTextAnnotation } from "./useTextAnnotation";
9
- import { loadCanvasModule } from "./clientApi";
10
- import { RenderErrorContext, type CanvasRenderError } from "./RenderErrorContext";
11
-
12
- /** All navigable blocks (keyboard arrows) */
13
- const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
14
- /** Blocks eligible for annotation comment icons (excludes interactive controls) */
15
- const ANNOTATABLE_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='image']";
16
-
17
- interface PlanRendererProps {
18
- revision: number;
19
- filename: string;
20
- }
21
-
22
- interface CanvasErrorBoundaryProps {
23
- revision: number;
24
- filename: string;
25
- reportError: (error: CanvasRenderError) => void;
26
- children: React.ReactNode;
27
- }
28
-
29
- interface CanvasErrorBoundaryState {
30
- error: Error | null;
31
- }
32
-
33
- class CanvasErrorBoundary extends React.Component<CanvasErrorBoundaryProps, CanvasErrorBoundaryState> {
34
- state: CanvasErrorBoundaryState = { error: null };
35
-
36
- static getDerivedStateFromError(error: Error): CanvasErrorBoundaryState {
37
- return { error };
38
- }
39
-
40
- componentDidCatch(error: Error, info: React.ErrorInfo) {
41
- this.props.reportError({
42
- revision: this.props.revision,
43
- filename: this.props.filename,
44
- message: error.message,
45
- ...(error.stack ? { stack: error.stack } : {}),
46
- ...(info.componentStack ? { componentStack: info.componentStack } : {}),
47
- });
48
- }
49
-
50
- render() {
51
- if (!this.state.error) return this.props.children;
52
- return <CanvasErrorDisplay message={this.state.error.message} />;
53
- }
54
- }
55
-
56
- function CanvasErrorDisplay({ message }: { message: string }) {
57
- return (
58
- <div className="bg-accent-red-muted rounded-lg p-5">
59
- <h3 className="font-body font-semibold text-accent-red mb-2">Canvas render failed</h3>
60
- <pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{message}</pre>
61
- <p className="text-meta font-body text-text-tertiary mt-3">The error was sent back to the canvas author.</p>
62
- </div>
63
- );
64
- }
65
-
66
- export function PlanRenderer({ revision, filename }: PlanRendererProps) {
67
- const sessionId = useContext(SessionContext);
68
- const reportError = useContext(RenderErrorContext);
69
- const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
70
- const [error, setError] = useState<string | null>(null);
71
- const [loading, setLoading] = useState(true);
72
- const containerRef = useRef<HTMLDivElement>(null);
73
- const { annotations: allAnnotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
74
- // Filter annotations to this canvas file
75
- const annotations = useMemo(() => allAnnotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename)), [allAnnotations, filename]);
76
- const [editingAnn, setEditingAnn] = useState<{ id: string; note: string } | null>(null);
77
-
78
- // Block annotation hover state
79
- const [hoveredBlock, setHoveredBlock] = useState<HTMLElement | null>(null);
80
- const [blockPopover, setBlockPopover] = useState<{ anchorEl: HTMLElement; snippet: string; annId?: string } | null>(null);
81
-
82
- // Keyboard navigation state
83
- const [focusedBlockIndex, setFocusedBlockIndex] = useState<number | null>(null);
84
- const [keyboardNav, setKeyboardNav] = useState(false);
85
-
86
- useEffect(() => {
87
- setLoading(true);
88
- setError(null);
89
- setFocusedBlockIndex(null);
90
- void sessionId; // sessionId is implied by the URL in clientApi (shared or local)
91
- loadCanvasModule(filename, revision)
92
- .then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
93
- .catch((cause: unknown) => {
94
- const moduleError = cause instanceof Error ? cause : new Error(String(cause));
95
- setError(moduleError.message);
96
- setLoading(false);
97
- reportError({
98
- revision,
99
- filename,
100
- message: moduleError.message,
101
- ...(moduleError.stack ? { stack: moduleError.stack } : {}),
102
- });
103
- });
104
- }, [sessionId, revision, filename, reportError]);
105
-
106
- const scrollContainer = document.getElementById("plan-scroll-container");
107
-
108
- const { popovers: textAnnotationPopovers, isPopoverOpen: textPopoverOpen } = useTextAnnotation({
109
- containerRef,
110
- restoreKey: PlanComponent,
111
- restoreAnnotations: annotations,
112
- extractContext: (range) => extractContext(range, containerRef.current!),
113
- canvasFile: filename,
114
- scrollContainer,
115
- });
116
-
117
- // Build a map of block snippets to annotation IDs for quick lookup
118
- const blockAnnotationMap = useMemo(() => {
119
- const map = new Map<string, string>();
120
- for (const ann of annotations) {
121
- if (ann.snippet.startsWith("[")) {
122
- map.set(ann.snippet, ann.id);
123
- }
124
- }
125
- return map;
126
- }, [annotations]);
127
-
128
- // Block annotation: hover detection
129
- useEffect(() => {
130
- const container = containerRef.current;
131
- if (!container) return;
132
-
133
- const handleMove = (e: MouseEvent) => {
134
- // Don't change hover when a popover is open
135
- if (blockPopover || textPopoverOpen) return;
136
- const target = e.target as HTMLElement;
137
- // Ignore events on the block comment button itself
138
- if (target.closest("[data-block-comment-btn]")) return;
139
- setKeyboardNav(false);
140
- // Track keyboard nav position on any navigable block
141
- const navBlock = target.closest(BLOCK_SELECTOR) as HTMLElement | null;
142
- if (navBlock && container.contains(navBlock)) {
143
- const blocks = getOrderedBlocks(container);
144
- const idx = blocks.indexOf(navBlock);
145
- setFocusedBlockIndex(idx >= 0 ? idx : null);
146
- } else {
147
- setFocusedBlockIndex(null);
148
- }
149
- // Only show comment icons on annotatable blocks
150
- const block = target.closest(ANNOTATABLE_SELECTOR) as HTMLElement | null;
151
- if (block && container.contains(block)) {
152
- setHoveredBlock(block);
153
- const snippet = getBlockSnippet(block);
154
- const annId = snippet ? blockAnnotationMap.get(snippet) : undefined;
155
- if (annId && annId !== activeAnnotationId) {
156
- setActiveAnnotationId(annId);
157
- }
158
- } else {
159
- setHoveredBlock(null);
160
- }
161
- };
162
-
163
- const handleLeave = (e: MouseEvent) => {
164
- if (blockPopover || textPopoverOpen) return;
165
- // Don't clear if moving onto the block comment button
166
- const related = e.relatedTarget as HTMLElement | null;
167
- if (related?.closest("[data-block-comment-btn]")) return;
168
- setHoveredBlock(null);
169
- setActiveAnnotationId(null);
170
- };
171
-
172
- container.addEventListener("mousemove", handleMove);
173
- container.addEventListener("mouseleave", handleLeave);
174
- return () => {
175
- container.removeEventListener("mousemove", handleMove);
176
- container.removeEventListener("mouseleave", handleLeave);
177
- };
178
- }, [PlanComponent, blockPopover, textPopoverOpen, blockAnnotationMap, activeAnnotationId]);
179
-
180
- // Keyboard navigation: document-level key listener
181
- useEffect(() => {
182
- const handleKeyDown = (e: KeyboardEvent) => {
183
- // Guard: skip if input/textarea focused or popover open
184
- const tag = (e.target as HTMLElement).tagName;
185
- if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
186
- if (blockPopover || textPopoverOpen) return;
187
-
188
- const container = containerRef.current;
189
- if (!container) return;
190
-
191
- if (e.key === "ArrowDown") {
192
- e.preventDefault();
193
- setKeyboardNav(true);
194
- const blocks = getOrderedBlocks(container);
195
- if (blocks.length === 0) return;
196
- setFocusedBlockIndex((prev) => {
197
- if (prev === null) return 0;
198
- return Math.min(prev + 1, blocks.length - 1);
199
- });
200
- } else if (e.key === "ArrowUp") {
201
- e.preventDefault();
202
- setKeyboardNav(true);
203
- const blocks = getOrderedBlocks(container);
204
- if (blocks.length === 0) return;
205
- setFocusedBlockIndex((prev) => {
206
- if (prev === null) return blocks.length - 1;
207
- return Math.max(prev - 1, 0);
208
- });
209
- } else if (e.key === "a" || e.key === "A") {
210
- if (focusedBlockIndex === null || !container) return;
211
- e.preventDefault();
212
- const blocks = getOrderedBlocks(container);
213
- const block = blocks[focusedBlockIndex];
214
- if (!block) return;
215
- const snippet = getBlockSnippet(block);
216
- if (!snippet) return;
217
- const existingAnnId = blockAnnotationMap.get(snippet);
218
- if (existingAnnId) {
219
- setActiveAnnotationId(existingAnnId);
220
- setBlockPopover({ anchorEl: block, snippet, annId: existingAnnId });
221
- } else {
222
- setBlockPopover({ anchorEl: block, snippet });
223
- }
224
- } else if (e.key === " " || e.key === "Enter") {
225
- if (focusedBlockIndex === null) return;
226
- const blocks = getOrderedBlocks(container);
227
- const block = blocks[focusedBlockIndex];
228
- if (!block) return;
229
- const md = block.getAttribute("data-md");
230
- if (md === "choice-option" || md === "multichoice-option") {
231
- e.preventDefault();
232
- block.dispatchEvent(new CustomEvent("kb-select", { bubbles: true }));
233
- } else if (md === "userinput") {
234
- e.preventDefault();
235
- const textarea = block.querySelector("textarea");
236
- if (textarea) {
237
- textarea.focus();
238
- const exitHandler = (te: KeyboardEvent) => {
239
- if (te.key === "Escape") {
240
- te.preventDefault();
241
- textarea.blur();
242
- setKeyboardNav(true);
243
- }
244
- };
245
- textarea.addEventListener("keydown", exitHandler);
246
- textarea.addEventListener("blur", () => textarea.removeEventListener("keydown", exitHandler), { once: true });
247
- }
248
- } else if (md === "rangeinput") {
249
- e.preventDefault();
250
- const input = block.querySelector("input[type='range']") as HTMLInputElement | null;
251
- if (input) {
252
- input.focus();
253
- const exitHandler = (re: KeyboardEvent) => {
254
- if (re.key === "Escape" || re.key === "ArrowUp" || re.key === "ArrowDown") {
255
- re.preventDefault();
256
- input.blur();
257
- setKeyboardNav(true);
258
- }
259
- };
260
- input.addEventListener("keydown", exitHandler);
261
- input.addEventListener("blur", () => input.removeEventListener("keydown", exitHandler), { once: true });
262
- }
263
- }
264
- } else if (e.key === "Escape") {
265
- setFocusedBlockIndex(null);
266
- setKeyboardNav(false);
267
- setActiveAnnotationId(null);
268
- }
269
- };
270
-
271
- document.addEventListener("keydown", handleKeyDown);
272
- return () => document.removeEventListener("keydown", handleKeyDown);
273
- }, [focusedBlockIndex, blockPopover, textPopoverOpen, blockAnnotationMap]);
274
-
275
- // Keyboard navigation: visual focus outline + scroll into view + sidebar sync
276
- useEffect(() => {
277
- const container = containerRef.current;
278
- if (!container || focusedBlockIndex === null || !keyboardNav) return;
279
- const blocks = getOrderedBlocks(container);
280
- const block = blocks[focusedBlockIndex];
281
- if (!block) return;
282
-
283
- block.style.outline = "2px solid var(--color-accent-blue)";
284
- block.style.outlineOffset = "-2px";
285
- block.style.borderRadius = "8px";
286
- block.scrollIntoView({ block: "nearest", behavior: "smooth" });
287
-
288
- // Sync sidebar if block has an annotation
289
- const snippet = getBlockSnippet(block);
290
- const annId = snippet ? blockAnnotationMap.get(snippet) : undefined;
291
- if (annId) setActiveAnnotationId(annId);
292
-
293
- return () => {
294
- block.style.outline = "";
295
- block.style.outlineOffset = "";
296
- block.style.borderRadius = "";
297
- };
298
- }, [focusedBlockIndex, keyboardNav, blockAnnotationMap]);
299
-
300
- // Memoize so context-triggered re-renders of PlanRenderer don't re-render the plan tree.
301
- // This prevents remounting custom components defined inside the Plan function.
302
- const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent]);
303
-
304
- if (loading) {
305
- return <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">Loading canvas...</div>;
306
- }
307
-
308
- if (error) {
309
- return <CanvasErrorDisplay message={error} />;
310
- }
311
-
312
- if (!PlanComponent) {
313
- return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>;
314
- }
315
-
316
- return (
317
- <CanvasFileCtx.Provider value={filename}>
318
- <div ref={containerRef} className="plan-content plan-updated">
319
- <CanvasErrorBoundary
320
- key={`${revision}:${filename}`}
321
- revision={revision}
322
- filename={filename}
323
- reportError={reportError}
324
- >
325
- {planElement}
326
- </CanvasErrorBoundary>
327
- </div>
328
- {editingAnn && (
329
- <EditAnnotationModal
330
- note={editingAnn.note}
331
- onSave={(note) => { updateAnnotation(editingAnn.id, note); setEditingAnn(null); }}
332
- onCancel={() => setEditingAnn(null)}
333
- />
334
- )}
335
-
336
- {textAnnotationPopovers}
337
-
338
- {/* Block annotation floating buttons */}
339
- <BlockCommentButtons
340
- containerRef={containerRef}
341
- hoveredBlock={hoveredBlock}
342
- focusedBlockIndex={focusedBlockIndex}
343
- blockPopover={blockPopover}
344
- blockAnnotationMap={blockAnnotationMap}
345
- activeAnnotationId={activeAnnotationId}
346
- onOpen={(anchorEl, snippet, annId) => {
347
- if (annId) {
348
- setActiveAnnotationId(annId);
349
- setBlockPopover({ anchorEl, snippet, annId });
350
- } else {
351
- setBlockPopover({ anchorEl, snippet });
352
- }
353
- }}
354
- />
355
-
356
- {/* Block annotation popover */}
357
- {blockPopover && !blockPopover.annId && (
358
- <AnnotationCreatePopover
359
- anchorEl={blockPopover.anchorEl}
360
- scrollContainer={scrollContainer}
361
- snippet={blockPopover.snippet}
362
- truncateAt={80}
363
- onAdd={(note, images) => {
364
- const id = generateAnnotationId();
365
- addAnnotationWithId(id, blockPopover.snippet, note, undefined, undefined, images, filename);
366
- setBlockPopover(null);
367
- }}
368
- onCancel={() => setBlockPopover(null)}
369
- />
370
- )}
371
- {blockPopover?.annId && (() => {
372
- const ann = annotations.find((a) => a.id === blockPopover.annId);
373
- if (!ann) return null;
374
- return (
375
- <AnnotationEditPopover
376
- anchorEl={blockPopover.anchorEl}
377
- scrollContainer={scrollContainer}
378
- initialNote={ann.note}
379
- initialImages={ann.images}
380
- onUpdate={(note, images) => {
381
- updateAnnotation(blockPopover.annId!, note);
382
- const current = ann.images || [];
383
- for (const img of images) {
384
- if (!current.includes(img)) addAnnotationImage(blockPopover.annId!, img);
385
- }
386
- for (const img of current) {
387
- if (!images.includes(img)) removeAnnotationImage(blockPopover.annId!, img);
388
- }
389
- }}
390
- onDelete={() => { removeAnnotation(blockPopover.annId!); setActiveAnnotationId(null); setBlockPopover(null); }}
391
- onClose={() => setBlockPopover(null)}
392
- />
393
- );
394
- })()}
395
- </CanvasFileCtx.Provider>
396
- );
397
- }
398
-
399
- /** Get all navigable blocks in DOM order */
400
- function getOrderedBlocks(container: HTMLElement): HTMLElement[] {
401
- return Array.from(container.querySelectorAll(BLOCK_SELECTOR)) as HTMLElement[];
402
- }
403
-
404
- /** Extract a snippet identifier for a block element */
405
- function getBlockSnippet(block: HTMLElement): string | null {
406
- const md = block.getAttribute("data-md");
407
- if (md === "item") {
408
- const label = block.getAttribute("data-md-label");
409
- return label ? `[Item] ${label}` : null;
410
- }
411
- if (md === "section") {
412
- const title = block.getAttribute("data-md-title");
413
- return title ? `[Section] ${title}` : null;
414
- }
415
- if (md === "callout") {
416
- const type = block.getAttribute("data-md-type") || "info";
417
- const text = block.textContent?.trim().slice(0, 60) || "Callout";
418
- return `[Callout:${type}] ${text}`;
419
- }
420
- if (md === "note") {
421
- const text = block.textContent?.trim().slice(0, 60) || "Note";
422
- return `[Note] ${text}`;
423
- }
424
- // Table row
425
- if (block.tagName === "TR") {
426
- const cells = Array.from(block.querySelectorAll("td")).map((td) => td.textContent?.trim()).filter(Boolean);
427
- return cells.length ? `[Row] ${cells.join(" | ")}` : null;
428
- }
429
- if (md === "checklist-item") {
430
- const label = block.getAttribute("data-md-label");
431
- return label ? `[Checklist] ${label}` : null;
432
- }
433
- if (md === "choice-option" || md === "multichoice-option") {
434
- const label = block.getAttribute("data-md-label");
435
- return label ? `[Option] ${label}` : null;
436
- }
437
- if (md === "userinput") {
438
- const label = block.getAttribute("data-md-label");
439
- return label ? `[Input] ${label}` : null;
440
- }
441
- if (md === "rangeinput") {
442
- const label = block.getAttribute("data-md-label");
443
- return label ? `[Range] ${label}` : null;
444
- }
445
- if (md === "image") {
446
- const src = block.getAttribute("data-md-src");
447
- return src ? `[Image] ${src}` : null;
448
- }
449
- return null;
450
- }
451
-
452
- /** Renders comment icons: always visible on annotated blocks, on hover for others */
453
- function BlockCommentButtons({
454
- containerRef,
455
- hoveredBlock,
456
- focusedBlockIndex,
457
- blockPopover,
458
- blockAnnotationMap,
459
- activeAnnotationId,
460
- onOpen,
461
- }: {
462
- containerRef: React.RefObject<HTMLDivElement | null>;
463
- hoveredBlock: HTMLElement | null;
464
- focusedBlockIndex: number | null;
465
- blockPopover: { anchorEl: HTMLElement; snippet: string; annId?: string } | null;
466
- blockAnnotationMap: Map<string, string>;
467
- activeAnnotationId: string | null;
468
- onOpen: (anchorEl: HTMLElement, snippet: string, annId?: string) => void;
469
- }) {
470
- // Find all blocks that have annotations
471
- const [annotatedBlocks, setAnnotatedBlocks] = useState<HTMLElement[]>([]);
472
-
473
- useEffect(() => {
474
- const container = containerRef.current;
475
- if (!container || blockAnnotationMap.size === 0) { setAnnotatedBlocks([]); return; }
476
- const blocks: HTMLElement[] = [];
477
- for (const el of container.querySelectorAll(ANNOTATABLE_SELECTOR)) {
478
- const snippet = getBlockSnippet(el as HTMLElement);
479
- if (snippet && blockAnnotationMap.has(snippet)) {
480
- blocks.push(el as HTMLElement);
481
- }
482
- }
483
- setAnnotatedBlocks(blocks);
484
- }, [blockAnnotationMap, containerRef.current]);
485
-
486
- // Also highlight block when its annotation is active from sidebar hover
487
- const [activeBlock, setActiveBlock] = useState<HTMLElement | null>(null);
488
- useEffect(() => {
489
- if (!activeAnnotationId || !containerRef.current) { setActiveBlock(null); return; }
490
- // Find block matching active annotation
491
- for (const el of containerRef.current.querySelectorAll(BLOCK_SELECTOR)) {
492
- const snippet = getBlockSnippet(el as HTMLElement);
493
- if (snippet && blockAnnotationMap.get(snippet) === activeAnnotationId) {
494
- setActiveBlock(el as HTMLElement);
495
- return;
496
- }
497
- }
498
- setActiveBlock(null);
499
- }, [activeAnnotationId, blockAnnotationMap]);
500
-
501
- // Apply highlight style to active block
502
- useEffect(() => {
503
- if (!activeBlock) return;
504
- activeBlock.style.boxShadow = "inset 0 0 0 1.5px var(--color-highlight-bg)";
505
- activeBlock.style.borderRadius = "8px";
506
- return () => {
507
- activeBlock.style.boxShadow = "";
508
- activeBlock.style.borderRadius = "";
509
- };
510
- }, [activeBlock]);
511
-
512
- // Resolve keyboard-focused block element (only annotatable blocks get comment icons)
513
- const focusedBlock = useMemo(() => {
514
- if (focusedBlockIndex === null || !containerRef.current) return null;
515
- const block = getOrderedBlocks(containerRef.current)[focusedBlockIndex];
516
- if (!block) return null;
517
- // Only show comment icon on annotatable blocks, not interactive controls
518
- return block.matches(ANNOTATABLE_SELECTOR) ? block : null;
519
- }, [focusedBlockIndex, containerRef.current]);
520
-
521
- // Collect all blocks that need a button: annotated + hovered + focused + popover target
522
- const targets = useMemo(() => {
523
- const set = new Set<HTMLElement>(annotatedBlocks);
524
- if (hoveredBlock) set.add(hoveredBlock);
525
- if (focusedBlock) set.add(focusedBlock);
526
- if (blockPopover?.anchorEl) set.add(blockPopover.anchorEl);
527
- return Array.from(set);
528
- }, [annotatedBlocks, hoveredBlock, focusedBlock, blockPopover]);
529
-
530
- return (
531
- <>
532
- {targets.map((block) => (
533
- <BlockCommentIcon
534
- key={block.getAttribute("data-task-id") || block.getAttribute("data-md-title") || block.textContent?.slice(0, 30) || "block"}
535
- block={block}
536
- isHovered={block === hoveredBlock || block === focusedBlock || block === blockPopover?.anchorEl}
537
- blockAnnotationMap={blockAnnotationMap}
538
- activeAnnotationId={activeAnnotationId}
539
- onOpen={onOpen}
540
- />
541
- ))}
542
- </>
543
- );
544
- }
545
-
546
- function BlockCommentIcon({
547
- block,
548
- isHovered,
549
- blockAnnotationMap,
550
- activeAnnotationId,
551
- onOpen,
552
- }: {
553
- block: HTMLElement;
554
- isHovered: boolean;
555
- blockAnnotationMap: Map<string, string>;
556
- activeAnnotationId: string | null;
557
- onOpen: (anchorEl: HTMLElement, snippet: string, annId?: string) => void;
558
- }) {
559
- const snippet = getBlockSnippet(block);
560
- if (!snippet) return null;
561
-
562
- const existingAnnId = blockAnnotationMap.get(snippet);
563
- const hasAnnotation = !!existingAnnId;
564
- const isActive = existingAnnId === activeAnnotationId;
565
-
566
- return createPortal(
567
- <button
568
- data-block-comment-btn
569
- onClick={(e) => {
570
- e.stopPropagation();
571
- onOpen(e.currentTarget as HTMLElement, snippet, existingAnnId);
572
- }}
573
- className={`absolute top-1 right-1 z-10 w-6 h-6 flex items-center justify-center rounded transition-all duration-150 ${
574
- hasAnnotation
575
- ? isActive
576
- ? "text-accent-amber bg-accent-amber-muted"
577
- : "text-accent-amber opacity-60 hover:opacity-100"
578
- : isHovered
579
- ? "text-text-disabled hover:text-text-tertiary opacity-50 hover:opacity-100"
580
- : "text-text-disabled opacity-0 group-hover/section:opacity-20 hover:!opacity-80"
581
- }`}
582
- title={hasAnnotation ? "Edit annotation" : "Add annotation"}
583
- >
584
- <svg width="13" height="13" viewBox="0 0 24 24" fill={hasAnnotation ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
585
- <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
586
- </svg>
587
- </button>,
588
- block,
589
- );
590
- }
591
-
592
- function EditAnnotationModal({ note, onSave, onCancel }: { note: string; onSave: (n: string) => void; onCancel: () => void }) {
593
- const [text, setText] = React.useState(note);
594
- const ref = React.useRef<HTMLTextAreaElement>(null);
595
- React.useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
596
- return (
597
- <div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center" onClick={onCancel}>
598
- <div className="bg-bg-elevated border border-border-hover rounded-lg shadow-md p-4 w-80" onClick={(e) => e.stopPropagation()}>
599
- <div className="text-tiny uppercase tracking-widest text-text-tertiary font-body mb-2">Edit annotation</div>
600
- <textarea ref={ref} value={text} onChange={(e) => setText(e.target.value)}
601
- className="w-full bg-transparent border-none text-body font-body text-text-primary resize-vertical focus:outline-none min-h-[60px]"
602
- onKeyDown={(e) => {
603
- if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) onSave(text.trim());
604
- if (e.key === "Escape") onCancel();
605
- }}
606
- />
607
- <div className="flex justify-end gap-2 mt-2">
608
- <button onClick={onCancel} className="text-meta text-text-tertiary font-body px-3 py-1">Cancel</button>
609
- <button onClick={() => onSave(text.trim())} className="text-meta font-medium font-body px-3 py-1 rounded-md bg-highlight-bg text-text-primary border border-highlight-border">Save</button>
610
- </div>
611
- </div>
612
- </div>
613
- );
614
- }