@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,361 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, useCallback, useMemo, useContext } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { getPopoverPosition, pxWidth } from "./popoverPosition";
|
|
4
|
+
import { AnnotationEditor, AttachButton } from "./AnnotationEditor";
|
|
5
|
+
import { SessionContext } from "#canvas/runtime";
|
|
6
|
+
import { useCanvasHost } from "./hostContext";
|
|
7
|
+
import { hostAcceptsUploads } from "./utils";
|
|
8
|
+
|
|
9
|
+
interface PopoverProps {
|
|
10
|
+
anchorEl: HTMLElement;
|
|
11
|
+
scrollContainer?: HTMLElement | null;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
/** Selectors that should not trigger outside-click close */
|
|
14
|
+
ignoreSelector?: string;
|
|
15
|
+
zIndex?: string;
|
|
16
|
+
padding?: string;
|
|
17
|
+
width?: string;
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Popover({ anchorEl, scrollContainer, onClose, ignoreSelector, zIndex = "50", padding = "12px", width = "280px", children }: PopoverProps) {
|
|
22
|
+
const popRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const onCloseRef = useRef(onClose);
|
|
24
|
+
onCloseRef.current = onClose;
|
|
25
|
+
// Placed by the width it is about to be rendered at, or it overflows the edge
|
|
26
|
+
// it was supposed to be clamped away from.
|
|
27
|
+
const { style: posStyle, parent } = getPopoverPosition(anchorEl, scrollContainer, pxWidth(width));
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
const handler = (e: MouseEvent) => {
|
|
32
|
+
if (popRef.current && !popRef.current.contains(e.target as Node)) {
|
|
33
|
+
if (ignoreSelector && (e.target as HTMLElement).closest?.(ignoreSelector)) return;
|
|
34
|
+
onCloseRef.current();
|
|
35
|
+
document.removeEventListener("mousedown", handler);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
document.addEventListener("mousedown", handler);
|
|
39
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
40
|
+
}, 0);
|
|
41
|
+
return () => clearTimeout(timer);
|
|
42
|
+
}, [ignoreSelector]);
|
|
43
|
+
|
|
44
|
+
return createPortal(
|
|
45
|
+
<div
|
|
46
|
+
ref={popRef}
|
|
47
|
+
style={{
|
|
48
|
+
...posStyle,
|
|
49
|
+
zIndex,
|
|
50
|
+
width,
|
|
51
|
+
background: "var(--color-bg-elevated)",
|
|
52
|
+
border: "1px solid var(--color-border-hover)",
|
|
53
|
+
borderRadius: "8px",
|
|
54
|
+
boxShadow: "0 4px 12px var(--color-shadow)",
|
|
55
|
+
padding,
|
|
56
|
+
fontFamily: "'Inter', sans-serif",
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{children}
|
|
60
|
+
</div>,
|
|
61
|
+
parent,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ── Create/Edit annotation popover ────────────────────────── */
|
|
66
|
+
|
|
67
|
+
interface AnnotationPopoverProps {
|
|
68
|
+
anchorEl: HTMLElement;
|
|
69
|
+
scrollContainer?: HTMLElement | null;
|
|
70
|
+
snippet: string;
|
|
71
|
+
/** Max length for displayed snippet (default 100) */
|
|
72
|
+
truncateAt?: number;
|
|
73
|
+
/** Custom header instead of the default quoted snippet */
|
|
74
|
+
header?: React.ReactNode;
|
|
75
|
+
/** Initial note text (for edit mode) */
|
|
76
|
+
initialNote?: string;
|
|
77
|
+
/** Initial images (for edit mode) */
|
|
78
|
+
initialImages?: string[];
|
|
79
|
+
/** Called on confirm — passes note and images */
|
|
80
|
+
onConfirm: (note: string, images: string[]) => void;
|
|
81
|
+
/** Called on cancel / close without saving */
|
|
82
|
+
onCancel: () => void;
|
|
83
|
+
/** Called when annotation should be deleted (edit mode) */
|
|
84
|
+
onDelete?: () => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const SUGGESTION_GROUPS: { label: string; items: string[] }[] = [
|
|
88
|
+
{ label: "Clarity", items: ["Unclear", "Why?", "Needs example", "Missing context"] },
|
|
89
|
+
{ label: "Scope", items: ["Overkill", "Out of scope", "Important", "Risky"] },
|
|
90
|
+
{ label: "Action", items: ["Simplify", "Rethink", "Expand on this", "Skip this"] },
|
|
91
|
+
];
|
|
92
|
+
const ALL_SUGGESTIONS = SUGGESTION_GROUPS.flatMap((g) => g.items);
|
|
93
|
+
|
|
94
|
+
export function AnnotationPopover({
|
|
95
|
+
anchorEl, scrollContainer, snippet, truncateAt = 100, header,
|
|
96
|
+
initialNote = "", initialImages, onConfirm, onCancel, onDelete,
|
|
97
|
+
}: AnnotationPopoverProps) {
|
|
98
|
+
const sessionId = useContext(SessionContext);
|
|
99
|
+
// The footer draws the editor's attach button itself, so it owes the same
|
|
100
|
+
// check the editor makes: no upload endpoint, no button.
|
|
101
|
+
const host = useCanvasHost();
|
|
102
|
+
const canAttach = hostAcceptsUploads(host);
|
|
103
|
+
const isEditMode = initialNote !== "" || !!onDelete;
|
|
104
|
+
const [note, setNote] = useState(initialNote);
|
|
105
|
+
const [images, setImages] = useState<string[]>(initialImages || []);
|
|
106
|
+
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
|
|
107
|
+
const [suppressed, setSuppressed] = useState(isEditMode);
|
|
108
|
+
const [deleteHover, setDeleteHover] = useState(false);
|
|
109
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
110
|
+
const openFilePickerRef = useRef<(() => void) | null>(null);
|
|
111
|
+
|
|
112
|
+
const submit = useCallback(() => {
|
|
113
|
+
if (note.trim() || images.length > 0) onConfirm(note.trim(), images);
|
|
114
|
+
else if (isEditMode && onDelete) onDelete();
|
|
115
|
+
else onCancel();
|
|
116
|
+
}, [note, images, onConfirm, onCancel, isEditMode, onDelete]);
|
|
117
|
+
|
|
118
|
+
const handleClose = useCallback(() => {
|
|
119
|
+
if (isEditMode) {
|
|
120
|
+
if (!note.trim() && images.length === 0 && onDelete) { onDelete(); onCancel(); return; }
|
|
121
|
+
onConfirm(note, images);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
onCancel();
|
|
125
|
+
}, [note, images, isEditMode, onConfirm, onCancel, onDelete]);
|
|
126
|
+
|
|
127
|
+
// Suggestions (only in create mode)
|
|
128
|
+
const showSuggestions = !isEditMode;
|
|
129
|
+
const matches = note.trim().length > 0
|
|
130
|
+
? ALL_SUGGESTIONS.filter((s) => s.toLowerCase().startsWith(note.trim().toLowerCase()))
|
|
131
|
+
: ALL_SUGGESTIONS;
|
|
132
|
+
const filtered = !showSuggestions || suppressed ? [] : matches.length === 1 && matches[0]?.toLowerCase() === note.trim().toLowerCase() ? [] : matches;
|
|
133
|
+
const matchSet = new Set(filtered);
|
|
134
|
+
|
|
135
|
+
const filteredGroups = SUGGESTION_GROUPS
|
|
136
|
+
.map((g) => ({ label: g.label, items: g.items.filter((s) => matchSet.has(s)) }))
|
|
137
|
+
.filter((g) => g.items.length > 0);
|
|
138
|
+
|
|
139
|
+
const colItems = filteredGroups.map((g) => g.items);
|
|
140
|
+
/** All the arrow-key math indexes columns; out of range simply means empty. */
|
|
141
|
+
const colLength = (c: number) => colItems[c]?.length ?? 0;
|
|
142
|
+
const flatFiltered = colItems.flat();
|
|
143
|
+
|
|
144
|
+
const selectedCol = useMemo(() => {
|
|
145
|
+
if (selectedIdx === null) return 0;
|
|
146
|
+
let count = 0;
|
|
147
|
+
for (let c = 0; c < colItems.length; c++) {
|
|
148
|
+
if (selectedIdx < count + colLength(c)) return c;
|
|
149
|
+
count += colLength(c);
|
|
150
|
+
}
|
|
151
|
+
return 0;
|
|
152
|
+
}, [selectedIdx, colItems]);
|
|
153
|
+
const selectedRow = useMemo(() => {
|
|
154
|
+
if (selectedIdx === null) return 0;
|
|
155
|
+
let count = 0;
|
|
156
|
+
for (let c = 0; c < selectedCol; c++) count += colLength(c);
|
|
157
|
+
return selectedIdx - count;
|
|
158
|
+
}, [selectedIdx, selectedCol, colItems]);
|
|
159
|
+
|
|
160
|
+
const colRowToFlat = (col: number, row: number) => {
|
|
161
|
+
let idx = 0;
|
|
162
|
+
for (let c = 0; c < col; c++) idx += colLength(c);
|
|
163
|
+
return idx + row;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const [prevNote, setPrevNote] = useState(note);
|
|
167
|
+
if (note !== prevNote) {
|
|
168
|
+
setPrevNote(note);
|
|
169
|
+
setSelectedIdx(null);
|
|
170
|
+
setSuppressed(isEditMode);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const applySuggestion = useCallback((s: string) => {
|
|
174
|
+
setNote(s);
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
const truncated = snippet.length > truncateAt ? snippet.slice(0, truncateAt) + "..." : snippet;
|
|
178
|
+
|
|
179
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
180
|
+
if (flatFiltered.length > 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
if (selectedIdx === null) { setSelectedIdx(0); return; }
|
|
183
|
+
if (e.key === "ArrowDown") {
|
|
184
|
+
const newRow = Math.min(selectedRow + 1, colLength(selectedCol) - 1);
|
|
185
|
+
setSelectedIdx(colRowToFlat(selectedCol, newRow));
|
|
186
|
+
} else if (e.key === "ArrowUp") {
|
|
187
|
+
const newRow = Math.max(selectedRow - 1, 0);
|
|
188
|
+
setSelectedIdx(colRowToFlat(selectedCol, newRow));
|
|
189
|
+
} else if (e.key === "ArrowRight") {
|
|
190
|
+
const newCol = Math.min(selectedCol + 1, colItems.length - 1);
|
|
191
|
+
const newRow = Math.min(selectedRow, colLength(newCol) - 1);
|
|
192
|
+
setSelectedIdx(colRowToFlat(newCol, newRow));
|
|
193
|
+
} else if (e.key === "ArrowLeft") {
|
|
194
|
+
const newCol = Math.max(selectedCol - 1, 0);
|
|
195
|
+
const newRow = Math.min(selectedRow, colLength(newCol) - 1);
|
|
196
|
+
setSelectedIdx(colRowToFlat(newCol, newRow));
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const selected = selectedIdx === null ? undefined : flatFiltered[selectedIdx];
|
|
201
|
+
if (selected !== undefined) {
|
|
202
|
+
if (e.key === "Tab") { e.preventDefault(); applySuggestion(selected); return; }
|
|
203
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onConfirm(selected, images); return; }
|
|
204
|
+
}
|
|
205
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
|
|
206
|
+
if (e.key === "Escape") {
|
|
207
|
+
if (flatFiltered.length > 0 && !suppressed) { setSuppressed(true); }
|
|
208
|
+
else if (isEditMode) { handleClose(); }
|
|
209
|
+
else { onCancel(); }
|
|
210
|
+
}
|
|
211
|
+
}, [flatFiltered, selectedIdx, selectedRow, selectedCol, colItems, colRowToFlat, applySuggestion, onConfirm, images, submit, suppressed, isEditMode, handleClose, onCancel]);
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<Popover
|
|
215
|
+
anchorEl={anchorEl}
|
|
216
|
+
scrollContainer={scrollContainer}
|
|
217
|
+
onClose={isEditMode ? handleClose : onCancel}
|
|
218
|
+
ignoreSelector={isEditMode ? "[data-annotation-id]" : undefined}
|
|
219
|
+
zIndex={isEditMode ? "60" : "50"}
|
|
220
|
+
padding={isEditMode ? "10px 12px" : "12px"}
|
|
221
|
+
width="420px"
|
|
222
|
+
>
|
|
223
|
+
{!isEditMode && (header ?? (
|
|
224
|
+
<div style={{ fontSize: "11px", color: "var(--color-text-tertiary)", fontStyle: "italic", marginBottom: "8px", lineHeight: "1.4", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>
|
|
225
|
+
“{truncated}”
|
|
226
|
+
</div>
|
|
227
|
+
))}
|
|
228
|
+
|
|
229
|
+
<AnnotationEditor
|
|
230
|
+
note={note}
|
|
231
|
+
onNoteChange={setNote}
|
|
232
|
+
images={images}
|
|
233
|
+
onAddImage={(path) => setImages((prev) => [...prev, path])}
|
|
234
|
+
onRemoveImage={(path) => setImages((prev) => prev.filter((p) => p !== path))}
|
|
235
|
+
sessionId={sessionId}
|
|
236
|
+
autoFocus
|
|
237
|
+
autoResize={isEditMode}
|
|
238
|
+
textareaRef={textareaRef}
|
|
239
|
+
onKeyDown={handleKeyDown}
|
|
240
|
+
attachButton="none"
|
|
241
|
+
openFilePickerRef={openFilePickerRef}
|
|
242
|
+
textareaStyle={isEditMode ? {
|
|
243
|
+
width: "100%", background: "transparent", border: "none",
|
|
244
|
+
color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif",
|
|
245
|
+
fontSize: "13px", lineHeight: "1.5", resize: "none", outline: "none",
|
|
246
|
+
overflow: "hidden",
|
|
247
|
+
} : {
|
|
248
|
+
width: "100%", minHeight: "60px", background: "transparent", border: "none",
|
|
249
|
+
color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif",
|
|
250
|
+
fontSize: "13px", lineHeight: "1.5", resize: "vertical", outline: "none",
|
|
251
|
+
}}
|
|
252
|
+
placeholder="Add your note..."
|
|
253
|
+
/>
|
|
254
|
+
|
|
255
|
+
{/* Action buttons */}
|
|
256
|
+
{/* Action buttons */}
|
|
257
|
+
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginTop: isEditMode ? "6px" : "8px" }}>
|
|
258
|
+
{canAttach && <AttachButton onClick={() => openFilePickerRef.current?.()} size={14} />}
|
|
259
|
+
<div style={{ flex: 1 }} />
|
|
260
|
+
{isEditMode ? (
|
|
261
|
+
<button
|
|
262
|
+
onClick={() => { if (onDelete) onDelete(); onCancel(); }}
|
|
263
|
+
onMouseEnter={() => setDeleteHover(true)}
|
|
264
|
+
onMouseLeave={() => setDeleteHover(false)}
|
|
265
|
+
style={{ fontSize: "11px", color: deleteHover ? "var(--color-accent-red)" : "var(--color-text-tertiary)", background: "none", border: "none", cursor: "pointer", padding: "2px 0", fontFamily: "'Inter', sans-serif" }}
|
|
266
|
+
>Delete</button>
|
|
267
|
+
) : (
|
|
268
|
+
<>
|
|
269
|
+
<button onClick={onCancel} style={{ fontSize: "11px", color: "var(--color-text-tertiary)", background: "none", border: "none", cursor: "pointer", padding: "4px 12px", fontFamily: "'Inter', sans-serif" }}>Cancel</button>
|
|
270
|
+
<button onClick={submit} style={{ fontSize: "11px", fontWeight: 500, padding: "4px 12px", borderRadius: "6px", background: "var(--color-highlight-bg)", color: "var(--color-text-primary)", border: "1px solid var(--color-highlight-border)", cursor: "pointer", fontFamily: "'Inter', sans-serif" }}>Add</button>
|
|
271
|
+
</>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Suggestions (create mode only) */}
|
|
276
|
+
{flatFiltered.length > 0 && (
|
|
277
|
+
<div style={{ marginTop: "6px", borderTop: "1px solid var(--color-border-subtle)", paddingTop: "8px", display: "flex", gap: "4px" }}>
|
|
278
|
+
{filteredGroups.map((group) => (
|
|
279
|
+
<div key={group.label} style={{ flex: 1 }}>
|
|
280
|
+
<div style={{ fontSize: "10px", fontWeight: 600, color: "var(--color-text-disabled)", textTransform: "uppercase", letterSpacing: "0.05em", padding: "2px 6px", marginBottom: "2px", fontFamily: "'Inter', sans-serif" }}>
|
|
281
|
+
{group.label}
|
|
282
|
+
</div>
|
|
283
|
+
{group.items.map((s) => {
|
|
284
|
+
const flatIdx = flatFiltered.indexOf(s);
|
|
285
|
+
return (
|
|
286
|
+
<div
|
|
287
|
+
key={s}
|
|
288
|
+
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); applySuggestion(s); }}
|
|
289
|
+
onMouseEnter={() => setSelectedIdx(flatIdx)}
|
|
290
|
+
style={{
|
|
291
|
+
padding: "3px 6px",
|
|
292
|
+
fontSize: "12px",
|
|
293
|
+
borderRadius: "4px",
|
|
294
|
+
fontFamily: "'Inter', sans-serif",
|
|
295
|
+
color: flatIdx === selectedIdx ? "var(--color-text-primary)" : "var(--color-text-secondary)",
|
|
296
|
+
background: flatIdx === selectedIdx ? "var(--color-highlight-selected)" : "transparent",
|
|
297
|
+
cursor: "pointer",
|
|
298
|
+
}}
|
|
299
|
+
>
|
|
300
|
+
<span style={{ fontWeight: 500 }}>{s.slice(0, note.trim().length)}</span>{s.slice(note.trim().length)}
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
})}
|
|
304
|
+
</div>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
</Popover>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* ── Legacy exports for backwards compatibility ───────────── */
|
|
313
|
+
|
|
314
|
+
interface AnnotationCreatePopoverProps {
|
|
315
|
+
anchorEl: HTMLElement;
|
|
316
|
+
scrollContainer?: HTMLElement | null;
|
|
317
|
+
snippet: string;
|
|
318
|
+
truncateAt?: number;
|
|
319
|
+
header?: React.ReactNode;
|
|
320
|
+
onAdd: (note: string, images?: string[]) => void;
|
|
321
|
+
onCancel: () => void;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function AnnotationCreatePopover({ anchorEl, scrollContainer, snippet, truncateAt, header, onAdd, onCancel }: AnnotationCreatePopoverProps) {
|
|
325
|
+
return (
|
|
326
|
+
<AnnotationPopover
|
|
327
|
+
anchorEl={anchorEl}
|
|
328
|
+
scrollContainer={scrollContainer}
|
|
329
|
+
snippet={snippet}
|
|
330
|
+
truncateAt={truncateAt}
|
|
331
|
+
header={header}
|
|
332
|
+
onConfirm={(note, images) => onAdd(note, images)}
|
|
333
|
+
onCancel={onCancel}
|
|
334
|
+
/>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
interface AnnotationEditPopoverProps {
|
|
339
|
+
anchorEl: HTMLElement;
|
|
340
|
+
scrollContainer?: HTMLElement | null;
|
|
341
|
+
initialNote: string;
|
|
342
|
+
initialImages?: string[];
|
|
343
|
+
onUpdate: (note: string, images: string[]) => void;
|
|
344
|
+
onDelete: () => void;
|
|
345
|
+
onClose: () => void;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function AnnotationEditPopover({ anchorEl, scrollContainer, initialNote, initialImages, onUpdate, onDelete, onClose }: AnnotationEditPopoverProps) {
|
|
349
|
+
return (
|
|
350
|
+
<AnnotationPopover
|
|
351
|
+
anchorEl={anchorEl}
|
|
352
|
+
scrollContainer={scrollContainer}
|
|
353
|
+
snippet=""
|
|
354
|
+
initialNote={initialNote}
|
|
355
|
+
initialImages={initialImages}
|
|
356
|
+
onConfirm={(note, images) => { onUpdate(note, images); onClose(); }}
|
|
357
|
+
onDelete={onDelete}
|
|
358
|
+
onCancel={onClose}
|
|
359
|
+
/>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
|
|
3
|
+
export interface CanvasRenderError {
|
|
4
|
+
revision: number;
|
|
5
|
+
filename: string;
|
|
6
|
+
message: string;
|
|
7
|
+
stack?: string;
|
|
8
|
+
componentStack?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const RenderErrorContext = createContext<(error: CanvasRenderError) => void>(() => {});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React, { useEffect, useState, useRef, useMemo } from "react";
|
|
2
|
+
import { marked } from "marked";
|
|
3
|
+
import { useAnnotations } from "./AnnotationProvider";
|
|
4
|
+
import { generateMarkdown, getMissingRequiredLabels } from "./generateMarkdown";
|
|
5
|
+
|
|
6
|
+
interface ResponsePreviewProps {
|
|
7
|
+
open: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onSubmit: (feedback: string) => void;
|
|
10
|
+
includedRemoteIds: Set<string>;
|
|
11
|
+
onToggleRemoteId: (id: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ResponsePreview({ open, onClose, onSubmit, includedRemoteIds, onToggleRemoteId }: ResponsePreviewProps) {
|
|
15
|
+
const { annotations, generalNote, submittableResponses, feedbackEntries } = useAnnotations();
|
|
16
|
+
const [editedText, setEditedText] = useState("");
|
|
17
|
+
const [manuallyEdited, setManuallyEdited] = useState(false);
|
|
18
|
+
const [editMode, setEditMode] = useState(false);
|
|
19
|
+
const [validationError, setValidationError] = useState<string | null>(null);
|
|
20
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
21
|
+
const [prevOpen, setPrevOpen] = useState(false);
|
|
22
|
+
|
|
23
|
+
const remoteAnnotations = useMemo(
|
|
24
|
+
() => annotations.filter((a) => a.source === "remote"),
|
|
25
|
+
[annotations],
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (open && !prevOpen) {
|
|
29
|
+
setManuallyEdited(false);
|
|
30
|
+
setEditMode(false);
|
|
31
|
+
setValidationError(null);
|
|
32
|
+
}
|
|
33
|
+
if (!open && prevOpen) {
|
|
34
|
+
setManuallyEdited(false);
|
|
35
|
+
setEditMode(false);
|
|
36
|
+
setValidationError(null);
|
|
37
|
+
}
|
|
38
|
+
if (open !== prevOpen) {
|
|
39
|
+
setPrevOpen(open);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const generatedText = useMemo(
|
|
43
|
+
() => generateMarkdown(annotations, generalNote, submittableResponses, feedbackEntries, includedRemoteIds),
|
|
44
|
+
[annotations, generalNote, submittableResponses, feedbackEntries, includedRemoteIds],
|
|
45
|
+
);
|
|
46
|
+
const text = manuallyEdited ? editedText : generatedText;
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (open && editMode && textareaRef.current) {
|
|
50
|
+
textareaRef.current.focus();
|
|
51
|
+
}
|
|
52
|
+
}, [open, editMode]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open) return;
|
|
56
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
57
|
+
if (e.key === "Escape") onClose();
|
|
58
|
+
};
|
|
59
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
60
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
61
|
+
}, [open, onClose]);
|
|
62
|
+
|
|
63
|
+
if (!open) return null;
|
|
64
|
+
|
|
65
|
+
const hasContent = text.trim().length > 0;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-8" onClick={onClose}>
|
|
69
|
+
<div className="bg-bg-surface border border-border-medium rounded-xl shadow-lg w-full max-w-2xl flex flex-col" style={{ height: "80vh" }} onClick={(e) => e.stopPropagation()}>
|
|
70
|
+
{/* Header */}
|
|
71
|
+
<div className="flex items-center justify-between px-5 py-3 border-b border-border-subtle flex-shrink-0">
|
|
72
|
+
<div className="flex items-center gap-3">
|
|
73
|
+
<span className="text-[13px] font-medium text-text-primary font-body">Response preview</span>
|
|
74
|
+
{manuallyEdited && (
|
|
75
|
+
<button onClick={() => { setManuallyEdited(false); setEditMode(false); }} className="text-[11px] text-text-tertiary hover:text-text-secondary font-body">
|
|
76
|
+
Reset
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex items-center gap-2">
|
|
81
|
+
{/* Mode toggle */}
|
|
82
|
+
<div className="flex items-center bg-bg-base rounded-md p-0.5">
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => setEditMode(false)}
|
|
85
|
+
className={`px-2.5 py-1 rounded text-[11px] font-body font-medium transition-colors ${
|
|
86
|
+
!editMode ? "bg-bg-elevated text-text-primary shadow-sm" : "text-text-tertiary hover:text-text-secondary"
|
|
87
|
+
}`}
|
|
88
|
+
>
|
|
89
|
+
Preview
|
|
90
|
+
</button>
|
|
91
|
+
<button
|
|
92
|
+
onClick={() => setEditMode(true)}
|
|
93
|
+
className={`px-2.5 py-1 rounded text-[11px] font-body font-medium transition-colors ${
|
|
94
|
+
editMode ? "bg-bg-elevated text-text-primary shadow-sm" : "text-text-tertiary hover:text-text-secondary"
|
|
95
|
+
}`}
|
|
96
|
+
>
|
|
97
|
+
Edit
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
<button onClick={onClose} className="text-text-tertiary hover:text-text-secondary w-7 h-7 flex items-center justify-center rounded-md hover:bg-border-subtle transition-colors">
|
|
101
|
+
✕
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{/* Content */}
|
|
107
|
+
{editMode ? (
|
|
108
|
+
<textarea
|
|
109
|
+
ref={textareaRef}
|
|
110
|
+
value={text}
|
|
111
|
+
onChange={(e) => { setEditedText(e.currentTarget.value); setManuallyEdited(true); }}
|
|
112
|
+
className="flex-1 w-full bg-bg-base p-5 text-[13px] font-mono text-text-code resize-none focus:outline-none leading-relaxed placeholder:text-text-disabled"
|
|
113
|
+
placeholder="Your feedback will appear here..."
|
|
114
|
+
/>
|
|
115
|
+
) : (
|
|
116
|
+
<div className="flex-1 overflow-y-auto p-5">
|
|
117
|
+
<MarkdownPreview text={text} />
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{/* Remote feedback summary */}
|
|
122
|
+
{remoteAnnotations.length > 0 && (
|
|
123
|
+
<div className="px-5 py-2 border-t border-border-subtle flex-shrink-0">
|
|
124
|
+
<span className="text-[12px] text-text-secondary font-body">
|
|
125
|
+
Reviewer feedback: {includedRemoteIds.size}/{remoteAnnotations.length} included
|
|
126
|
+
</span>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{/* Actions */}
|
|
131
|
+
{validationError && (
|
|
132
|
+
<div className="px-5 py-2 text-[12px] text-accent-red font-body border-t border-border-subtle flex-shrink-0 flex items-center justify-between gap-2">
|
|
133
|
+
<span>{validationError}</span>
|
|
134
|
+
<button
|
|
135
|
+
onClick={() => {
|
|
136
|
+
setValidationError(null);
|
|
137
|
+
onSubmit(text);
|
|
138
|
+
}}
|
|
139
|
+
className="text-[11px] text-text-tertiary hover:text-text-secondary font-body whitespace-nowrap underline"
|
|
140
|
+
>
|
|
141
|
+
Submit anyway
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
<div className="flex justify-end gap-2 px-5 py-3 border-t border-border-subtle flex-shrink-0">
|
|
146
|
+
<button
|
|
147
|
+
onClick={onClose}
|
|
148
|
+
className="px-4 py-2 rounded-lg text-[13px] font-body text-text-tertiary hover:text-text-secondary transition-colors"
|
|
149
|
+
>
|
|
150
|
+
Cancel
|
|
151
|
+
</button>
|
|
152
|
+
<button
|
|
153
|
+
onClick={() => {
|
|
154
|
+
const allMissing = getMissingRequiredLabels(submittableResponses, feedbackEntries);
|
|
155
|
+
if (allMissing.length > 0) {
|
|
156
|
+
setValidationError(`Please answer: ${allMissing.join(", ")}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
setValidationError(null);
|
|
160
|
+
onSubmit(text);
|
|
161
|
+
}}
|
|
162
|
+
disabled={!hasContent}
|
|
163
|
+
className={`px-6 py-2 rounded-lg font-body text-[13px] font-medium transition-all ${
|
|
164
|
+
hasContent
|
|
165
|
+
? "bg-btn-primary text-btn-primary-text hover:opacity-90"
|
|
166
|
+
: "bg-bg-input text-text-disabled cursor-default"
|
|
167
|
+
}`}
|
|
168
|
+
>
|
|
169
|
+
Submit feedback
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const UPLOADS_PATH_RE = /!\[([^\]]*)\]\((\/tmp\/agent-canvas\/uploads\/([^)]+))\)/g;
|
|
178
|
+
|
|
179
|
+
function rewriteUploadPaths(md: string): string {
|
|
180
|
+
return md.replace(UPLOADS_PATH_RE, (_match, alt, _fullPath, filename) => ``);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function MarkdownPreview({ text }: { text: string }) {
|
|
184
|
+
const html = useMemo(() => marked.parse(rewriteUploadPaths(text), { async: false }) as string, [text]);
|
|
185
|
+
return <div className="prose-canvas font-body text-body text-text-primary leading-relaxed" dangerouslySetInnerHTML={{ __html: html }} />;
|
|
186
|
+
}
|