@fabrika/canvas-kernel 0.1.45 → 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 (66) hide show
  1. package/README.md +33 -67
  2. package/index.ts +19 -83
  3. package/package.json +68 -72
  4. package/src/client/AnnotationProvider.tsx +227 -268
  5. package/src/client/AnnotationSidebar.tsx +392 -338
  6. package/src/client/FileIcon.tsx +106 -65
  7. package/src/client/PlanRenderer.tsx +607 -551
  8. package/src/client/RenderErrorContext.ts +7 -7
  9. package/src/client/ResponsePreview.tsx +192 -173
  10. package/src/client/annotationDom.ts +43 -97
  11. package/src/client/annotationDraft.ts +98 -92
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +28 -28
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +26 -26
  17. package/src/client/components/Diff.tsx +17 -17
  18. package/src/client/components/FilePreview.tsx +99 -74
  19. package/src/client/components/Image.tsx +41 -39
  20. package/src/client/components/Markdown.tsx +33 -32
  21. package/src/client/components/Mermaid.tsx +592 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +11 -11
  24. package/src/client/components/ResponseNote.tsx +17 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +34 -29
  27. package/src/client/components/Task.tsx +58 -60
  28. package/src/client/components/UserInput.tsx +169 -152
  29. package/src/client/components/index.ts +16 -16
  30. package/src/client/components/renderMarkdownChildren.tsx +86 -68
  31. package/src/client/diffLines.ts +51 -39
  32. package/src/client/generateMarkdown.ts +197 -191
  33. package/src/client/hostContext.tsx +34 -34
  34. package/src/client/index.ts +43 -125
  35. package/src/client/preact-all.ts +4 -4
  36. package/src/client/runtime.ts +58 -111
  37. package/src/client/styles.css +1 -74
  38. package/src/engine/compiler.ts +316 -313
  39. package/src/engine/paths.ts +44 -44
  40. package/src/engine/session.ts +623 -618
  41. package/src/engine/watcher.ts +63 -60
  42. package/src/engine/websocket.ts +123 -121
  43. package/annotate.ts +0 -94
  44. package/daemon.ts +0 -46
  45. package/src/client/AnnotationDraftFooter.tsx +0 -76
  46. package/src/client/AnnotationEditor.tsx +0 -320
  47. package/src/client/AnnotationList.tsx +0 -190
  48. package/src/client/Popover.tsx +0 -361
  49. package/src/client/SessionContext.tsx +0 -3
  50. package/src/client/annotationContext.ts +0 -130
  51. package/src/client/annotationMarkdown.ts +0 -102
  52. package/src/client/annotationTarget.ts +0 -73
  53. package/src/client/blockTarget.ts +0 -127
  54. package/src/client/highlightRange.ts +0 -262
  55. package/src/client/popoverPosition.ts +0 -46
  56. package/src/client/regionTarget.ts +0 -180
  57. package/src/client/testing/dom.ts +0 -74
  58. package/src/client/textTarget.ts +0 -31
  59. package/src/client/useRegionAnnotation.tsx +0 -188
  60. package/src/client/useTextAnnotation.tsx +0 -194
  61. package/src/client/utils.ts +0 -33
  62. package/src/engine/channel.ts +0 -278
  63. package/src/engine/http-utils.ts +0 -14
  64. package/src/engine/lifecycle.ts +0 -302
  65. package/src/engine/router.ts +0 -16
  66. package/src/engine/waiter.ts +0 -188
@@ -1,17 +1,15 @@
1
- import React, { useEffect, useState, useContext, useMemo, useCallback } from "react";
2
- import { useAnnotations } from "./AnnotationProvider";
3
- import type { Annotation } from "./AnnotationProvider";
4
- import { generateMarkdown, hasValue, getMissingRequiredLabels } from "./generateMarkdown";
5
- import { RevisionContext, ActiveViewContext } from "./appContext";
6
- import { SessionContext } from "#canvas/runtime";
7
- import { MarkdownPreview } from "./ResponsePreview";
8
- import { FileIcon } from "./FileIcon";
9
- import { fileAnnotationPath, RESPONSE_ANNOTATION_PATH } from "./utils";
10
- import { AnnotationList } from "./AnnotationList";
11
- import { AnnotationDraftFooter } from "./AnnotationDraftFooter";
12
- import { findAnnotationElement, scrollToAnnotation } from "./annotationDom";
13
- import { useCanvasHost } from "./hostContext";
14
- import { ResponsePreview } from "./ResponsePreview";
1
+ import { SessionContext } from '#canvas/runtime'
2
+ import { AnnotationDraftFooter, AnnotationList, fileAnnotationPath, RESPONSE_ANNOTATION_PATH } from '@fabrika/annotations'
3
+ import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
4
+ import { findAnnotationElement, scrollToAnnotation } from './annotationDom'
5
+ import { useAnnotations } from './AnnotationProvider'
6
+ import type { Annotation } from './AnnotationProvider'
7
+ import { ActiveViewContext, RevisionContext } from './appContext'
8
+ import { FileIcon } from './FileIcon'
9
+ import { generateMarkdown, getMissingRequiredLabels, hasValue } from './generateMarkdown'
10
+ import { useCanvasHost } from './hostContext'
11
+ import { MarkdownPreview } from './ResponsePreview'
12
+ import { ResponsePreview } from './ResponsePreview'
15
13
 
