@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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/annotate.ts +94 -0
- package/daemon.ts +46 -0
- package/index.ts +84 -0
- package/package.json +74 -0
- package/src/client/AnnotationDraftFooter.tsx +76 -0
- package/src/client/AnnotationEditor.tsx +320 -0
- package/src/client/AnnotationList.tsx +190 -0
- package/src/client/AnnotationProvider.tsx +294 -0
- package/src/client/AnnotationSidebar.tsx +374 -0
- package/src/client/FileIcon.tsx +76 -0
- package/src/client/PlanRenderer.tsx +587 -0
- package/src/client/Popover.tsx +361 -0
- package/src/client/RenderErrorContext.ts +11 -0
- package/src/client/ResponsePreview.tsx +186 -0
- package/src/client/SessionContext.tsx +3 -0
- package/src/client/annotationContext.ts +130 -0
- package/src/client/annotationDom.ts +115 -0
- package/src/client/annotationDraft.ts +130 -0
- package/src/client/annotationMarkdown.ts +102 -0
- package/src/client/annotationTarget.ts +73 -0
- package/src/client/appContext.tsx +61 -0
- package/src/client/blockTarget.ts +127 -0
- package/src/client/components/Callout.tsx +27 -0
- package/src/client/components/Checklist.tsx +37 -0
- package/src/client/components/Choice.tsx +178 -0
- package/src/client/components/CodeBlock.tsx +35 -0
- package/src/client/components/Diff.tsx +36 -0
- package/src/client/components/FilePreview.tsx +88 -0
- package/src/client/components/Image.tsx +48 -0
- package/src/client/components/Markdown.tsx +44 -0
- package/src/client/components/Mermaid.tsx +576 -0
- package/src/client/components/Note.tsx +14 -0
- package/src/client/components/Priority.tsx +19 -0
- package/src/client/components/ResponseNote.tsx +19 -0
- package/src/client/components/Section.tsx +33 -0
- package/src/client/components/Table.tsx +35 -0
- package/src/client/components/Task.tsx +76 -0
- package/src/client/components/UserInput.tsx +176 -0
- package/src/client/components/index.ts +18 -0
- package/src/client/components/renderMarkdownChildren.tsx +100 -0
- package/src/client/diffLines.ts +50 -0
- package/src/client/generateMarkdown.ts +250 -0
- package/src/client/highlightRange.ts +262 -0
- package/src/client/hostContext.tsx +55 -0
- package/src/client/index.ts +128 -0
- package/src/client/popoverPosition.ts +46 -0
- package/src/client/preact-all.ts +4 -0
- package/src/client/regionTarget.ts +180 -0
- package/src/client/runtime.ts +149 -0
- package/src/client/styles.css +492 -0
- package/src/client/testing/dom.ts +58 -0
- package/src/client/textTarget.ts +31 -0
- package/src/client/theme.css +66 -0
- package/src/client/useRegionAnnotation.tsx +188 -0
- package/src/client/useTextAnnotation.tsx +194 -0
- package/src/client/utils.ts +33 -0
- package/src/engine/channel.ts +278 -0
- package/src/engine/compiler.ts +415 -0
- package/src/engine/http-utils.ts +14 -0
- package/src/engine/lifecycle.ts +302 -0
- package/src/engine/paths.ts +63 -0
- package/src/engine/router.ts +16 -0
- package/src/engine/session.ts +678 -0
- package/src/engine/waiter.ts +188 -0
- package/src/engine/watcher.ts +84 -0
- 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
|
+
}
|