agent-canvas 0.1.44 → 0.1.45

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 (110) hide show
  1. package/bin/lib/commands/daemon.ts +3 -6
  2. package/bin/lib/commands/push.ts +1 -1
  3. package/bin/lib/daemon-lifecycle.ts +65 -72
  4. package/bin/lib/helpers.test.ts +1 -0
  5. package/bin/lib/helpers.ts +30 -74
  6. package/daemon/build.ts +16 -13
  7. package/daemon/client/App.tsx +32 -61
  8. package/daemon/client/CompareView.tsx +4 -4
  9. package/daemon/client/FileBrowser.tsx +4 -4
  10. package/daemon/client/FileViewer.tsx +4 -4
  11. package/daemon/client/OverviewView.tsx +2 -2
  12. package/daemon/client/RevisionSelect.tsx +1 -1
  13. package/daemon/client/clientApi.ts +18 -0
  14. package/daemon/client/components/index.ts +3 -16
  15. package/daemon/client/styles.css +4 -457
  16. package/daemon/dist/client.css +1 -1
  17. package/daemon/dist/client.js +76 -76
  18. package/daemon/dist/components.js +29 -29
  19. package/daemon/dist/runtime.js +1 -1
  20. package/daemon/src/components.ts +13 -0
  21. package/daemon/src/handlers/api.ts +14 -9
  22. package/daemon/src/handlers/files.ts +1 -3
  23. package/daemon/src/handlers/static.ts +1 -1
  24. package/daemon/src/handlers/uploads.ts +1 -3
  25. package/daemon/src/paths.ts +10 -24
  26. package/daemon/src/remote-feedback.test.ts +1 -1
  27. package/daemon/src/remote-feedback.ts +1 -1
  28. package/daemon/src/secrets-api.test.ts +1 -2
  29. package/daemon/src/server.ts +13 -10
  30. package/daemon/src/share-e2e.test.ts +1 -1
  31. package/daemon/src/share.test.ts +1 -1
  32. package/daemon/src/share.ts +1 -1
  33. package/daemon/tsconfig.json +1 -3
  34. package/package.json +10 -2
  35. package/.github/dark.png +0 -0
  36. package/.github/light.png +0 -0
  37. package/.github/workflows/ci.yml +0 -48
  38. package/.github/workflows/deploy-worker-prod.yml +0 -19
  39. package/.github/workflows/deploy-worker-stage.yml +0 -21
  40. package/.github/workflows/deploy-worker.yml +0 -72
  41. package/.github/workflows/release.yml +0 -36
  42. package/CLAUDE.md +0 -86
  43. package/bun.lock +0 -377
  44. package/daemon/client/AnnotationEditor.tsx +0 -315
  45. package/daemon/client/AnnotationProvider.tsx +0 -195
  46. package/daemon/client/AnnotationSidebar.tsx +0 -627
  47. package/daemon/client/FileIcon.tsx +0 -76
  48. package/daemon/client/PlanRenderer.tsx +0 -614
  49. package/daemon/client/Popover.tsx +0 -350
  50. package/daemon/client/RenderErrorContext.ts +0 -11
  51. package/daemon/client/ResponsePreview.tsx +0 -186
  52. package/daemon/client/SessionContext.tsx +0 -3
  53. package/daemon/client/annotationContext.ts +0 -114
  54. package/daemon/client/annotationDom.ts +0 -104
  55. package/daemon/client/annotationDraft.test.ts +0 -188
  56. package/daemon/client/annotationDraft.ts +0 -130
  57. package/daemon/client/compat/preact-all.ts +0 -4
  58. package/daemon/client/components/Callout.tsx +0 -26
  59. package/daemon/client/components/Checklist.tsx +0 -37
  60. package/daemon/client/components/Choice.tsx +0 -176
  61. package/daemon/client/components/CodeBlock.tsx +0 -35
  62. package/daemon/client/components/Diff.tsx +0 -65
  63. package/daemon/client/components/FilePreview.tsx +0 -88
  64. package/daemon/client/components/Image.tsx +0 -45
  65. package/daemon/client/components/Markdown.tsx +0 -44
  66. package/daemon/client/components/Mermaid.tsx +0 -565
  67. package/daemon/client/components/Note.tsx +0 -14
  68. package/daemon/client/components/Priority.tsx +0 -19
  69. package/daemon/client/components/ResponseNote.tsx +0 -19
  70. package/daemon/client/components/Section.tsx +0 -33
  71. package/daemon/client/components/Table.tsx +0 -35
  72. package/daemon/client/components/Task.tsx +0 -76
  73. package/daemon/client/components/UserInput.tsx +0 -174
  74. package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
  75. package/daemon/client/generateMarkdown.ts +0 -273
  76. package/daemon/client/highlightRange.ts +0 -224
  77. package/daemon/client/popoverPosition.ts +0 -31
  78. package/daemon/client/runtime.ts +0 -123
  79. package/daemon/client/theme.css +0 -66
  80. package/daemon/client/useTextAnnotation.tsx +0 -188
  81. package/daemon/client/utils.ts +0 -13
  82. package/daemon/src/compiler.test.ts +0 -57
  83. package/daemon/src/compiler.ts +0 -360
  84. package/daemon/src/handlers/utils.ts +0 -14
  85. package/daemon/src/router.ts +0 -16
  86. package/daemon/src/session-secrets.test.ts +0 -83
  87. package/daemon/src/session.ts +0 -612
  88. package/daemon/src/watcher.ts +0 -71
  89. package/daemon/src/websocket.test.ts +0 -99
  90. package/daemon/src/websocket.ts +0 -211
  91. package/tsconfig.json +0 -14
  92. package/workers/canvas-share/README.md +0 -159
  93. package/workers/canvas-share/oblaka.ts +0 -56
  94. package/workers/canvas-share/package.json +0 -25
  95. package/workers/canvas-share/public/assets/.gitkeep +0 -0
  96. package/workers/canvas-share/public/assets/client.css +0 -2
  97. package/workers/canvas-share/public/assets/client.js +0 -91
  98. package/workers/canvas-share/public/assets/components.js +0 -65
  99. package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
  100. package/workers/canvas-share/public/assets/preact-compat.js +0 -1
  101. package/workers/canvas-share/public/assets/runtime.js +0 -1
  102. package/workers/canvas-share/public/index.html +0 -385
  103. package/workers/canvas-share/public/screenshot-dark.png +0 -0
  104. package/workers/canvas-share/public/screenshot-light.png +0 -0
  105. package/workers/canvas-share/src/index.test.ts +0 -209
  106. package/workers/canvas-share/src/index.ts +0 -518
  107. package/workers/canvas-share/src/types.ts +0 -119
  108. package/workers/canvas-share/src/util.ts +0 -156
  109. package/workers/canvas-share/src/validation.ts +0 -160
  110. package/workers/canvas-share/tsconfig.json +0 -16
