@fabrika/canvas-kernel 0.1.45 → 0.3.1

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