@fabrika/canvas-kernel 0.1.44

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/annotate.ts +94 -0
  4. package/daemon.ts +46 -0
  5. package/index.ts +84 -0
  6. package/package.json +74 -0
  7. package/src/client/AnnotationDraftFooter.tsx +76 -0
  8. package/src/client/AnnotationEditor.tsx +320 -0
  9. package/src/client/AnnotationList.tsx +190 -0
  10. package/src/client/AnnotationProvider.tsx +294 -0
  11. package/src/client/AnnotationSidebar.tsx +374 -0
  12. package/src/client/FileIcon.tsx +76 -0
  13. package/src/client/PlanRenderer.tsx +587 -0
  14. package/src/client/Popover.tsx +361 -0
  15. package/src/client/RenderErrorContext.ts +11 -0
  16. package/src/client/ResponsePreview.tsx +186 -0
  17. package/src/client/SessionContext.tsx +3 -0
  18. package/src/client/annotationContext.ts +130 -0
  19. package/src/client/annotationDom.ts +115 -0
  20. package/src/client/annotationDraft.ts +130 -0
  21. package/src/client/annotationMarkdown.ts +102 -0
  22. package/src/client/annotationTarget.ts +73 -0
  23. package/src/client/appContext.tsx +61 -0
  24. package/src/client/blockTarget.ts +127 -0
  25. package/src/client/components/Callout.tsx +27 -0
  26. package/src/client/components/Checklist.tsx +37 -0
  27. package/src/client/components/Choice.tsx +178 -0
  28. package/src/client/components/CodeBlock.tsx +35 -0
  29. package/src/client/components/Diff.tsx +36 -0
  30. package/src/client/components/FilePreview.tsx +88 -0
  31. package/src/client/components/Image.tsx +48 -0
  32. package/src/client/components/Markdown.tsx +44 -0
  33. package/src/client/components/Mermaid.tsx +576 -0
  34. package/src/client/components/Note.tsx +14 -0
  35. package/src/client/components/Priority.tsx +19 -0
  36. package/src/client/components/ResponseNote.tsx +19 -0
  37. package/src/client/components/Section.tsx +33 -0
  38. package/src/client/components/Table.tsx +35 -0
  39. package/src/client/components/Task.tsx +76 -0
  40. package/src/client/components/UserInput.tsx +176 -0
  41. package/src/client/components/index.ts +18 -0
  42. package/src/client/components/renderMarkdownChildren.tsx +100 -0
  43. package/src/client/diffLines.ts +50 -0
  44. package/src/client/generateMarkdown.ts +250 -0
  45. package/src/client/highlightRange.ts +262 -0
  46. package/src/client/hostContext.tsx +55 -0
  47. package/src/client/index.ts +128 -0
  48. package/src/client/popoverPosition.ts +46 -0
  49. package/src/client/preact-all.ts +4 -0
  50. package/src/client/regionTarget.ts +180 -0
  51. package/src/client/runtime.ts +149 -0
  52. package/src/client/styles.css +492 -0
  53. package/src/client/testing/dom.ts +58 -0
  54. package/src/client/textTarget.ts +31 -0
  55. package/src/client/theme.css +66 -0
  56. package/src/client/useRegionAnnotation.tsx +188 -0
  57. package/src/client/useTextAnnotation.tsx +194 -0
  58. package/src/client/utils.ts +33 -0
  59. package/src/engine/channel.ts +278 -0
  60. package/src/engine/compiler.ts +415 -0
  61. package/src/engine/http-utils.ts +14 -0
  62. package/src/engine/lifecycle.ts +302 -0
  63. package/src/engine/paths.ts +63 -0
  64. package/src/engine/router.ts +16 -0
  65. package/src/engine/session.ts +678 -0
  66. package/src/engine/waiter.ts +188 -0
  67. package/src/engine/watcher.ts +84 -0
  68. package/src/engine/websocket.ts +159 -0