@@ -1,627 +0,0 @@
1
- import React, { useRef, useEffect, useCallback, useState, useContext, useMemo } from "react";
2
- import { useAnnotations, Annotation } from "./AnnotationProvider";
3
- import { setMarkActive } from "./highlightRange";
4
- import { generateMarkdown, hasValue, getMissingRequiredLabels } from "./generateMarkdown";
5
- import { RevisionContext, ActiveViewContext, type ActiveView } from "./App";
6
- import { SessionContext } from "#canvas/runtime";
7
- import { MarkdownPreview } from "./ResponsePreview";
8
- import { FileIcon } from "./FileIcon";
9
- import { autoResizeTextarea, RESPONSE_ANNOTATION_PATH } from "./utils";
10
- import { AnnotationEditor, ImageThumbnails } from "./AnnotationEditor";
11
- import { findAnnotationElement, scrollToAnnotation } from "./annotationDom";
12
- import { MODE, FS_AVAILABLE } from "./clientApi";
13
- import { ResponsePreview } from "./ResponsePreview";
14
-
15
- interface AnnotationSidebarProps {
16
- onSubmit: (feedback: string) => void;
17
- collapseButton?: React.ReactNode;
18
- }
19
-
20
- export function AnnotationSidebar({ onSubmit, collapseButton }: AnnotationSidebarProps) {
21
- const { isReadOnly, selectedRevision, currentRevision, revisions, agentWatching } = useContext(RevisionContext);
22
- const sessionId = useContext(SessionContext);
23
- const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision);
24
- const isCurrentButSubmitted = isReadOnly && selectedRevision === currentRevision;
25
- const feedbackConsumed = !!selectedRevInfo?.feedbackConsumed;
26
- const roundLabel = selectedRevInfo?.label || `Round ${selectedRevision}`;
27
-
28
- if (isReadOnly) {
29
- return <ReadOnlyAnnotationSidebar sessionId={sessionId} revision={selectedRevision} label={roundLabel} waitingForUpdate={isCurrentButSubmitted} feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} collapseButton={collapseButton} />;
30
- }
31
-
32
- return <AnnotationSidebarInner onSubmit={onSubmit} agentWatching={agentWatching} collapseButton={collapseButton} />;
33
- }
34
-
35
- function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, feedbackConsumed, agentWatching, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; feedbackConsumed?: boolean; agentWatching?: boolean; collapseButton?: React.ReactNode }) {
36
- const isSharedMode = MODE.isShared;
37
- const [feedback, setFeedback] = useState<string | null>(null);
38
- const [loading, setLoading] = useState(true);
39
-
40
- useEffect(() => {
41
- setLoading(true);
42
- // Shared mode has no prior local feedback to fetch — shared canvases
43
- // are always one-off snapshots. Skip the call entirely.
44
- if (!FS_AVAILABLE) { setFeedback(null); setLoading(false); return; }
45
- fetch(`/api/session/${sessionId}/revision/${revision}/feedback`)
46
- .then((r) => r.json())
47
- .then((data: any) => { setFeedback(data.feedback || null); setLoading(false); })
48
- .catch(() => { setFeedback(null); setLoading(false); });
49
- }, [sessionId, revision]);
50
-
51
- return (
52
- <div className="flex flex-col h-full">
53
- <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
54
- <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body">
55
- {waitingForUpdate ? "Feedback sent" : `Feedback — ${label}`}
56
- </span>
57
- {collapseButton}
58
- </div>
59
-
60
- {waitingForUpdate && !isSharedMode && <WaitingBanner feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} />}
61
-
62
- <div className="flex-1 overflow-y-auto px-4">
63
- {loading ? (
64
- <p className="text-[12px] text-text-tertiary font-body py-4">Loading...</p>
65
- ) : feedback ? (
66
- <MarkdownPreview text={feedback} />
67
- ) : (
68
- <p className="text-[12px] text-text-tertiary font-body py-4">No feedback was submitted for this revision.</p>
69
- )}
70
- </div>
71
- </div>
72
- );
73
- }
74
-
75
- function WaitingBanner({ feedbackConsumed, agentWatching }: { feedbackConsumed?: boolean; agentWatching?: boolean }) {
76
- if (feedbackConsumed) {
77
- return (
78
- <div className="mx-4 mb-3 px-3 py-2.5 rounded-lg bg-accent-green-muted flex items-center gap-2 flex-shrink-0">
79
- <span className="w-2 h-2 rounded-full bg-accent-green animate-pulse" />
80
- <span className="text-[12px] font-body text-accent-green">Feedback received — waiting for next revision...</span>
81
- </div>
82
- );
83
- }
84
- if (agentWatching) {
85
- return (
86
- <div className="mx-4 mb-3 px-3 py-2.5 rounded-lg bg-accent-blue-muted flex items-center gap-2 flex-shrink-0">
87
- <span className="w-2 h-2 rounded-full bg-accent-blue animate-pulse" />
88
- <span className="text-[12px] font-body text-accent-blue">Waiting for agent to pick up feedback...</span>
89
- </div>
90
- );
91
- }
92
- return (
93
- <div className="mx-4 mb-3 px-3 py-2.5 rounded-lg bg-accent-amber-muted flex items-center gap-2 flex-shrink-0">
94
- <span className="w-2 h-2 rounded-full bg-accent-amber" />
95
- <span className="text-[12px] font-body text-accent-amber">Agent disconnected — tell Claude to check feedback</span>
96
- </div>
97
- );
98
- }
99
-
100
- type ReadOnlyTab = "feedback" | "annotations";
101
-
102
- function ReadOnlyAnnotationSidebar({ sessionId, revision, label, waitingForUpdate, feedbackConsumed, agentWatching, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; feedbackConsumed?: boolean; agentWatching?: boolean; collapseButton?: React.ReactNode }) {
103
- const { annotations, generalNote, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
104
- const { setActiveView } = useContext(ActiveViewContext);
105
- const hasAnnotations = annotations.length > 0 || generalNote.trim().length > 0;
106
- const [activeTab, setActiveTab] = useState<ReadOnlyTab>("feedback");
107
-
108
- // If no annotations in localStorage, just show feedback
109
- if (!hasAnnotations) {
110
- return <FeedbackDisplay sessionId={sessionId} revision={revision} label={label} waitingForUpdate={waitingForUpdate} feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} collapseButton={collapseButton} />;
111
- }
112
-
113
- return (
114
- <div className="flex flex-col h-full">
115
- {/* Header with tab switcher */}
116
- <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
117
- <div className="flex items-center gap-1 bg-bg-input rounded-md p-0.5">
118
- <button
119
- onClick={() => setActiveTab("feedback")}
120
- className={`px-2.5 py-1 rounded text-[11px] font-medium font-body transition-colors ${
121
- activeTab === "feedback"
122
- ? "bg-bg-elevated text-text-primary shadow-sm"
123
- : "text-text-tertiary hover:text-text-secondary"
124
- }`}
125
- >
126
- Feedback
127
- </button>
128
- <button
129
- onClick={() => setActiveTab("annotations")}
130
- className={`px-2.5 py-1 rounded text-[11px] font-medium font-body transition-colors flex items-center gap-1.5 ${
131
- activeTab === "annotations"
132
- ? "bg-bg-elevated text-text-primary shadow-sm"
133
- : "text-text-tertiary hover:text-text-secondary"
134
- }`}
135
- >
136
- Annotations
137
- <span className="inline-flex items-center justify-center min-w-[16px] h-[16px] px-1 rounded-full bg-border-subtle text-[10px] font-medium">{annotations.length}</span>
138
- </button>
139
- </div>
140
- {collapseButton}
141
- </div>
142
-
143
- {waitingForUpdate && <WaitingBanner feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} />}
144
-
145
- {activeTab === "feedback" ? (
146
- <FeedbackDisplayContent sessionId={sessionId} revision={revision} />
147
- ) : (
148
- <ReadOnlyAnnotationList
149
- annotations={annotations}
150
- generalNote={generalNote}
151
- activeAnnotationId={activeAnnotationId}
152
- setActiveAnnotationId={setActiveAnnotationId}
153
- setActiveView={setActiveView}
154
- />
155
- )}
156
- </div>
157
- );
158
- }
159
-
160
- function FeedbackDisplayContent({ sessionId, revision }: { sessionId: string; revision: number }) {
161
- const [feedback, setFeedback] = useState<string | null>(null);
162
- const [loading, setLoading] = useState(true);
163
-
164
- useEffect(() => {
165
- setLoading(true);
166
- // Shared mode has no prior local feedback to fetch — shared canvases
167
- // are always one-off snapshots. Skip the call entirely.
168
- if (!FS_AVAILABLE) { setFeedback(null); setLoading(false); return; }
169
- fetch(`/api/session/${sessionId}/revision/${revision}/feedback`)
170
- .then((r) => r.json())
171
- .then((data: any) => { setFeedback(data.feedback || null); setLoading(false); })
172
- .catch(() => { setFeedback(null); setLoading(false); });
173
- }, [sessionId, revision]);
174
-
175
- return (
176
- <div className="flex-1 overflow-y-auto px-4">
177
- {loading ? (
178
- <p className="text-[12px] text-text-tertiary font-body py-4">Loading...</p>
179
- ) : feedback ? (
180
- <MarkdownPreview text={feedback} />
181
- ) : (
182
- <p className="text-[12px] text-text-tertiary font-body py-4">No feedback was submitted for this revision.</p>
183
- )}
184
- </div>
185
- );
186
- }
187
-
188
- function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId, setActiveAnnotationId, setActiveView }: {
189
- annotations: Annotation[];
190
- generalNote: string;
191
- activeAnnotationId: string | null;
192
- setActiveAnnotationId: (id: string | null) => void;
193
- setActiveView: (view: ActiveView) => void;
194
- }) {
195
- const responseAnnotations = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
196
- const planAnnotations = annotations.filter((a) => !a.filePath);
197
- const fileAnnotations = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
198
- const fileGroups: Record<string, Annotation[]> = {};
199
- for (const ann of fileAnnotations) {
200
- const key = ann.filePath!;
201
- if (!fileGroups[key]) fileGroups[key] = [];
202
- fileGroups[key].push(ann);
203
- }
204
-
205
- const handleMouseEnter = (annId: string) => {
206
- setActiveAnnotationId(annId);
207
- setMarkActive(annId, true);
208
- };
209
-
210
- const handleMouseLeave = (annId: string) => {
211
- setActiveAnnotationId(null);
212
- setMarkActive(annId, false);
213
- };
214
-
215
- const handleClick = (ann: Annotation) => {
216
- if (ann.id === activeAnnotationId) {
217
- setActiveAnnotationId(null);
218
- } else {
219
- setActiveAnnotationId(ann.id);
220
- if (ann.filePath) {
221
- setActiveView({ type: "file", path: ann.filePath });
222
- setTimeout(() => {
223
- const el = findAnnotationElement(ann);
224
- if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
225
- }, 150);
226
- }
227
- }
228
- };
229
-
230
- const renderAnnotation = (ann: Annotation) => (
231
- <div
232
- key={ann.id}
233
- className={`group/ann relative px-3 py-2.5 transition-colors duration-150 cursor-pointer ${
234
- activeAnnotationId === ann.id
235
- ? "bg-highlight-selected"
236
- : "odd:bg-bg-elevated-half hover:bg-bg-input"
237
- }`}
238
- onMouseEnter={() => handleMouseEnter(ann.id)}
239
- onMouseLeave={() => handleMouseLeave(ann.id)}
240
- onClick={() => handleClick(ann)}
241
- >
242
- <div className="text-[11px] text-text-tertiary italic line-clamp-2 mb-1.5 leading-snug font-body border-l-2 border-border-medium pl-2">
243
- {ann.snippet.length > 80 ? ann.snippet.slice(0, 80) + "..." : ann.snippet}
244
- </div>
245
- {ann.note.trim() && (
246
- <div className="text-[13px] font-body text-text-primary leading-relaxed">
247
- {ann.note}
248
- </div>
249
- )}
250
- <ImageThumbnails images={ann.images || []} />
251
- <div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100">
252
- <button
253
- onClick={(e) => { e.stopPropagation(); scrollToAnnotation(ann, setActiveView); }}
254
- className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-input transition-colors"
255
- title="Scroll to annotation"
256
- >
257
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
258
- <circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
259
- </svg>
260
- </button>
261
- </div>
262
- </div>
263
- );
264
-
265
- const hasMultipleGroups = [responseAnnotations, planAnnotations, fileAnnotations].filter((g) => g.length > 0).length > 1;
266
-
267
- return (
268
- <>
269
- <div className="flex-1 overflow-y-auto">
270
- {responseAnnotations.length > 0 && (
271
- <>
272
- {hasMultipleGroups && (
273
- <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Agent Response</div>
274
- )}
275
- {responseAnnotations.map(renderAnnotation)}
276
- </>
277
- )}
278
-
279
- {planAnnotations.length > 0 && (
280
- <>
281
- {hasMultipleGroups && (
282
- <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Canvas</div>
283
- )}
284
- {planAnnotations.map(renderAnnotation)}
285
- </>
286
- )}
287
-
288
- {Object.entries(fileGroups).map(([filePath, anns]) => (
289
- <div key={filePath}>
290
- <button
291
- onClick={() => setActiveView({ type: "file", path: filePath })}
292
- className="flex items-center gap-1.5 text-[10px] uppercase tracking-widest text-text-tertiary hover:text-text-secondary font-body px-3 pt-3 pb-1 truncate transition-colors w-full text-left"
293
- title={filePath}
294
- >
295
- <FileIcon name={filePath.split("/").pop() || filePath} type="file" />
296
- <span className="truncate">{filePath}</span>
297
- </button>
298
- {anns.map(renderAnnotation)}
299
- </div>
300
- ))}
301
- </div>
302
-
303
- {generalNote.trim() && (
304
- <div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
305
- <div className="text-[13px] font-body text-text-primary leading-relaxed">
306
- {generalNote}
307
- </div>
308
- </div>
309
- )}
310
- </>
311
- );
312
- }
313
-
314
- function AnnotationSidebarInner({ onSubmit, agentWatching, collapseButton }: Omit<AnnotationSidebarProps, "onPreview"> & { agentWatching: boolean }) {
315
- const [previewOpen, setPreviewOpen] = useState(false);
316
- const {
317
- annotations, updateAnnotation, removeAnnotation,
318
- addAnnotationImage, removeAnnotationImage,
319
- generalNote, setGeneralNote,
320
- activeAnnotationId, setActiveAnnotationId,
321
- responses, feedbackEntries,
322
- } = useAnnotations();
323
- const sessionId = useContext(SessionContext);
324
- const { setActiveView } = React.useContext(ActiveViewContext);
325
-
326
- const listRef = useRef<HTMLDivElement>(null);
327
- const annRefs = useRef<Map<string, HTMLDivElement>>(new Map());
328
-
329
- const responseAnnotations = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
330
- const planAnnotations = annotations.filter((a) => !a.filePath);
331
- const fileAnnotations = annotations.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
332
- const fileGroups: Record<string, Annotation[]> = {};
333
- for (const ann of fileAnnotations) {
334
- const key = ann.filePath!;
335
- if (!fileGroups[key]) fileGroups[key] = [];
336
- fileGroups[key].push(ann);
337
- }
338
-
339
- // Scroll to active annotation in sidebar
340
- useEffect(() => {
341
- if (!activeAnnotationId) return;
342
- const el = annRefs.current.get(activeAnnotationId);
343
- if (el) {
344
- el.scrollIntoView({ behavior: "smooth", block: "nearest" });
345
- }
346
- }, [activeAnnotationId]);
347
-
348
- // Hover on sidebar card → highlight inline mark + block
349
- const handleMouseEnter = useCallback((annId: string) => {
350
- setActiveAnnotationId(annId);
351
- setMarkActive(annId, true);
352
- }, [setActiveAnnotationId]);
353
-
354
- const handleMouseLeave = useCallback((annId: string) => {
355
- setActiveAnnotationId(null);
356
- setMarkActive(annId, false);
357
- }, [activeAnnotationId]);
358
-
359
- const remoteAnnotations = useMemo(() => annotations.filter((a) => a.source === "remote"), [annotations]);
360
- const [includedRemoteIds, setIncludedRemoteIds] = useState<Set<string>>(() => new Set());
361
-
362
- // Auto-include new remote annotations as they arrive
363
- useEffect(() => {
364
- if (remoteAnnotations.length === 0) return;
365
- setIncludedRemoteIds((prev) => {
366
- const next = new Set(prev);
367
- for (const a of remoteAnnotations) next.add(a.id);
368
- return next.size === prev.size ? prev : next;
369
- });
370
- }, [remoteAnnotations]);
371
-
372
- const toggleRemoteId = useCallback((id: string) => {
373
- setIncludedRemoteIds((prev) => {
374
- const next = new Set(prev);
375
- if (next.has(id)) next.delete(id); else next.add(id);
376
- return next;
377
- });
378
- }, []);
379
-
380
- const hasResponses = Array.from(responses.values()).some(hasValue);
381
- const hasFeedback = feedbackEntries.size > 0;
382
- const hasContent = annotations.length > 0 || generalNote.trim().length > 0 || hasResponses || hasFeedback;
383
- const [validationError, setValidationError] = useState<string | null>(null);
384
-
385
- const renderAnnotation = (ann: Annotation) => {
386
- const isRemote = ann.source === "remote";
387
- return (
388
- <div
389
- key={ann.id}
390
- ref={(el) => { if (el) annRefs.current.set(ann.id, el); else annRefs.current.delete(ann.id); }}
391
- className={`group/ann relative px-3 py-2.5 transition-colors duration-150 ${
392
- activeAnnotationId === ann.id
393
- ? "bg-highlight-selected"
394
- : isRemote
395
- ? "bg-accent-purple-muted/30 hover:bg-accent-purple-muted/50"
396
- : "odd:bg-bg-elevated-half hover:bg-bg-input"
397
- }`}
398
- onMouseEnter={() => handleMouseEnter(ann.id)}
399
- onMouseLeave={() => handleMouseLeave(ann.id)}
400
- onClick={() => {
401
- if (ann.id === activeAnnotationId) {
402
- setActiveAnnotationId(null);
403
- } else {
404
- setActiveAnnotationId(ann.id);
405
- if (ann.filePath) {
406
- setActiveView({ type: "file", path: ann.filePath });
407
- setTimeout(() => {
408
- const el = findAnnotationElement(ann);
409
- if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
410
- }, 150);
411
- }
412
- }
413
- }}
414
- >
415
- {isRemote && ann.author && (
416
- <div className="flex items-center gap-1.5 mb-1.5">
417
- <input
418
- type="checkbox"
419
- checked={includedRemoteIds.has(ann.id)}
420
- onChange={(e) => { e.stopPropagation(); toggleRemoteId(ann.id); }}
421
- onClick={(e) => e.stopPropagation()}
422
- className="accent-accent-purple flex-shrink-0"
423
- title="Include in submission"
424
- />
425
- <span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent-purple text-white text-[9px] font-medium uppercase">
426
- {ann.author.name.charAt(0)}
427
- </span>
428
- <span className="text-[11px] font-medium text-accent-purple font-body">
429
- {ann.author.name}
430
- </span>
431
- <span className="text-[10px] text-text-tertiary font-body uppercase tracking-widest">
432
- Remote
433
- </span>
434
- </div>
435
- )}
436
-
437
- {/* Snippet quote */}
438
- <div className="text-[11px] text-text-tertiary italic line-clamp-2 mb-1.5 leading-snug font-body border-l-2 border-border-medium pl-2">
439
- {ann.snippet.length > 80 ? ann.snippet.slice(0, 80) + "..." : ann.snippet}
440
- </div>
441
-
442
- {/* Editable note + images — only for local annotations. Remote are read-only. */}
443
- {isRemote ? (
444
- <div className="text-[13px] font-body text-text-primary leading-relaxed whitespace-pre-wrap">
445
- {ann.note}
446
- </div>
447
- ) : (
448
- <div onClick={(e) => e.stopPropagation()}>
449
- <AnnotationEditor
450
- note={ann.note}
451
- onNoteChange={(note) => updateAnnotation(ann.id, note)}
452
- images={ann.images || []}
453
- onAddImage={(path) => addAnnotationImage(ann.id, path)}
454
- onRemoveImage={(path) => removeAnnotationImage(ann.id, path)}
455
- sessionId={sessionId}
456
- autoResize
457
- minHeight={20}
458
- textareaClassName="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
459
- textareaStyle={{ height: "auto", overflow: "hidden" }}
460
- placeholder="Add your note..."
461
- attachButton="on-focus"
462
- />
463
- </div>
464
- )}
465
-
466
- {/* Actions — top right on hover */}
467
- <div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100 flex items-center gap-0.5">
468
- <button
469
- onClick={(e) => {
470
- e.stopPropagation();
471
- scrollToAnnotation(ann, setActiveView);
472
- }}
473
- className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-input transition-colors"
474
- title="Scroll to annotation"
475
- >
476
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
477
- <circle cx="12" cy="12" r="3" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
478
- </svg>
479
- </button>
480
- {!isRemote && (
481
- <button
482
- onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
483
- className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-accent-red hover:bg-accent-red-muted transition-colors"
484
- title="Delete annotation"
485
- >
486
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
487
- <polyline points="3 6 5 6 21 6"/>
488
- <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
489
- </svg>
490
- </button>
491
- )}
492
- </div>
493
- </div>
494
- );
495
- };
496
-
497
- return (
498
- <div className="flex flex-col h-full">
499
- {/* Header */}
500
- <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
501
- <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body flex items-center gap-2">
502
- Annotations
503
- {annotations.length > 0 && (
504
- <span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-border-subtle text-[10px] font-medium text-text-secondary">{annotations.length}</span>
505
- )}
506
- {!MODE.isShared && (
507
- <span
508
- className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${agentWatching ? "bg-accent-green" : "bg-accent-amber"}`}
509
- title={agentWatching ? "Agent connected" : "Agent disconnected"}
510
- />
511
- )}
512
- </span>
513
- {collapseButton}
514
- </div>
515
-
516
- {/* Annotation list */}
517
- <div ref={listRef} className="flex-1 overflow-y-auto">
518
- {annotations.length === 0 && (
519
- <p className="text-[12px] text-text-tertiary px-3 py-4 leading-relaxed font-body">
520
- Select text in the canvas or in files to add annotations.
521
- </p>
522
- )}
523
-
524
- {responseAnnotations.length > 0 && (
525
- <>
526
- {(planAnnotations.length > 0 || fileAnnotations.length > 0) && (
527
- <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Agent Response</div>
528
- )}
529
- {responseAnnotations.map(renderAnnotation)}
530
- </>
531
- )}
532
-
533
- {planAnnotations.length > 0 && (responseAnnotations.length > 0 || fileAnnotations.length > 0) && (
534
- <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Canvas</div>
535
- )}
536
- {planAnnotations.map(renderAnnotation)}
537
-
538
- {Object.entries(fileGroups).map(([filePath, anns]) => (
539
- <div key={filePath}>
540
- <button
541
- onClick={() => setActiveView({ type: "file", path: filePath })}
542
- className="flex items-center gap-1.5 text-[10px] uppercase tracking-widest text-text-tertiary hover:text-text-secondary font-body px-3 pt-3 pb-1 truncate transition-colors w-full text-left"
543
- title={filePath}
544
- >
545
- <FileIcon name={filePath.split("/").pop() || filePath} type="file" />
546
- <span className="truncate">{filePath}</span>
547
- </button>
548
- {anns.map(renderAnnotation)}
549
- </div>
550
- ))}
551
- </div>
552
-
553
- {/* General note — seamless */}
554
- <div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
555
- <textarea
556
- value={generalNote}
557
- onChange={(e) => setGeneralNote(e.target.value)}
558
- className="w-full bg-transparent text-[13px] font-body text-text-primary resize-none leading-relaxed p-0 border-none ring-0 shadow-none outline-none focus:outline-none focus:ring-0 focus:border-none placeholder:text-text-disabled min-h-[40px]"
559
- placeholder="General notes..."
560
- onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement, 40)}
561
- ref={(el) => { if (el) autoResizeTextarea(el, 40); }}
562
- />
563
- </div>
564
-
565
- {/* Validation error */}
566
- {validationError && (
567
- <div className="px-4 py-2 text-[12px] text-accent-red font-body border-t border-border-subtle flex-shrink-0 flex items-center justify-between gap-2">
568
- <span>{validationError}</span>
569
- <button
570
- onClick={() => {
571
- setValidationError(null);
572
- const md = generateMarkdown(annotations, generalNote, responses, feedbackEntries, includedRemoteIds);
573
- onSubmit(md);
574
- }}
575
- className="text-[11px] text-text-tertiary hover:text-text-secondary font-body whitespace-nowrap underline"
576
- >
577
- Submit anyway
578
- </button>
579
- </div>
580
- )}
581
-
582
- {/* Action buttons */}
583
- <div className="px-4 py-3 border-t border-border-subtle flex-shrink-0">
584
- {hasContent ? (
585
- <div className="flex gap-2">
586
- <button
587
- onClick={() => setPreviewOpen(true)}
588
- className="flex-1 py-2 rounded-lg font-body text-[13px] font-medium transition-all bg-border-subtle text-text-secondary hover:bg-border-medium hover:text-text-primary"
589
- >
590
- Preview
591
- </button>
592
- <button
593
- onClick={() => {
594
- const allMissing = getMissingRequiredLabels(responses, feedbackEntries);
595
- if (allMissing.length > 0) {
596
- setValidationError(`Please answer: ${allMissing.join(", ")}`);
597
- return;
598
- }
599
- setValidationError(null);
600
- const md = generateMarkdown(annotations, generalNote, responses, feedbackEntries, includedRemoteIds);
601
- onSubmit(md);
602
- }}
603
- className="flex-1 py-2 rounded-lg font-body text-[13px] font-medium transition-all bg-btn-primary text-btn-primary-text hover:opacity-90 hover:-translate-y-px shadow-sm"
604
- >
605
- Submit
606
- </button>
607
- </div>
608
- ) : (
609
- <button
610
- onClick={() => onSubmit("No feedback — looks good.")}
611
- className="w-full py-2 rounded-lg font-body text-[13px] font-medium border border-border-medium text-text-secondary hover:text-text-primary hover:border-border-hover hover:bg-bg-input transition-all"
612
- >
613
- Submit without feedback
614
- </button>
615
- )}
616
- </div>
617
- <ResponsePreview
618
- open={previewOpen}
619
- onClose={() => setPreviewOpen(false)}
620
- onSubmit={onSubmit}
621
- includedRemoteIds={includedRemoteIds}
622
- onToggleRemoteId={toggleRemoteId}
623
- />
624
- </div>
625
- );
626
- }
627
-