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,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
-