@@ -0,0 +1,294 @@
1
+ import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
2
+ import { AnnotationCtx } from "#canvas/runtime";
3
+ import type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue } from "#canvas/runtime";
4
+ import { annotationDraftKey, clearPersistedDraft, type AnnotationDraftPhase } from "./annotationDraft";
5
+ import { canPruneResponses, pruneStaleResponses } from "./generateMarkdown";
6
+ import { generateAnnotationId } from "./utils";
7
+
8
+ // Re-export types for convenience
9
+ export type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue };
10
+ export { useAnnotations } from "#canvas/runtime";
11
+
12
+ export interface PersistedState {
13
+ annotations: Annotation[];
14
+ generalNote: string;
15
+ responses: [string, PlanResponse][];
16
+ feedbackEntries?: [string, FeedbackEntry][];
17
+ }
18
+
19
+ interface AnnotationProviderProps {
20
+ sessionId: string;
21
+ revision: number;
22
+ isReadOnly: boolean;
23
+ draftPhase: AnnotationDraftPhase;
24
+ /** Remote annotations fetched from shared views. Merged read-only into
25
+ * the annotation list so they render alongside the author's own. */
26
+ remoteAnnotations?: Annotation[];
27
+ /** Canvas files this revision is made of. Supplying it enables pruning of
28
+ * answers to questions the revision no longer asks; omit it and every
29
+ * answer is submitted, which is what a host without canvas tabs wants. */
30
+ canvasFiles?: readonly string[];
31
+ /** Server-authoritative persistence. When supplied, the draft is loaded and
32
+ * saved through these instead of localStorage, so it survives reloads and
33
+ * follows the author across browsers. Omit for the localStorage default. */
34
+ loadState?: (sessionId: string, revision: number, phase: AnnotationDraftPhase) => Promise<PersistedState | null>;
35
+ saveState?: (sessionId: string, revision: number, state: PersistedState, phase: AnnotationDraftPhase) => void;
36
+ children: React.ReactNode;
37
+ }
38
+
39
+ function loadPersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase): PersistedState | null {
40
+ try {
41
+ const raw = localStorage.getItem(annotationDraftKey(sessionId, revision, phase));
42
+ if (!raw) return null;
43
+ return JSON.parse(raw);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function savePersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase, state: PersistedState) {
50
+ try {
51
+ localStorage.setItem(annotationDraftKey(sessionId, revision, phase), JSON.stringify(state));
52
+ } catch {}
53
+ }
54
+
55
+ /** What the reader has been shown for one draft. Stamped with the draft it
56
+ * belongs to rather than cleared in an effect: response controls register from
57
+ * their own effects, which run before the provider's, so an effect-based reset
58
+ * would wipe registrations that already happened. */
59
+ interface SeenState {
60
+ draft: string;
61
+ responseIds: Set<string>;
62
+ canvases: Set<string>;
63
+ }
64
+
65
+ function seenFor(draft: string): SeenState {
66
+ return { draft, responseIds: new Set(), canvases: new Set() };
67
+ }
68
+
69
+ export function AnnotationProvider({ sessionId, revision, isReadOnly, draftPhase, remoteAnnotations, canvasFiles, loadState, saveState, children }: AnnotationProviderProps) {
70
+ // Server-authoritative persistence loads asynchronously, so that mode starts
71
+ // empty and hydrates in an effect. localStorage mode seeds synchronously.
72
+ const serverMode = !!loadState;
73
+ const seed = serverMode ? null : loadPersisted(sessionId, revision, draftPhase);
74
+
75
+ const [localAnnotations, setAnnotations] = useState<Annotation[]>(() =>
76
+ (seed?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? "local" as const })),
77
+ );
78
+
79
+ // Merge local + remote annotations. Remote annotations are always
80
+ // rendered read-only; mutation helpers below operate on localAnnotations
81
+ // only so they silently no-op on remote ids.
82
+ const annotations = useMemo<Annotation[]>(() => {
83
+ if (!remoteAnnotations || remoteAnnotations.length === 0) return localAnnotations;
84
+ const localIds = new Set(localAnnotations.map((a) => a.id));
85
+ const filtered = remoteAnnotations.filter((a) => !localIds.has(a.id));
86
+ return [...localAnnotations, ...filtered.map((a) => ({ ...a, source: "remote" as const }))];
87
+ }, [localAnnotations, remoteAnnotations]);
88
+ const [generalNote, setGeneralNote] = useState(() => seed?.generalNote ?? "");
89
+ const [activeAnnotationId, setActiveAnnotationId] = useState<string | null>(null);
90
+ const [responses, setResponses] = useState<Map<string, PlanResponse>>(() =>
91
+ seed?.responses ? new Map(seed.responses) : new Map(),
92
+ );
93
+ const draftId = `${sessionId}:${revision}:${draftPhase}`;
94
+ const draftIdRef = useRef(draftId);
95
+ draftIdRef.current = draftId;
96
+ const [seen, setSeen] = useState<SeenState>(() => seenFor(draftId));
97
+ const [feedbackEntries, setFeedbackEntries] = useState<Map<string, FeedbackEntry>>(() =>
98
+ seed?.feedbackEntries ? new Map(seed.feedbackEntries) : new Map(),
99
+ );
100
+
101
+ // Re-hydrate when the draft identity changes. Hosts that remount the provider
102
+ // per revision (via a key) get this for free from the initializers, but hosts
103
+ // that keep it mounted need it here — otherwise one revision's answers leak
104
+ // into the next and the persist effect writes them over the new revision's
105
+ // saved draft. `hydrated` gates that effect so an async server load cannot be
106
+ // clobbered by the empty initial state.
107
+ const [hydrated, setHydrated] = useState(!serverMode);
108
+ useEffect(() => {
109
+ const apply = (saved: PersistedState | null) => {
110
+ setAnnotations((saved?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? "local" as const })));
111
+ setGeneralNote(saved?.generalNote ?? "");
112
+ setResponses(saved?.responses ? new Map(saved.responses) : new Map());
113
+ setFeedbackEntries(saved?.feedbackEntries ? new Map(saved.feedbackEntries) : new Map());
114
+ setActiveAnnotationId(null);
115
+ };
116
+ if (serverMode) {
117
+ let cancelled = false;
118
+ setHydrated(false);
119
+ loadState!(sessionId, revision, draftPhase).then((saved) => {
120
+ if (cancelled) return;
121
+ apply(saved);
122
+ setHydrated(true);
123
+ });
124
+ return () => { cancelled = true; };
125
+ }
126
+ apply(loadPersisted(sessionId, revision, draftPhase));
127
+ setHydrated(true);
128
+ return undefined;
129
+ }, [sessionId, revision, draftPhase, serverMode, loadState]);
130
+
131
+ // Post-feedback drafts save immediately so a new revision can carry the latest edit.
132
+ const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
+ useEffect(() => {
134
+ if (isReadOnly && draftPhase !== "next") return;
135
+ if (!hydrated) return;
136
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
137
+ const state: PersistedState = {
138
+ // Persist only local annotations — remote ones are re-fetched on load.
139
+ annotations: localAnnotations,
140
+ generalNote,
141
+ responses: Array.from(responses.entries()),
142
+ feedbackEntries: Array.from(feedbackEntries.entries()),
143
+ };
144
+ const persist = () => {
145
+ if (saveState) saveState(sessionId, revision, state, draftPhase);
146
+ else savePersisted(sessionId, revision, draftPhase, state);
147
+ };
148
+ if (draftPhase === "next") {
149
+ persist();
150
+ return;
151
+ }
152
+ persistTimerRef.current = setTimeout(persist, 300);
153
+ return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
154
+ }, [localAnnotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly, draftPhase, hydrated, saveState]);
155
+
156
+ const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => {
157
+ setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}), ...(canvasFile ? { canvasFile } : {}) }]);
158
+ }, []);
159
+
160
+ const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
161
+ addAnnotationWithId(generateAnnotationId(), snippet, note, filePath);
162
+ }, [addAnnotationWithId]);
163
+
164
+ const updateAnnotation = useCallback((id: string, note: string) => {
165
+ setAnnotations((prev) => prev.map((a) => (a.id === id ? { ...a, note } : a)));
166
+ }, []);
167
+
168
+ const removeAnnotation = useCallback((id: string) => {
169
+ removeMarksFromDom(id);
170
+ setAnnotations((prev) => prev.filter((a) => a.id !== id));
171
+ setActiveAnnotationId((prev) => (prev === id ? null : prev));
172
+ }, []);
173
+
174
+ const addAnnotationImage = useCallback((id: string, imagePath: string) => {
175
+ setAnnotations((prev) => prev.map((a) =>
176
+ a.id === id ? { ...a, images: [...(a.images || []), imagePath] } : a
177
+ ));
178
+ }, []);
179
+
180
+ const removeAnnotationImage = useCallback((id: string, imagePath: string) => {
181
+ setAnnotations((prev) => prev.map((a) =>
182
+ a.id === id ? { ...a, images: (a.images || []).filter((p) => p !== imagePath) } : a
183
+ ));
184
+ }, []);
185
+
186
+ const setResponse = useCallback((id: string, response: PlanResponse) => {
187
+ setResponses((prev) => {
188
+ const next = new Map(prev);
189
+ next.set(id, response);
190
+ return next;
191
+ });
192
+ }, []);
193
+
194
+ // Both registrations accumulate for the life of one draft and are idempotent,
195
+ // so re-running the caller's effect cannot loop.
196
+ const registerResponse = useCallback((id: string) => {
197
+ setSeen((prev) => {
198
+ const base = prev.draft === draftIdRef.current ? prev : seenFor(draftIdRef.current);
199
+ if (base.responseIds.has(id)) return base;
200
+ return { ...base, responseIds: new Set(base.responseIds).add(id) };
201
+ });
202
+ }, []);
203
+
204
+ const registerCanvasRendered = useCallback((filename: string) => {
205
+ setSeen((prev) => {
206
+ const base = prev.draft === draftIdRef.current ? prev : seenFor(draftIdRef.current);
207
+ if (base.canvases.has(filename)) return base;
208
+ return { ...base, canvases: new Set(base.canvases).add(filename) };
209
+ });
210
+ }, []);
211
+
212
+ // Answers survive until a canvas proves the question is gone. `seen` can lag
213
+ // the draft by a render when the host keeps the provider mounted across
214
+ // revisions, and stale evidence must never prune.
215
+ const current = seen.draft === draftId ? seen : null;
216
+ const submittableResponses = useMemo(() => {
217
+ if (!current || !canPruneResponses(canvasFiles, current.canvases)) return responses;
218
+ return pruneStaleResponses(responses, current.responseIds);
219
+ }, [current, canvasFiles, responses]);
220
+
221
+ const setFeedbackEntry = useCallback((id: string, entry: FeedbackEntry) => {
222
+ setFeedbackEntries((prev) => {
223
+ const existing = prev.get(id);
224
+ if (existing && existing.markdown === entry.markdown && existing.label === entry.label && existing.required === entry.required) {
225
+ return prev; // same reference → no re-render
226
+ }
227
+ const next = new Map(prev);
228
+ next.set(id, entry);
229
+ return next;
230
+ });
231
+ }, []);
232
+
233
+ const removeFeedbackEntry = useCallback((id: string) => {
234
+ setFeedbackEntries((prev) => {
235
+ if (!prev.has(id)) return prev;
236
+ const next = new Map(prev);
237
+ next.delete(id);
238
+ return next;
239
+ });
240
+ }, []);
241
+
242
+ const clearAll = useCallback(() => {
243
+ for (const mark of document.querySelectorAll("[data-annotation-id]")) {
244
+ unwrapMark(mark as HTMLElement);
245
+ }
246
+ setAnnotations([]);
247
+ setGeneralNote("");
248
+ setActiveAnnotationId(null);
249
+ setResponses(new Map());
250
+ setFeedbackEntries(new Map());
251
+ if (saveState) {
252
+ saveState(sessionId, revision, { annotations: [], generalNote: "", responses: [], feedbackEntries: [] }, draftPhase);
253
+ } else if (draftPhase === "next") {
254
+ localStorage.removeItem(annotationDraftKey(sessionId, revision, draftPhase));
255
+ } else {
256
+ clearPersistedDraft(localStorage, sessionId, revision);
257
+ }
258
+ }, [sessionId, revision, draftPhase, saveState]);
259
+
260
+ return (
261
+ <AnnotationCtx.Provider
262
+ value={{
263
+ annotations, addAnnotation, addAnnotationWithId, updateAnnotation, removeAnnotation, addAnnotationImage, removeAnnotationImage,
264
+ generalNote, setGeneralNote, clearAll,
265
+ activeAnnotationId, setActiveAnnotationId,
266
+ responses, setResponse, submittableResponses,
267
+ registerResponse, registerCanvasRendered,
268
+ feedbackEntries, setFeedbackEntry, removeFeedbackEntry,
269
+ isReadOnly,
270
+ }}
271
+ >
272
+ {children}
273
+ </AnnotationCtx.Provider>
274
+ );
275
+ }
276
+
277
+ function removeMarksFromDom(id: string) {
278
+ const marks = document.querySelectorAll(`[data-annotation-id="${id}"]`);
279
+ for (const mark of marks) unwrapMark(mark as HTMLElement);
280
+ }
281
+
282
+ function unwrapMark(mark: HTMLElement) {
283
+ const parent = mark.parentNode;
284
+ if (!parent) return;
285
+ // A region overlay wraps no text; swapping it for its text content would
286
+ // leave a stray empty node inside the image.
287
+ if (mark.tagName !== "MARK") {
288
+ parent.removeChild(mark);
289
+ return;
290
+ }
291
+ const text = document.createTextNode(mark.textContent || "");
292
+ parent.replaceChild(text, mark);
293
+ parent.normalize();
294
+ }
@@ -0,0 +1,374 @@
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";
15
+
16
+ /**
17
+ * The canvas host's sidebar: the reusable annotation list and draft footer,
18
+ * wrapped in the chrome only a canvas has — revisions, per-revision feedback,
19
+ * canvas files, agent responses, and the wait for the agent to come back.
20
+ */
21
+
22
+ interface AnnotationSidebarProps {
23
+ onSubmit: (feedback: string) => void;
24
+ collapseButton?: React.ReactNode;
25
+ }
26
+
27
+ 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} />;
40
+ }
41
+
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
+ );
60
+ }
61
+
62
+ 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
+ );
85
+ }
86
+
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
+ );
149
+ }
150
+
151
+ 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
+ );
178
+ }
179
+
180
+ /** How a canvas splits its annotations: the agent's response, the canvas
181
+ * itself, then one group per file the reader opened. */
182
+ 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 };
196
+ }
197
+
198
+ 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>;
200
+ }
201
+
202
+ function CanvasAnnotationGroups({ annotations, readOnly, includedRemoteIds, onToggleRemoteId }: {
203
+ annotations: Annotation[];
204
+ readOnly?: boolean;
205
+ includedRemoteIds?: Set<string>;
206
+ onToggleRemoteId?: (id: string) => void;
207
+ }) {
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
+ );
257
+ }
258
+
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
+ );
374
+ }