16
14
  /**
17
15
  * The canvas host's sidebar: the reusable annotation list and draft footer,
@@ -20,355 +18,411 @@ import { ResponsePreview } from "./ResponsePreview";
20
18
  */
21
19
 
22
20
  interface AnnotationSidebarProps {
23
- onSubmit: (feedback: string) => void;
24
- collapseButton?: React.ReactNode;
21
+ onSubmit: (feedback: string) => void
22
+ collapseButton?: React.ReactNode
25
23
  }
26
24
 
27
25
  export function AnnotationSidebar({ onSubmit, collapseButton }: AnnotationSidebarProps) {
28
- const { isReadOnly, selectedRevision, currentRevision, revisions, agentWatching } = useContext(RevisionContext);
29
- const sessionId = useContext(SessionContext);
30
- const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision);
31
- const isCurrentButSubmitted = isReadOnly && selectedRevision === currentRevision;
32
- const feedbackConsumed = !!selectedRevInfo?.feedbackConsumed;
33
- const roundLabel = selectedRevInfo?.label || `Round ${selectedRevision}`;
34
-
35
- if (isReadOnly) {
36
- return <ReadOnlyAnnotationSidebar sessionId={sessionId} revision={selectedRevision} label={roundLabel} waitingForUpdate={isCurrentButSubmitted} feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} collapseButton={collapseButton} />;
37
- }
38
-
39
- return <AnnotationSidebarInner onSubmit={onSubmit} agentWatching={agentWatching} collapseButton={collapseButton} />;
26
+ const { isReadOnly, selectedRevision, currentRevision, revisions, agentWatching } = useContext(RevisionContext)
27
+ const sessionId = useContext(SessionContext)
28
+ const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision)
29
+ const isCurrentButSubmitted = isReadOnly && selectedRevision === currentRevision
30
+ const feedbackConsumed = !!selectedRevInfo?.feedbackConsumed
31
+ const roundLabel = selectedRevInfo?.label || `Round ${selectedRevision}`
32
+
33
+ if (isReadOnly) {
34
+ return (
35
+ <ReadOnlyAnnotationSidebar
36
+ sessionId={sessionId}
37
+ revision={selectedRevision}
38
+ label={roundLabel}
39
+ waitingForUpdate={isCurrentButSubmitted}
40
+ feedbackConsumed={feedbackConsumed}
41
+ agentWatching={agentWatching}
42
+ collapseButton={collapseButton}
43
+ />
44
+ )
45
+ }
46
+
47
+ return <AnnotationSidebarInner onSubmit={onSubmit} agentWatching={agentWatching} collapseButton={collapseButton} />
40
48
  }
41
49
 
42
- function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, feedbackConsumed, agentWatching, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; feedbackConsumed?: boolean; agentWatching?: boolean; collapseButton?: React.ReactNode }) {
43
- const host = useCanvasHost();
44
- const isSharedMode = host.isShared;
45
-
46
- return (
47
- <div className="flex flex-col h-full">
48
- <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
49
- <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body">
50
- {waitingForUpdate ? "Feedback sent" : `Feedback — ${label}`}
51
- </span>
52
- {collapseButton}
53
- </div>
54
-
55
- {waitingForUpdate && !isSharedMode && <WaitingBanner feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} />}
56
-
57
- <FeedbackDisplayContent sessionId={sessionId} revision={revision} />
58
- </div>
59
- );
50
+ function FeedbackDisplay(
51
+ { sessionId, revision, label, waitingForUpdate, feedbackConsumed, agentWatching, collapseButton }: {
52
+ sessionId: string
53
+ revision: number
54
+ label: string
55
+ waitingForUpdate?: boolean
56
+ feedbackConsumed?: boolean
57
+ agentWatching?: boolean
58
+ collapseButton?: React.ReactNode
59
+ },
60
+ ) {
61
+ const host = useCanvasHost()
62
+ const isSharedMode = host.isShared
63
+
64
+ return (
65
+ <div className="flex flex-col h-full">
66
+ <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
67
+ <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body">
68
+ {waitingForUpdate ? 'Feedback sent' : `Feedback — ${label}`}
69
+ </span>
70
+ {collapseButton}
71
+ </div>
72
+
73
+ {waitingForUpdate && !isSharedMode && <WaitingBanner feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} />}
74
+
75
+ <FeedbackDisplayContent sessionId={sessionId} revision={revision} />
76
+ </div>
77
+ )
60
78
  }
