agent-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agent-canvas.js +14 -0
- package/bin/agent-canvas.ts +377 -0
- package/daemon/build.ts +185 -0
- package/daemon/client/AnnotationProvider.tsx +185 -0
- package/daemon/client/AnnotationSidebar.tsx +314 -0
- package/daemon/client/App.tsx +611 -0
- package/daemon/client/CompareView.tsx +333 -0
- package/daemon/client/FileBrowser.tsx +188 -0
- package/daemon/client/FileIcon.tsx +76 -0
- package/daemon/client/FileViewer.tsx +207 -0
- package/daemon/client/PlanRenderer.tsx +665 -0
- package/daemon/client/Popover.tsx +285 -0
- package/daemon/client/ResponsePreview.tsx +260 -0
- package/daemon/client/RevisionSelect.tsx +97 -0
- package/daemon/client/SessionContext.tsx +3 -0
- package/daemon/client/SessionSwitcher.tsx +72 -0
- package/daemon/client/annotationContext.ts +114 -0
- package/daemon/client/components/Callout.tsx +25 -0
- package/daemon/client/components/Checklist.tsx +37 -0
- package/daemon/client/components/Choice.tsx +164 -0
- package/daemon/client/components/CodeBlock.tsx +35 -0
- package/daemon/client/components/Diff.tsx +65 -0
- package/daemon/client/components/FilePreview.tsx +88 -0
- package/daemon/client/components/Image.tsx +45 -0
- package/daemon/client/components/Mermaid.tsx +277 -0
- package/daemon/client/components/Note.tsx +13 -0
- package/daemon/client/components/Priority.tsx +19 -0
- package/daemon/client/components/ResponseNote.tsx +24 -0
- package/daemon/client/components/Section.tsx +32 -0
- package/daemon/client/components/Table.tsx +35 -0
- package/daemon/client/components/Task.tsx +75 -0
- package/daemon/client/components/UserInput.tsx +180 -0
- package/daemon/client/components/index.ts +15 -0
- package/daemon/client/domDiff.ts +336 -0
- package/daemon/client/exportMarkdown.ts +267 -0
- package/daemon/client/generateMarkdown.ts +194 -0
- package/daemon/client/highlightRange.ts +233 -0
- package/daemon/client/index.html +26 -0
- package/daemon/client/lcs.ts +19 -0
- package/daemon/client/popoverPosition.ts +31 -0
- package/daemon/client/runtime.ts +89 -0
- package/daemon/client/styles.css +306 -0
- package/daemon/client/theme.css +66 -0
- package/daemon/client/unifiedDiff.ts +604 -0
- package/daemon/dist/client.css +2 -0
- package/daemon/dist/client.js +68 -0
- package/daemon/dist/components.js +5 -0
- package/daemon/dist/index.html +108 -0
- package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
- package/daemon/dist/jsx-runtime-shim.js +17 -0
- package/daemon/dist/react-dom-client-shim.js +4 -0
- package/daemon/dist/react-dom-shim.js +4 -0
- package/daemon/dist/react-shim.js +4 -0
- package/daemon/dist/runtime.js +1 -0
- package/daemon/dist/theme.css +66 -0
- package/daemon/src/compiler.ts +92 -0
- package/daemon/src/paths.ts +22 -0
- package/daemon/src/server.ts +448 -0
- package/daemon/src/session.ts +274 -0
- package/daemon/src/watcher.ts +59 -0
- package/hooks/session-start.sh +11 -0
- package/package.json +39 -0
- package/skills/canvas/SKILL.md +174 -0
- package/skills/canvas/components.md +349 -0
- package/skills/canvas/flows.md +406 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { getPopoverPosition } from "./popoverPosition";
|
|
4
|
+
|
|
5
|
+
interface PopoverProps {
|
|
6
|
+
anchorEl: HTMLElement;
|
|
7
|
+
scrollContainer?: HTMLElement | null;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
/** Selectors that should not trigger outside-click close */
|
|
10
|
+
ignoreSelector?: string;
|
|
11
|
+
zIndex?: string;
|
|
12
|
+
padding?: string;
|
|
13
|
+
width?: string;
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Popover({ anchorEl, scrollContainer, onClose, ignoreSelector, zIndex = "50", padding = "12px", width = "280px", children }: PopoverProps) {
|
|
18
|
+
const popRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
const onCloseRef = useRef(onClose);
|
|
20
|
+
onCloseRef.current = onClose;
|
|
21
|
+
const { style: posStyle, parent } = getPopoverPosition(anchorEl, scrollContainer);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
const handler = (e: MouseEvent) => {
|
|
26
|
+
if (popRef.current && !popRef.current.contains(e.target as Node)) {
|
|
27
|
+
if (ignoreSelector && (e.target as HTMLElement).closest?.(ignoreSelector)) return;
|
|
28
|
+
onCloseRef.current();
|
|
29
|
+
document.removeEventListener("mousedown", handler);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
document.addEventListener("mousedown", handler);
|
|
33
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
34
|
+
}, 0);
|
|
35
|
+
return () => clearTimeout(timer);
|
|
36
|
+
}, [ignoreSelector]);
|
|
37
|
+
|
|
38
|
+
return createPortal(
|
|
39
|
+
<div
|
|
40
|
+
ref={popRef}
|
|
41
|
+
style={{
|
|
42
|
+
...posStyle,
|
|
43
|
+
zIndex,
|
|
44
|
+
width,
|
|
45
|
+
background: "var(--color-bg-elevated)",
|
|
46
|
+
border: "1px solid var(--color-border-hover)",
|
|
47
|
+
borderRadius: "8px",
|
|
48
|
+
boxShadow: "0 4px 12px var(--color-shadow)",
|
|
49
|
+
padding,
|
|
50
|
+
fontFamily: "'Inter', sans-serif",
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</div>,
|
|
55
|
+
parent,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ── Create annotation popover ─────────────────────────────── */
|
|
60
|
+
|
|
61
|
+
interface AnnotationCreatePopoverProps {
|
|
62
|
+
anchorEl: HTMLElement;
|
|
63
|
+
scrollContainer?: HTMLElement | null;
|
|
64
|
+
snippet: string;
|
|
65
|
+
/** Max length for displayed snippet (default 100) */
|
|
66
|
+
truncateAt?: number;
|
|
67
|
+
/** Custom header instead of the default quoted snippet */
|
|
68
|
+
header?: React.ReactNode;
|
|
69
|
+
onAdd: (note: string) => void;
|
|
70
|
+
onCancel: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SUGGESTION_GROUPS: { label: string; items: string[] }[] = [
|
|
74
|
+
{ label: "Clarity", items: ["Unclear", "Why?", "Needs example", "Missing context"] },
|
|
75
|
+
{ label: "Scope", items: ["Overkill", "Out of scope", "Important", "Risky"] },
|
|
76
|
+
{ label: "Action", items: ["Simplify", "Rethink", "Expand on this", "Skip this"] },
|
|
77
|
+
];
|
|
78
|
+
const ALL_SUGGESTIONS = SUGGESTION_GROUPS.flatMap((g) => g.items);
|
|
79
|
+
|
|
80
|
+
export function AnnotationCreatePopover({ anchorEl, scrollContainer, snippet, truncateAt = 100, header, onAdd, onCancel }: AnnotationCreatePopoverProps) {
|
|
81
|
+
const [note, setNote] = useState("");
|
|
82
|
+
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
|
|
83
|
+
const [suppressed, setSuppressed] = useState(false);
|
|
84
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
85
|
+
|
|
86
|
+
useEffect(() => { textareaRef.current?.focus(); }, []);
|
|
87
|
+
|
|
88
|
+
const submit = useCallback(() => {
|
|
89
|
+
if (note.trim()) onAdd(note.trim());
|
|
90
|
+
else onCancel();
|
|
91
|
+
}, [note, onAdd, onCancel]);
|
|
92
|
+
|
|
93
|
+
const matches = note.trim().length > 0
|
|
94
|
+
? ALL_SUGGESTIONS.filter((s) => s.toLowerCase().startsWith(note.trim().toLowerCase()))
|
|
95
|
+
: ALL_SUGGESTIONS;
|
|
96
|
+
// Don't show if the input already exactly matches a suggestion, or if suppressed
|
|
97
|
+
const filtered = suppressed ? [] : matches.length === 1 && matches[0].toLowerCase() === note.trim().toLowerCase() ? [] : matches;
|
|
98
|
+
const matchSet = new Set(filtered);
|
|
99
|
+
|
|
100
|
+
// Build filtered groups for grouped display
|
|
101
|
+
const filteredGroups = SUGGESTION_GROUPS
|
|
102
|
+
.map((g) => ({ label: g.label, items: g.items.filter((s) => matchSet.has(s)) }))
|
|
103
|
+
.filter((g) => g.items.length > 0);
|
|
104
|
+
|
|
105
|
+
// Each group = one column
|
|
106
|
+
const colItems = filteredGroups.map((g) => g.items);
|
|
107
|
+
const flatFiltered = colItems.flat();
|
|
108
|
+
|
|
109
|
+
// Navigation state as [col, row]
|
|
110
|
+
const selectedCol = useMemo(() => {
|
|
111
|
+
if (selectedIdx === null) return 0;
|
|
112
|
+
let count = 0;
|
|
113
|
+
for (let c = 0; c < colItems.length; c++) {
|
|
114
|
+
if (selectedIdx < count + colItems[c].length) return c;
|
|
115
|
+
count += colItems[c].length;
|
|
116
|
+
}
|
|
117
|
+
return 0;
|
|
118
|
+
}, [selectedIdx, colItems]);
|
|
119
|
+
const selectedRow = useMemo(() => {
|
|
120
|
+
if (selectedIdx === null) return 0;
|
|
121
|
+
let count = 0;
|
|
122
|
+
for (let c = 0; c < selectedCol; c++) count += colItems[c].length;
|
|
123
|
+
return selectedIdx - count;
|
|
124
|
+
}, [selectedIdx, selectedCol, colItems]);
|
|
125
|
+
|
|
126
|
+
const colRowToFlat = (col: number, row: number) => {
|
|
127
|
+
let idx = 0;
|
|
128
|
+
for (let c = 0; c < col; c++) idx += colItems[c].length;
|
|
129
|
+
return idx + row;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Reset selection and unsuppress when input changes
|
|
133
|
+
useEffect(() => { setSelectedIdx(null); setSuppressed(false); }, [note]);
|
|
134
|
+
|
|
135
|
+
const applySuggestion = useCallback((s: string) => {
|
|
136
|
+
setNote(s);
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
const truncated = snippet.length > truncateAt ? snippet.slice(0, truncateAt) + "..." : snippet;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<Popover anchorEl={anchorEl} scrollContainer={scrollContainer} onClose={onCancel} width="420px">
|
|
143
|
+
{header ?? (
|
|
144
|
+
<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" }}>
|
|
145
|
+
“{truncated}”
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
<textarea
|
|
149
|
+
ref={textareaRef}
|
|
150
|
+
value={note}
|
|
151
|
+
onChange={(e) => setNote(e.target.value)}
|
|
152
|
+
onKeyDown={(e) => {
|
|
153
|
+
if (flatFiltered.length > 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
if (selectedIdx === null) {
|
|
156
|
+
setSelectedIdx(0);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (e.key === "ArrowDown") {
|
|
160
|
+
const newRow = Math.min(selectedRow + 1, colItems[selectedCol].length - 1);
|
|
161
|
+
setSelectedIdx(colRowToFlat(selectedCol, newRow));
|
|
162
|
+
} else if (e.key === "ArrowUp") {
|
|
163
|
+
const newRow = Math.max(selectedRow - 1, 0);
|
|
164
|
+
setSelectedIdx(colRowToFlat(selectedCol, newRow));
|
|
165
|
+
} else if (e.key === "ArrowRight") {
|
|
166
|
+
const newCol = Math.min(selectedCol + 1, colItems.length - 1);
|
|
167
|
+
const newRow = Math.min(selectedRow, colItems[newCol].length - 1);
|
|
168
|
+
setSelectedIdx(colRowToFlat(newCol, newRow));
|
|
169
|
+
} else if (e.key === "ArrowLeft") {
|
|
170
|
+
const newCol = Math.max(selectedCol - 1, 0);
|
|
171
|
+
const newRow = Math.min(selectedRow, colItems[newCol].length - 1);
|
|
172
|
+
setSelectedIdx(colRowToFlat(newCol, newRow));
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (selectedIdx !== null && flatFiltered.length > 0) {
|
|
177
|
+
if (e.key === "Tab") { e.preventDefault(); applySuggestion(flatFiltered[selectedIdx]); return; }
|
|
178
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onAdd(flatFiltered[selectedIdx]); return; }
|
|
179
|
+
}
|
|
180
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
|
|
181
|
+
if (e.key === "Escape") { if (flatFiltered.length > 0 && !suppressed) { setSuppressed(true); } else { onCancel(); } }
|
|
182
|
+
}}
|
|
183
|
+
style={{ width: "100%", minHeight: "60px", background: "transparent", border: "none", color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif", fontSize: "13px", lineHeight: "1.5", resize: "vertical", outline: "none" }}
|
|
184
|
+
placeholder="Add your note..."
|
|
185
|
+
/>
|
|
186
|
+
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "8px" }}>
|
|
187
|
+
<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>
|
|
188
|
+
<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>
|
|
189
|
+
</div>
|
|
190
|
+
{flatFiltered.length > 0 && (
|
|
191
|
+
<div style={{ marginTop: "6px", borderTop: "1px solid var(--color-border-subtle)", paddingTop: "8px", display: "flex", gap: "4px" }}>
|
|
192
|
+
{filteredGroups.map((group) => (
|
|
193
|
+
<div key={group.label} style={{ flex: 1 }}>
|
|
194
|
+
<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" }}>
|
|
195
|
+
{group.label}
|
|
196
|
+
</div>
|
|
197
|
+
{group.items.map((s) => {
|
|
198
|
+
const flatIdx = flatFiltered.indexOf(s);
|
|
199
|
+
return (
|
|
200
|
+
<div
|
|
201
|
+
key={s}
|
|
202
|
+
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); applySuggestion(s); }}
|
|
203
|
+
onMouseEnter={() => setSelectedIdx(flatIdx)}
|
|
204
|
+
style={{
|
|
205
|
+
padding: "3px 6px",
|
|
206
|
+
fontSize: "12px",
|
|
207
|
+
borderRadius: "4px",
|
|
208
|
+
fontFamily: "'Inter', sans-serif",
|
|
209
|
+
color: flatIdx === selectedIdx ? "var(--color-text-primary)" : "var(--color-text-secondary)",
|
|
210
|
+
background: flatIdx === selectedIdx ? "var(--color-highlight-selected)" : "transparent",
|
|
211
|
+
cursor: "pointer",
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
<span style={{ fontWeight: 500 }}>{s.slice(0, note.trim().length)}</span>{s.slice(note.trim().length)}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
})}
|
|
218
|
+
</div>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
</Popover>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* ── Edit annotation popover ───────────────────────────────── */
|
|
227
|
+
|
|
228
|
+
interface AnnotationEditPopoverProps {
|
|
229
|
+
anchorEl: HTMLElement;
|
|
230
|
+
scrollContainer?: HTMLElement | null;
|
|
231
|
+
initialNote: string;
|
|
232
|
+
onUpdate: (note: string) => void;
|
|
233
|
+
onDelete: () => void;
|
|
234
|
+
onClose: () => void;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function AnnotationEditPopover({ anchorEl, scrollContainer, initialNote, onUpdate, onDelete, onClose }: AnnotationEditPopoverProps) {
|
|
238
|
+
const [note, setNote] = useState(initialNote);
|
|
239
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
240
|
+
const [deleteHover, setDeleteHover] = useState(false);
|
|
241
|
+
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
const ta = textareaRef.current;
|
|
244
|
+
if (ta) {
|
|
245
|
+
ta.style.height = "auto";
|
|
246
|
+
ta.style.height = ta.scrollHeight + "px";
|
|
247
|
+
ta.focus();
|
|
248
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
249
|
+
}
|
|
250
|
+
}, []);
|
|
251
|
+
|
|
252
|
+
const handleClose = useCallback(() => {
|
|
253
|
+
if (!note.trim()) { onDelete(); onClose(); return; }
|
|
254
|
+
onUpdate(note);
|
|
255
|
+
onClose();
|
|
256
|
+
}, [note, onUpdate, onDelete, onClose]);
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<Popover anchorEl={anchorEl} scrollContainer={scrollContainer} onClose={handleClose} ignoreSelector="[data-annotation-id]" zIndex="60" padding="10px 12px">
|
|
260
|
+
<textarea
|
|
261
|
+
ref={textareaRef}
|
|
262
|
+
value={note}
|
|
263
|
+
onChange={(e) => {
|
|
264
|
+
setNote(e.target.value);
|
|
265
|
+
onUpdate(e.target.value);
|
|
266
|
+
e.target.style.height = "auto";
|
|
267
|
+
e.target.style.height = e.target.scrollHeight + "px";
|
|
268
|
+
}}
|
|
269
|
+
onKeyDown={(e) => {
|
|
270
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleClose(); }
|
|
271
|
+
if (e.key === "Escape") handleClose();
|
|
272
|
+
}}
|
|
273
|
+
style={{ width: "100%", background: "transparent", border: "none", color: "var(--color-text-primary)", fontFamily: "'Inter', sans-serif", fontSize: "13px", lineHeight: "1.5", resize: "none", outline: "none", overflow: "hidden" }}
|
|
274
|
+
/>
|
|
275
|
+
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "6px" }}>
|
|
276
|
+
<button
|
|
277
|
+
onClick={() => { onDelete(); onClose(); }}
|
|
278
|
+
onMouseEnter={() => setDeleteHover(true)}
|
|
279
|
+
onMouseLeave={() => setDeleteHover(false)}
|
|
280
|
+
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" }}
|
|
281
|
+
>Delete</button>
|
|
282
|
+
</div>
|
|
283
|
+
</Popover>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import React, { useEffect, useState, useRef, useMemo } from "react";
|
|
2
|
+
import { useAnnotations } from "./AnnotationProvider";
|
|
3
|
+
import { generateMarkdown, getMissingRequired, getMissingRequiredFeedback } from "./generateMarkdown";
|
|
4
|
+
|
|
5
|
+
interface ResponsePreviewProps {
|
|
6
|
+
open: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
onSubmit: (feedback: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ResponsePreview({ open, onClose, onSubmit }: ResponsePreviewProps) {
|
|
12
|
+
const { annotations, generalNote, responses, feedbackEntries } = useAnnotations();
|
|
13
|
+
const [text, setText] = useState("");
|
|
14
|
+
const [manuallyEdited, setManuallyEdited] = useState(false);
|
|
15
|
+
const [editMode, setEditMode] = useState(false);
|
|
16
|
+
const [validationError, setValidationError] = useState<string | null>(null);
|
|
17
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!open) { setManuallyEdited(false); setEditMode(false); setValidationError(null); return; }
|
|
21
|
+
if (manuallyEdited) return;
|
|
22
|
+
setText(generateMarkdown(annotations, generalNote, responses, feedbackEntries));
|
|
23
|
+
}, [annotations, generalNote, responses, feedbackEntries, manuallyEdited, open]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (open && editMode && textareaRef.current) {
|
|
27
|
+
textareaRef.current.focus();
|
|
28
|
+
}
|
|
29
|
+
}, [open, editMode]);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) return;
|
|
33
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
34
|
+
if (e.key === "Escape") onClose();
|
|
35
|
+
};
|
|
36
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
37
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
38
|
+
}, [open, onClose]);
|
|
39
|
+
|
|
40
|
+
if (!open) return null;
|
|
41
|
+
|
|
42
|
+
const hasContent = text.trim().length > 0;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-8" onClick={onClose}>
|
|
46
|
+
<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()}>
|
|
47
|
+
{/* Header */}
|
|
48
|
+
<div className="flex items-center justify-between px-5 py-3 border-b border-border-subtle flex-shrink-0">
|
|
49
|
+
<div className="flex items-center gap-3">
|
|
50
|
+
<span className="text-[13px] font-medium text-text-primary font-body">Response preview</span>
|
|
51
|
+
{manuallyEdited && (
|
|
52
|
+
<button onClick={() => { setManuallyEdited(false); setEditMode(false); }} className="text-[11px] text-text-tertiary hover:text-text-secondary font-body">
|
|
53
|
+
Reset
|
|
54
|
+
</button>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
<div className="flex items-center gap-2">
|
|
58
|
+
{/* Mode toggle */}
|
|
59
|
+
<div className="flex items-center bg-bg-base rounded-md p-0.5">
|
|
60
|
+
<button
|
|
61
|
+
onClick={() => setEditMode(false)}
|
|
62
|
+
className={`px-2.5 py-1 rounded text-[11px] font-body font-medium transition-colors ${
|
|
63
|
+
!editMode ? "bg-bg-elevated text-text-primary shadow-sm" : "text-text-tertiary hover:text-text-secondary"
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
Preview
|
|
67
|
+
</button>
|
|
68
|
+
<button
|
|
69
|
+
onClick={() => setEditMode(true)}
|
|
70
|
+
className={`px-2.5 py-1 rounded text-[11px] font-body font-medium transition-colors ${
|
|
71
|
+
editMode ? "bg-bg-elevated text-text-primary shadow-sm" : "text-text-tertiary hover:text-text-secondary"
|
|
72
|
+
}`}
|
|
73
|
+
>
|
|
74
|
+
Edit
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
<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">
|
|
78
|
+
✕
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Content */}
|
|
84
|
+
{editMode ? (
|
|
85
|
+
<textarea
|
|
86
|
+
ref={textareaRef}
|
|
87
|
+
value={text}
|
|
88
|
+
onChange={(e) => { setText(e.target.value); setManuallyEdited(true); }}
|
|
89
|
+
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"
|
|
90
|
+
placeholder="Your feedback will appear here..."
|
|
91
|
+
/>
|
|
92
|
+
) : (
|
|
93
|
+
<div className="flex-1 overflow-y-auto p-5">
|
|
94
|
+
<MarkdownPreview text={text} />
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{/* Actions */}
|
|
99
|
+
{validationError && (
|
|
100
|
+
<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">
|
|
101
|
+
<span>{validationError}</span>
|
|
102
|
+
<button
|
|
103
|
+
onClick={() => {
|
|
104
|
+
setValidationError(null);
|
|
105
|
+
onSubmit(text);
|
|
106
|
+
}}
|
|
107
|
+
className="text-[11px] text-text-tertiary hover:text-text-secondary font-body whitespace-nowrap underline"
|
|
108
|
+
>
|
|
109
|
+
Submit anyway
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
<div className="flex justify-end gap-2 px-5 py-3 border-t border-border-subtle flex-shrink-0">
|
|
114
|
+
<button
|
|
115
|
+
onClick={onClose}
|
|
116
|
+
className="px-4 py-2 rounded-lg text-[13px] font-body text-text-tertiary hover:text-text-secondary transition-colors"
|
|
117
|
+
>
|
|
118
|
+
Cancel
|
|
119
|
+
</button>
|
|
120
|
+
<button
|
|
121
|
+
onClick={() => {
|
|
122
|
+
const missingResponses = getMissingRequired(responses);
|
|
123
|
+
const missingFeedback = getMissingRequiredFeedback(feedbackEntries);
|
|
124
|
+
const allMissing = [
|
|
125
|
+
...missingResponses.map((r) => r.label),
|
|
126
|
+
...missingFeedback.map((e) => e.label || e.id),
|
|
127
|
+
];
|
|
128
|
+
if (allMissing.length > 0) {
|
|
129
|
+
setValidationError(`Please answer: ${allMissing.join(", ")}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
setValidationError(null);
|
|
133
|
+
onSubmit(text);
|
|
134
|
+
}}
|
|
135
|
+
disabled={!hasContent}
|
|
136
|
+
className={`px-6 py-2 rounded-lg font-body text-[13px] font-medium transition-all ${
|
|
137
|
+
hasContent
|
|
138
|
+
? "bg-btn-primary text-btn-primary-text hover:opacity-90"
|
|
139
|
+
: "bg-bg-input text-text-disabled cursor-default"
|
|
140
|
+
}`}
|
|
141
|
+
>
|
|
142
|
+
Submit feedback
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function MarkdownPreview({ text }: { text: string }) {
|
|
151
|
+
const elements = useMemo(() => parseMarkdown(text), [text]);
|
|
152
|
+
return <div className="font-body text-body text-text-primary leading-relaxed">{elements}</div>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function InlineText({ text }: { text: string }) {
|
|
156
|
+
const parts: React.ReactNode[] = [];
|
|
157
|
+
const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g;
|
|
158
|
+
let last = 0;
|
|
159
|
+
let match: RegExpExecArray | null;
|
|
160
|
+
let key = 0;
|
|
161
|
+
|
|
162
|
+
while ((match = regex.exec(text)) !== null) {
|
|
163
|
+
if (match.index > last) parts.push(text.slice(last, match.index));
|
|
164
|
+
if (match[2]) parts.push(<strong key={key++} className="font-semibold text-text-primary">{match[2]}</strong>);
|
|
165
|
+
else if (match[3]) parts.push(<code key={key++} className="font-mono text-code bg-bg-code px-1 py-px rounded">{match[3]}</code>);
|
|
166
|
+
last = regex.lastIndex;
|
|
167
|
+
}
|
|
168
|
+
if (last < text.length) parts.push(text.slice(last));
|
|
169
|
+
return <>{parts}</>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseMarkdown(md: string): React.ReactNode[] {
|
|
173
|
+
const lines = md.split("\n");
|
|
174
|
+
const elements: React.ReactNode[] = [];
|
|
175
|
+
let i = 0;
|
|
176
|
+
let key = 0;
|
|
177
|
+
|
|
178
|
+
while (i < lines.length) {
|
|
179
|
+
const line = lines[i];
|
|
180
|
+
|
|
181
|
+
// Headings
|
|
182
|
+
if (line.startsWith("#### ")) {
|
|
183
|
+
elements.push(<h4 key={key++} className="text-[13px] font-body font-semibold text-text-primary mt-5 mb-1">{line.slice(5)}</h4>);
|
|
184
|
+
i++; continue;
|
|
185
|
+
}
|
|
186
|
+
if (line.startsWith("### ")) {
|
|
187
|
+
elements.push(<h3 key={key++} className="text-[14px] font-body font-semibold text-text-primary mt-5 mb-1">{line.slice(4)}</h3>);
|
|
188
|
+
i++; continue;
|
|
189
|
+
}
|
|
190
|
+
if (line.startsWith("## ")) {
|
|
191
|
+
elements.push(<h2 key={key++} className="text-task-label font-body font-semibold text-text-primary mt-6 mb-2 pb-1 border-b border-border-subtle">{line.slice(3)}</h2>);
|
|
192
|
+
i++; continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Blockquote — collect consecutive > lines
|
|
196
|
+
if (line.startsWith("> ")) {
|
|
197
|
+
const quoteLines: string[] = [];
|
|
198
|
+
while (i < lines.length && lines[i].startsWith("> ")) {
|
|
199
|
+
quoteLines.push(lines[i].slice(2));
|
|
200
|
+
i++;
|
|
201
|
+
}
|
|
202
|
+
elements.push(
|
|
203
|
+
<blockquote key={key++} className="border-l-2 border-border-hover pl-3 my-2 text-text-secondary italic text-[13px]">
|
|
204
|
+
{quoteLines.map((ql, j) => <div key={j}>{ql}</div>)}
|
|
205
|
+
</blockquote>
|
|
206
|
+
);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Checkbox list — collect consecutive - [x]/- [ ] lines
|
|
211
|
+
if (line.startsWith("- [x] ") || line.startsWith("- [ ] ")) {
|
|
212
|
+
const items: { checked: boolean; label: string }[] = [];
|
|
213
|
+
while (i < lines.length && (lines[i].startsWith("- [x] ") || lines[i].startsWith("- [ ] "))) {
|
|
214
|
+
items.push({ checked: lines[i].startsWith("- [x] "), label: lines[i].slice(6) });
|
|
215
|
+
i++;
|
|
216
|
+
}
|
|
217
|
+
elements.push(
|
|
218
|
+
<ul key={key++} className="my-1 space-y-0.5">
|
|
219
|
+
{items.map((item, j) => (
|
|
220
|
+
<li key={j} className="flex items-center gap-2 text-[13px]">
|
|
221
|
+
<span className={`w-3.5 h-3.5 rounded flex items-center justify-center flex-shrink-0 ${item.checked ? "bg-accent-amber" : "border border-border-hover"}`}>
|
|
222
|
+
{item.checked && (
|
|
223
|
+
<svg width="8" height="8" viewBox="0 0 10 10" fill="none">
|
|
224
|
+
<path d="M2.5 5L4.5 7L7.5 3" style={{ stroke: "var(--color-text-inverse)" }} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
225
|
+
</svg>
|
|
226
|
+
)}
|
|
227
|
+
</span>
|
|
228
|
+
<span className={item.checked ? "text-text-primary" : "text-text-tertiary"}>{item.label}</span>
|
|
229
|
+
</li>
|
|
230
|
+
))}
|
|
231
|
+
</ul>
|
|
232
|
+
);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Regular list — collect consecutive - lines
|
|
237
|
+
if (line.startsWith("- ")) {
|
|
238
|
+
const items: string[] = [];
|
|
239
|
+
while (i < lines.length && lines[i].startsWith("- ")) {
|
|
240
|
+
items.push(lines[i].slice(2));
|
|
241
|
+
i++;
|
|
242
|
+
}
|
|
243
|
+
elements.push(
|
|
244
|
+
<ul key={key++} className="my-1 space-y-0.5 list-disc list-inside">
|
|
245
|
+
{items.map((item, j) => <li key={j} className="text-[13px] text-text-secondary"><InlineText text={item} /></li>)}
|
|
246
|
+
</ul>
|
|
247
|
+
);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Empty line
|
|
252
|
+
if (line.trim() === "") { i++; continue; }
|
|
253
|
+
|
|
254
|
+
// Paragraph
|
|
255
|
+
elements.push(<p key={key++} className="my-1.5 text-[13px]"><InlineText text={line} /></p>);
|
|
256
|
+
i++;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return elements;
|
|
260
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import React, { useEffect, useState, useRef, useContext } from "react";
|
|
2
|
+
import { RevisionContext, RevisionInfo } from "./App";
|
|
3
|
+
|
|
4
|
+
/** Format a revision for the trigger button: label or "Round N" */
|
|
5
|
+
function revisionButtonLabel(r: RevisionInfo) {
|
|
6
|
+
return r.label || `Round ${r.revision}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Format relative time from ISO string */
|
|
10
|
+
function relativeTime(iso: string): string {
|
|
11
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
12
|
+
const seconds = Math.floor(diff / 1000);
|
|
13
|
+
if (seconds < 60) return "just now";
|
|
14
|
+
const minutes = Math.floor(seconds / 60);
|
|
15
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
16
|
+
const hours = Math.floor(minutes / 60);
|
|
17
|
+
if (hours < 24) return `${hours}h ago`;
|
|
18
|
+
const days = Math.floor(hours / 24);
|
|
19
|
+
return `${days}d ago`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RevisionSelectProps {
|
|
23
|
+
value: number;
|
|
24
|
+
onChange: (rev: number) => void;
|
|
25
|
+
/** Accent color for the trigger text */
|
|
26
|
+
accent?: "default" | "red" | "green" | "amber";
|
|
27
|
+
/** Extra class on the trigger button */
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function RevisionSelect({ value, onChange, accent = "default", className }: RevisionSelectProps) {
|
|
32
|
+
const { currentRevision, revisions } = useContext(RevisionContext);
|
|
33
|
+
const [open, setOpen] = useState(false);
|
|
34
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!open) return;
|
|
38
|
+
const handler = (e: MouseEvent) => {
|
|
39
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
40
|
+
};
|
|
41
|
+
document.addEventListener("mousedown", handler);
|
|
42
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
43
|
+
}, [open]);
|
|
44
|
+
|
|
45
|
+
const selected = revisions.find((r) => r.revision === value);
|
|
46
|
+
|
|
47
|
+
const accentClasses = {
|
|
48
|
+
default: "text-text-secondary hover:text-text-primary",
|
|
49
|
+
red: "text-accent-red",
|
|
50
|
+
green: "text-accent-green",
|
|
51
|
+
amber: "text-accent-amber bg-highlight-selected",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="relative" ref={ref}>
|
|
56
|
+
<button
|
|
57
|
+
onClick={() => setOpen(!open)}
|
|
58
|
+
className={`flex items-center gap-1.5 text-[12px] font-body font-medium px-2 py-1 rounded-md transition-colors hover:bg-bg-elevated ${accentClasses[accent]} ${className || ""}`}
|
|
59
|
+
>
|
|
60
|
+
{selected ? revisionButtonLabel(selected) : `Round ${value}`}
|
|
61
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" className={`text-text-tertiary transition-transform duration-200 ${open ? "rotate-180" : ""}`}>
|
|
62
|
+
<path d="M2.5 4l2.5 2.5L7.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
63
|
+
</svg>
|
|
64
|
+
</button>
|
|
65
|
+
|
|
66
|
+
{open && (
|
|
67
|
+
<div className="absolute top-full left-0 mt-1 w-64 bg-bg-elevated border border-border-hover rounded-lg shadow-lg z-50 py-1 max-h-80 overflow-y-auto">
|
|
68
|
+
{[...revisions].reverse().map((r) => (
|
|
69
|
+
<button
|
|
70
|
+
key={r.revision}
|
|
71
|
+
onClick={() => { onChange(r.revision); setOpen(false); }}
|
|
72
|
+
className={`w-full flex items-center gap-2 px-3 py-2 text-[12px] font-body transition-colors ${
|
|
73
|
+
r.revision === value
|
|
74
|
+
? "bg-bg-surface text-text-primary"
|
|
75
|
+
: "text-text-secondary hover:bg-bg-surface hover:text-text-primary"
|
|
76
|
+
}`}
|
|
77
|
+
>
|
|
78
|
+
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
|
79
|
+
r.revision === currentRevision ? "bg-accent-green" : r.hasFeedback ? "bg-accent-amber" : "bg-border-hover"
|
|
80
|
+
}`} />
|
|
81
|
+
<span className="flex-1 text-left truncate">
|
|
82
|
+
{r.label ? (
|
|
83
|
+
<>{r.label} <span className="text-text-tertiary">#{r.revision}</span></>
|
|
84
|
+
) : (
|
|
85
|
+
<>Round {r.revision}</>
|
|
86
|
+
)}
|
|
87
|
+
</span>
|
|
88
|
+
<span className="text-[10px] text-text-tertiary flex-shrink-0">
|
|
89
|
+
{r.revision === currentRevision ? "current" : r.hasFeedback ? "sent" : relativeTime(r.createdAt)}
|
|
90
|
+
</span>
|
|
91
|
+
</button>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|