61
79
 
62
80
  function WaitingBanner({ feedbackConsumed, agentWatching }: { feedbackConsumed?: boolean; agentWatching?: boolean }) {
63
- if (feedbackConsumed) {
64
- return (
65
- <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">
66
- <span className="w-2 h-2 rounded-full bg-accent-green animate-pulse" />
67
- <span className="text-[12px] font-body text-accent-green">Feedback received — waiting for next revision...</span>
68
- </div>
69
- );
70
- }
71
- if (agentWatching) {
72
- return (
73
- <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">
74
- <span className="w-2 h-2 rounded-full bg-accent-blue animate-pulse" />
75
- <span className="text-[12px] font-body text-accent-blue">Waiting for agent to pick up feedback...</span>
76
- </div>
77
- );
78
- }
79
- return (
80
- <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">
81
- <span className="w-2 h-2 rounded-full bg-accent-amber" />
82
- <span className="text-[12px] font-body text-accent-amber">Agent disconnected — tell Claude to check feedback</span>
83
- </div>
84
- );
81
+ if (feedbackConsumed) {
82
+ return (
83
+ <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">
84
+ <span className="w-2 h-2 rounded-full bg-accent-green animate-pulse" />
85
+ <span className="text-[12px] font-body text-accent-green">Feedback received — waiting for next revision...</span>
86
+ </div>
87
+ )
88
+ }
89
+ if (agentWatching) {
90
+ return (
91
+ <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">
92
+ <span className="w-2 h-2 rounded-full bg-accent-blue animate-pulse" />
93
+ <span className="text-[12px] font-body text-accent-blue">Waiting for agent to pick up feedback...</span>
94
+ </div>
95
+ )
96
+ }
97
+ return (
98
+ <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">
99
+ <span className="w-2 h-2 rounded-full bg-accent-amber" />
100
+ <span className="text-[12px] font-body text-accent-amber">Agent disconnected — tell Claude to check feedback</span>
101
+ </div>
102
+ )
85
103
  }
86
104
 
87
- type ReadOnlyTab = "feedback" | "annotations";
88
-
89
- function ReadOnlyAnnotationSidebar({ sessionId, revision, label, waitingForUpdate, feedbackConsumed, agentWatching, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; feedbackConsumed?: boolean; agentWatching?: boolean; collapseButton?: React.ReactNode }) {
90
- const { annotations, generalNote } = useAnnotations();
91
- const hasAnnotations = annotations.length > 0 || generalNote.trim().length > 0;
92
- const [activeTab, setActiveTab] = useState<ReadOnlyTab>("feedback");
93
-
94
- // If no annotations in localStorage, just show feedback
95
- if (!hasAnnotations) {
96
- return <FeedbackDisplay sessionId={sessionId} revision={revision} label={label} waitingForUpdate={waitingForUpdate} feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} collapseButton={collapseButton} />;
97
- }
98
-
99
- return (
100
- <div className="flex flex-col h-full">
101
- {/* Header with tab switcher */}
102
- <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
103
- <div className="flex items-center gap-1 bg-bg-input rounded-md p-0.5">
104
- <button
105
- onClick={() => setActiveTab("feedback")}
106
- className={`px-2.5 py-1 rounded text-[11px] font-medium font-body transition-colors ${
107
- activeTab === "feedback"
108
- ? "bg-bg-elevated text-text-primary shadow-sm"
109
- : "text-text-tertiary hover:text-text-secondary"
110
- }`}
111
- >
112
- Feedback
113
- </button>
114
- <button
115
- onClick={() => setActiveTab("annotations")}
116
- className={`px-2.5 py-1 rounded text-[11px] font-medium font-body transition-colors flex items-center gap-1.5 ${
117
- activeTab === "annotations"
118
- ? "bg-bg-elevated text-text-primary shadow-sm"
119
- : "text-text-tertiary hover:text-text-secondary"
120
- }`}
121
- >
122
- Annotations
123
- <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>
124
- </button>
125
- </div>
126
- {collapseButton}
127
- </div>
128
-
129
- {waitingForUpdate && <WaitingBanner feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} />}
130
-
131
- {activeTab === "feedback" ? (
132
- <FeedbackDisplayContent sessionId={sessionId} revision={revision} />
133
- ) : (
134
- <>
135
- <div className="flex-1 overflow-y-auto">
136
- <CanvasAnnotationGroups annotations={annotations} readOnly />
137
- </div>
138
- {generalNote.trim() && (
139
- <div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
140
- <div className="text-[13px] font-body text-text-primary leading-relaxed">
141
- {generalNote}
142
- </div>
143
- </div>
144
- )}
145
- </>
146
- )}
147
- </div>
148
- );
105
+ type ReadOnlyTab = 'feedback' | 'annotations'
106
+
107
+ function ReadOnlyAnnotationSidebar(
108
+ { sessionId, revision, label, waitingForUpdate, feedbackConsumed, agentWatching, collapseButton }: {
109
+ sessionId: string
110
+ revision: number
111
+ label: string
112
+ waitingForUpdate?: boolean
113
+ feedbackConsumed?: boolean
114
+ agentWatching?: boolean
115
+ collapseButton?: React.ReactNode
116
+ },
117
+ ) {
118
+ const { annotations, generalNote } = useAnnotations()
119
+ const hasAnnotations = annotations.length > 0 || generalNote.trim().length > 0
120
+ const [activeTab, setActiveTab] = useState<ReadOnlyTab>('feedback')
121
+
122
+ // If no annotations in localStorage, just show feedback
123
+ if (!hasAnnotations) {
124
+ return (
125
+ <FeedbackDisplay
126
+ sessionId={sessionId}
127
+ revision={revision}
128
+ label={label}
129
+ waitingForUpdate={waitingForUpdate}
130
+ feedbackConsumed={feedbackConsumed}
131
+ agentWatching={agentWatching}
132
+ collapseButton={collapseButton}
133
+ />
134
+ )
135
+ }
136
+
137
+ return (
138
+ <div className="flex flex-col h-full">
139
+ {/* Header with tab switcher */}
140
+ <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
141
+ <div className="flex items-center gap-1 bg-bg-input rounded-md p-0.5">
142
+ <button
143
+ onClick={() => setActiveTab('feedback')}
144
+ className={`px-2.5 py-1 rounded text-[11px] font-medium font-body transition-colors ${
145
+ activeTab === 'feedback'
146
+ ? 'bg-bg-elevated text-text-primary shadow-sm'
147
+ : 'text-text-tertiary hover:text-text-secondary'
148
+ }`}
149
+ >
150
+ Feedback
151
+ </button>
152
+ <button
153
+ onClick={() => setActiveTab('annotations')}
154
+ className={`px-2.5 py-1 rounded text-[11px] font-medium font-body transition-colors flex items-center gap-1.5 ${
155
+ activeTab === 'annotations'
156
+ ? 'bg-bg-elevated text-text-primary shadow-sm'
157
+ : 'text-text-tertiary hover:text-text-secondary'
158
+ }`}
159
+ >
160
+ Annotations
161
+ <span className="inline-flex items-center justify-center min-w-[16px] h-[16px] px-1 rounded-full bg-border-subtle text-[10px] font-medium">
162
+ {annotations.length}
163
+ </span>
164
+ </button>
165
+ </div>
166
+ {collapseButton}
167
+ </div>
168
+
169
+ {waitingForUpdate && <WaitingBanner feedbackConsumed={feedbackConsumed} agentWatching={agentWatching} />}
170
+
171
+ {activeTab === 'feedback' ? <FeedbackDisplayContent sessionId={sessionId} revision={revision} /> : (
172
+ <>
173
+ <div className="flex-1 overflow-y-auto">
174
+ <CanvasAnnotationGroups annotations={annotations} readOnly />
175
+ </div>
176
+ {generalNote.trim() && (
177
+ <div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
178
+ <div className="text-[13px] font-body text-text-primary leading-relaxed">
179
+ {generalNote}
180
+ </div>
181
+ </div>
182
+ )}
183
+ </>
184
+ )}
185
+ </div>
186
+ )
149
187
  }
150
188
 
151
189
  function FeedbackDisplayContent({ sessionId, revision }: { sessionId: string; revision: number }) {
152
- const host = useCanvasHost();
153
- const [feedback, setFeedback] = useState<string | null>(null);
154
- const [loading, setLoading] = useState(true);
155
-
156
- useEffect(() => {
157
- setLoading(true);
158
- // Shared mode has no prior local feedback to fetch — shared canvases
159
- // are always one-off snapshots. Skip the call entirely.
160
- if (!host.fsAvailable) { setFeedback(null); setLoading(false); return; }
161
- fetch(`/api/session/${sessionId}/revision/${revision}/feedback`)
162
- .then((r) => r.json())
163
- .then((data: any) => { setFeedback(data.feedback || null); setLoading(false); })
164
- .catch(() => { setFeedback(null); setLoading(false); });
165
- }, [sessionId, revision]);
166
-
167
- return (
168
- <div className="flex-1 overflow-y-auto px-4">
169
- {loading ? (
170
- <p className="text-[12px] text-text-tertiary font-body py-4">Loading...</p>
171
- ) : feedback ? (
172
- <MarkdownPreview text={feedback} />
173
- ) : (
174
- <p className="text-[12px] text-text-tertiary font-body py-4">No feedback was submitted for this revision.</p>
175
- )}
176
- </div>
177
- );
190
+ const host = useCanvasHost()
191
+ const [feedback, setFeedback] = useState<string | null>(null)
192
+ const [loading, setLoading] = useState(true)
193
+
194
+ useEffect(() => {
195
+ setLoading(true)
196
+ // Shared mode has no prior local feedback to fetch — shared canvases
197
+ // are always one-off snapshots. Skip the call entirely.
198
+ if (!host.fsAvailable) {
199
+ setFeedback(null)
200
+ setLoading(false)
201
+ return
202
+ }
203
+ fetch(`/api/session/${sessionId}/revision/${revision}/feedback`)
204
+ .then((r) => r.json())
205
+ .then((data: any) => {
206
+ setFeedback(data.feedback || null)
207
+ setLoading(false)
208
+ })
209
+ .catch(() => {
210
+ setFeedback(null)
211
+ setLoading(false)
212
+ })
213
+ }, [sessionId, revision])
214
+
215
+ return (
216
+ <div className="flex-1 overflow-y-auto px-4">
217
+ {loading
218
+ ? <p className="text-[12px] text-text-tertiary font-body py-4">Loading...</p>
219
+ : feedback
220
+ ? <MarkdownPreview text={feedback} />
221
+ : <p className="text-[12px] text-text-tertiary font-body py-4">No feedback was submitted for this revision.</p>}
222
+ </div>
223
+ )
178
224
  }
179
225
 
180
226
  /** How a canvas splits its annotations: the agent's response, the canvas
181
227
  * itself, then one group per file the reader opened. */
182
228
  function groupAnnotations(annotations: Annotation[]) {
183
- const response = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
184
- const plan = annotations.filter((a) => !a.filePath);
185
- const files = annotations.filter((a) => fileAnnotationPath(a) !== undefined);
186
- const fileGroups: Record<string, Annotation[]> = {};
187
- for (const ann of files) {
188
- const key = fileAnnotationPath(ann);
189
- if (!key) continue;
190
- const group = fileGroups[key];
191
- if (group) group.push(ann);
192
- else fileGroups[key] = [ann];
193
- }
194
- const hasMultipleGroups = [response, plan, files].filter((g) => g.length > 0).length > 1;
195
- return { response, plan, fileGroups, hasMultipleGroups };
229
+ const response = annotations.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH)
230
+ const plan = annotations.filter((a) => !a.filePath)
231
+ const files = annotations.filter((a) => fileAnnotationPath(a) !== undefined)
232
+ const fileGroups: Record<string, Annotation[]> = {}
233
+ for (const ann of files) {
234
+ const key = fileAnnotationPath(ann)
235
+ if (!key) continue
236
+ const group = fileGroups[key]
237
+ if (group) group.push(ann)
238
+ else fileGroups[key] = [ann]
239
+ }
240
+ const hasMultipleGroups = [response, plan, files].filter((g) => g.length > 0).length > 1
241
+ return { response, plan, fileGroups, hasMultipleGroups }
196
242
  }
197
243
 
198
244
  function GroupHeading({ children }: { children: React.ReactNode }) {
199
- return <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">{children}</div>;
245
+ return <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">{children}</div>
200
246
  }
201
247
 
202
248
  function CanvasAnnotationGroups({ annotations, readOnly, includedRemoteIds, onToggleRemoteId }: {
203
- annotations: Annotation[];
204
- readOnly?: boolean;
205
- includedRemoteIds?: Set<string>;
206
- onToggleRemoteId?: (id: string) => void;
249
+ annotations: Annotation[]
250
+ readOnly?: boolean
251
+ includedRemoteIds?: Set<string>
252
+ onToggleRemoteId?: (id: string) => void
207
253
  }) {
208
- const { setActiveView } = useContext(ActiveViewContext);
209
- const { response, plan, fileGroups, hasMultipleGroups } = groupAnnotations(annotations);
210
-
211
- // Selecting a file annotation means opening its file first; the element only
212
- // exists once that view is mounted.
213
- const onSelect = useCallback((ann: Annotation) => {
214
- const filePath = fileAnnotationPath(ann);
215
- if (!filePath) return;
216
- setActiveView({ type: "file", path: filePath });
217
- setTimeout(() => {
218
- const el = findAnnotationElement(ann);
219
- if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
220
- }, 150);
221
- }, [setActiveView]);
222
-
223
- const onReveal = useCallback((ann: Annotation) => scrollToAnnotation(ann, setActiveView), [setActiveView]);
224
- const listProps = { readOnly, onSelect, onReveal, includedRemoteIds, onToggleRemoteId };
225
-
226
- return (
227
- <>
228
- {response.length > 0 && (
229
- <>
230
- {hasMultipleGroups && <GroupHeading>Agent Response</GroupHeading>}
231
- <AnnotationList annotations={response} {...listProps} />
232
- </>
233
- )}
234
-
235
- {plan.length > 0 && (
236
- <>
237
- {hasMultipleGroups && <GroupHeading>Canvas</GroupHeading>}
238
- <AnnotationList annotations={plan} {...listProps} />
239
- </>
240
- )}
241
-
242
- {Object.entries(fileGroups).map(([filePath, anns]) => (
243
- <div key={filePath}>
244
- <button
245
- onClick={() => setActiveView({ type: "file", path: filePath })}
246
- 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"
247
- title={filePath}
248
- >
249
- <FileIcon name={filePath.split("/").pop() || filePath} type="file" />
250
- <span className="truncate">{filePath}</span>
251
- </button>
252
- <AnnotationList annotations={anns} {...listProps} />
253
- </div>
254
- ))}
255
- </>
256
- );
254
+ const { setActiveView } = useContext(ActiveViewContext)
255
+ const { response, plan, fileGroups, hasMultipleGroups } = groupAnnotations(annotations)
256
+
257
+ // Selecting a file annotation means opening its file first; the element only
258
+ // exists once that view is mounted.
259
+ const onSelect = useCallback((ann: Annotation) => {
260
+ const filePath = fileAnnotationPath(ann)
261
+ if (!filePath) return
262
+ setActiveView({ type: 'file', path: filePath })
263
+ setTimeout(() => {
264
+ const el = findAnnotationElement(ann)
265
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
266
+ }, 150)
267
+ }, [setActiveView])
268
+
269
+ const onReveal = useCallback((ann: Annotation) => scrollToAnnotation(ann, setActiveView), [setActiveView])
270
+ const listProps = { readOnly, onSelect, onReveal, includedRemoteIds, onToggleRemoteId }
271
+
272
+ return (
273
+ <>
274
+ {response.length > 0 && (
275
+ <>
276
+ {hasMultipleGroups && <GroupHeading>Agent Response</GroupHeading>}
277
+ <AnnotationList annotations={response} {...listProps} />
278
+ </>
279
+ )}
280
+
281
+ {plan.length > 0 && (
282
+ <>
283
+ {hasMultipleGroups && <GroupHeading>Canvas</GroupHeading>}
284
+ <AnnotationList annotations={plan} {...listProps} />
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
+ <AnnotationList annotations={anns} {...listProps} />
299
+ </div>
300
+ ))}
301
+ </>
302
+ )
257
303
  }
258
304
 
259
- function AnnotationSidebarInner({ onSubmit, agentWatching, collapseButton }: Omit<AnnotationSidebarProps, "onPreview"> & { agentWatching: boolean }) {
260
- const host = useCanvasHost();
261
- const [previewOpen, setPreviewOpen] = useState(false);
262
- const {
263
- annotations, generalNote,
264
- submittableResponses, feedbackEntries,
265
- } = useAnnotations();
266
-
267
- const remoteAnnotations = useMemo(() => annotations.filter((a) => a.source === "remote"), [annotations]);
268
- const [includedRemoteIds, setIncludedRemoteIds] = useState<Set<string>>(() => new Set());
269
-
270
- // Auto-include new remote annotations as they arrive
271
- useEffect(() => {
272
- if (remoteAnnotations.length === 0) return;
273
- setIncludedRemoteIds((prev) => {
274
- const next = new Set(prev);
275
- for (const a of remoteAnnotations) next.add(a.id);
276
- return next.size === prev.size ? prev : next;
277
- });
278
- }, [remoteAnnotations]);
279
-
280
- const toggleRemoteId = useCallback((id: string) => {
281
- setIncludedRemoteIds((prev) => {
282
- const next = new Set(prev);
283
- if (next.has(id)) next.delete(id); else next.add(id);
284
- return next;
285
- });
286
- }, []);
287
-
288
- const hasResponses = Array.from(submittableResponses.values()).some(hasValue);
289
- const hasFeedback = feedbackEntries.size > 0;
290
- const hasContent = annotations.length > 0 || generalNote.trim().length > 0 || hasResponses || hasFeedback;
291
- const [validationError, setValidationError] = useState<string | null>(null);
292
-
293
- const submitMarkdown = () => {
294
- const md = generateMarkdown(annotations, generalNote, submittableResponses, feedbackEntries, includedRemoteIds);
295
- onSubmit(md);
296
- };
297
-
298
- return (
299
- <div className="flex flex-col h-full">
300
- {/* Header */}
301
- <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
302
- <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body flex items-center gap-2">
303
- Annotations
304
- {annotations.length > 0 && (
305
- <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>
306
- )}
307
- {!host.isShared && (
308
- <span
309
- className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${agentWatching ? "bg-accent-green" : "bg-accent-amber"}`}
310
- title={agentWatching ? "Agent connected" : "Agent disconnected"}
311
- />
312
- )}
313
- </span>
314
- {collapseButton}
315
- </div>
316
-
317
- {/* Annotation list */}
318
- <div className="flex-1 overflow-y-auto">
319
- {annotations.length === 0 && (
320
- <p className="text-[12px] text-text-tertiary px-3 py-4 leading-relaxed font-body">
321
- Select text in the canvas or in files to add annotations.
322
- </p>
323
- )}
324
-
325
- <CanvasAnnotationGroups
326
- annotations={annotations}
327
- includedRemoteIds={includedRemoteIds}
328
- onToggleRemoteId={toggleRemoteId}
329
- />
330
- </div>
331
-
332
- <AnnotationDraftFooter
333
- hasContent={hasContent}
334
- onSubmit={() => {
335
- const allMissing = getMissingRequiredLabels(submittableResponses, feedbackEntries);
336
- if (allMissing.length > 0) {
337
- setValidationError(`Please answer: ${allMissing.join(", ")}`);
338
- return;
339
- }
340
- setValidationError(null);
341
- submitMarkdown();
342
- }}
343
- onSubmitEmpty={() => onSubmit("No feedback — looks good.")}
344
- notice={validationError && (
345
- <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">
346
- <span>{validationError}</span>
347
- <button
348
- onClick={() => { setValidationError(null); submitMarkdown(); }}
349
- className="text-[11px] text-text-tertiary hover:text-text-secondary font-body whitespace-nowrap underline"
350
- >
351
- Submit anyway
352
- </button>
353
- </div>
354
- )}
355
- secondaryAction={
356
- <button
357
- onClick={() => setPreviewOpen(true)}
358
- 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"
359
- >
360
- Preview
361
- </button>
362
- }
363
- />
364
-
365
- <ResponsePreview
366
- open={previewOpen}
367
- onClose={() => setPreviewOpen(false)}
368
- onSubmit={onSubmit}
369
- includedRemoteIds={includedRemoteIds}
370
- onToggleRemoteId={toggleRemoteId}
371
- />
372
- </div>
373
- );
305
+ function AnnotationSidebarInner({ onSubmit, agentWatching, collapseButton }: Omit<AnnotationSidebarProps, 'onPreview'> & { agentWatching: boolean }) {
306
+ const host = useCanvasHost()
307
+ const [previewOpen, setPreviewOpen] = useState(false)
308
+ const {
309
+ annotations,
310
+ generalNote,
311
+ submittableResponses,
312
+ feedbackEntries,
313
+ } = useAnnotations()
314
+
315
+ const remoteAnnotations = useMemo(() => annotations.filter((a) => a.source === 'remote'), [annotations])
316
+ const [includedRemoteIds, setIncludedRemoteIds] = useState<Set<string>>(() => new Set())
317
+
318
+ // Auto-include new remote annotations as they arrive
319
+ useEffect(() => {
320
+ if (remoteAnnotations.length === 0) return
321
+ setIncludedRemoteIds((prev) => {
322
+ const next = new Set(prev)
323
+ for (const a of remoteAnnotations) next.add(a.id)
324
+ return next.size === prev.size ? prev : next
325
+ })
326
+ }, [remoteAnnotations])
327
+
328
+ const toggleRemoteId = useCallback((id: string) => {
329
+ setIncludedRemoteIds((prev) => {
330
+ const next = new Set(prev)
331
+ if (next.has(id)) next.delete(id)
332
+ else next.add(id)
333
+ return next
334
+ })
335
+ }, [])
336
+
337
+ const hasResponses = Array.from(submittableResponses.values()).some(hasValue)
338
+ const hasFeedback = feedbackEntries.size > 0
339
+ const hasContent = annotations.length > 0 || generalNote.trim().length > 0 || hasResponses || hasFeedback
340
+ const [validationError, setValidationError] = useState<string | null>(null)
341
+
342
+ const submitMarkdown = () => {
343
+ const md = generateMarkdown(annotations, generalNote, submittableResponses, feedbackEntries, includedRemoteIds)
344
+ onSubmit(md)
345
+ }
346
+
347
+ return (
348
+ <div className="flex flex-col h-full">
349
+ {/* Header */}
350
+ <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
351
+ <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body flex items-center gap-2">
352
+ Annotations
353
+ {annotations.length > 0 && (
354
+ <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">
355
+ {annotations.length}
356
+ </span>
357
+ )}
358
+ {!host.isShared && (
359
+ <span
360
+ className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${agentWatching ? 'bg-accent-green' : 'bg-accent-amber'}`}
361
+ title={agentWatching ? 'Agent connected' : 'Agent disconnected'}
362
+ />
363
+ )}
364
+ </span>
365
+ {collapseButton}
366
+ </div>
367
+
368
+ {/* Annotation list */}
369
+ <div className="flex-1 overflow-y-auto">
370
+ {annotations.length === 0 && (
371
+ <p className="text-[12px] text-text-tertiary px-3 py-4 leading-relaxed font-body">
372
+ Select text in the canvas or in files to add annotations.
373
+ </p>
374
+ )}
375
+
376
+ <CanvasAnnotationGroups
377
+ annotations={annotations}
378
+ includedRemoteIds={includedRemoteIds}
379
+ onToggleRemoteId={toggleRemoteId}
380
+ />
381
+ </div>
382
+
383
+ <AnnotationDraftFooter
384
+ hasContent={hasContent}
385
+ onSubmit={() => {
386
+ const allMissing = getMissingRequiredLabels(submittableResponses, feedbackEntries)
387
+ if (allMissing.length > 0) {
388
+ setValidationError(`Please answer: ${allMissing.join(', ')}`)
389
+ return
390
+ }
391
+ setValidationError(null)
392
+ submitMarkdown()
393
+ }}
394
+ onSubmitEmpty={() => onSubmit('No feedback — looks good.')}
395
+ notice={validationError && (
396
+ <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">
397
+ <span>{validationError}</span>
398
+ <button
399
+ onClick={() => {
400
+ setValidationError(null)
401
+ submitMarkdown()
402
+ }}
403
+ className="text-[11px] text-text-tertiary hover:text-text-secondary font-body whitespace-nowrap underline"
404
+ >
405
+ Submit anyway
406
+ </button>
407
+ </div>
408
+ )}
409
+ secondaryAction={
410
+ <button
411
+ onClick={() => setPreviewOpen(true)}
412
+ 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"
413
+ >
414
+ Preview
415
+ </button>
416
+ }
417
+ />
418
+
419
+ <ResponsePreview
420
+ open={previewOpen}
421
+ onClose={() => setPreviewOpen(false)}
422
+ onSubmit={onSubmit}
423
+ includedRemoteIds={includedRemoteIds}
424
+ onToggleRemoteId={toggleRemoteId}
425
+ />
426
+ </div>
427
+ )
374
428
  }