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.
Files changed (65) hide show
  1. package/bin/agent-canvas.js +14 -0
  2. package/bin/agent-canvas.ts +377 -0
  3. package/daemon/build.ts +185 -0
  4. package/daemon/client/AnnotationProvider.tsx +185 -0
  5. package/daemon/client/AnnotationSidebar.tsx +314 -0
  6. package/daemon/client/App.tsx +611 -0
  7. package/daemon/client/CompareView.tsx +333 -0
  8. package/daemon/client/FileBrowser.tsx +188 -0
  9. package/daemon/client/FileIcon.tsx +76 -0
  10. package/daemon/client/FileViewer.tsx +207 -0
  11. package/daemon/client/PlanRenderer.tsx +665 -0
  12. package/daemon/client/Popover.tsx +285 -0
  13. package/daemon/client/ResponsePreview.tsx +260 -0
  14. package/daemon/client/RevisionSelect.tsx +97 -0
  15. package/daemon/client/SessionContext.tsx +3 -0
  16. package/daemon/client/SessionSwitcher.tsx +72 -0
  17. package/daemon/client/annotationContext.ts +114 -0
  18. package/daemon/client/components/Callout.tsx +25 -0
  19. package/daemon/client/components/Checklist.tsx +37 -0
  20. package/daemon/client/components/Choice.tsx +164 -0
  21. package/daemon/client/components/CodeBlock.tsx +35 -0
  22. package/daemon/client/components/Diff.tsx +65 -0
  23. package/daemon/client/components/FilePreview.tsx +88 -0
  24. package/daemon/client/components/Image.tsx +45 -0
  25. package/daemon/client/components/Mermaid.tsx +277 -0
  26. package/daemon/client/components/Note.tsx +13 -0
  27. package/daemon/client/components/Priority.tsx +19 -0
  28. package/daemon/client/components/ResponseNote.tsx +24 -0
  29. package/daemon/client/components/Section.tsx +32 -0
  30. package/daemon/client/components/Table.tsx +35 -0
  31. package/daemon/client/components/Task.tsx +75 -0
  32. package/daemon/client/components/UserInput.tsx +180 -0
  33. package/daemon/client/components/index.ts +15 -0
  34. package/daemon/client/domDiff.ts +336 -0
  35. package/daemon/client/exportMarkdown.ts +267 -0
  36. package/daemon/client/generateMarkdown.ts +194 -0
  37. package/daemon/client/highlightRange.ts +233 -0
  38. package/daemon/client/index.html +26 -0
  39. package/daemon/client/lcs.ts +19 -0
  40. package/daemon/client/popoverPosition.ts +31 -0
  41. package/daemon/client/runtime.ts +89 -0
  42. package/daemon/client/styles.css +306 -0
  43. package/daemon/client/theme.css +66 -0
  44. package/daemon/client/unifiedDiff.ts +604 -0
  45. package/daemon/dist/client.css +2 -0
  46. package/daemon/dist/client.js +68 -0
  47. package/daemon/dist/components.js +5 -0
  48. package/daemon/dist/index.html +108 -0
  49. package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
  50. package/daemon/dist/jsx-runtime-shim.js +17 -0
  51. package/daemon/dist/react-dom-client-shim.js +4 -0
  52. package/daemon/dist/react-dom-shim.js +4 -0
  53. package/daemon/dist/react-shim.js +4 -0
  54. package/daemon/dist/runtime.js +1 -0
  55. package/daemon/dist/theme.css +66 -0
  56. package/daemon/src/compiler.ts +92 -0
  57. package/daemon/src/paths.ts +22 -0
  58. package/daemon/src/server.ts +448 -0
  59. package/daemon/src/session.ts +274 -0
  60. package/daemon/src/watcher.ts +59 -0
  61. package/hooks/session-start.sh +11 -0
  62. package/package.json +39 -0
  63. package/skills/canvas/SKILL.md +174 -0
  64. package/skills/canvas/components.md +349 -0
  65. package/skills/canvas/flows.md +406 -0
@@ -0,0 +1,185 @@
1
+ import React, { useState, useCallback, useEffect, useRef } from "react";
2
+ import { AnnotationCtx } from "#canvas/runtime";
3
+ import type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue } from "#canvas/runtime";
4
+
5
+ // Re-export types for convenience
6
+ export type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue };
7
+ export { useAnnotations } from "#canvas/runtime";
8
+
9
+ interface AnnotationProviderProps {
10
+ sessionId: string;
11
+ revision: number;
12
+ isReadOnly: boolean;
13
+ children: React.ReactNode;
14
+ }
15
+
16
+ interface PersistedState {
17
+ annotations: Annotation[];
18
+ generalNote: string;
19
+ responses: [string, PlanResponse][];
20
+ feedbackEntries?: [string, FeedbackEntry][];
21
+ }
22
+
23
+ function storageKey(sessionId: string, revision: number): string {
24
+ return `canvas:${sessionId}:rev:${revision}`;
25
+ }
26
+
27
+ function loadPersisted(sessionId: string, revision: number): PersistedState | null {
28
+ try {
29
+ const raw = localStorage.getItem(storageKey(sessionId, revision));
30
+ if (!raw) return null;
31
+ return JSON.parse(raw);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function savePersisted(sessionId: string, revision: number, state: PersistedState) {
38
+ try {
39
+ localStorage.setItem(storageKey(sessionId, revision), JSON.stringify(state));
40
+ } catch {}
41
+ }
42
+
43
+ function clearPersisted(sessionId: string, revision: number) {
44
+ try {
45
+ localStorage.removeItem(storageKey(sessionId, revision));
46
+ } catch {}
47
+ }
48
+
49
+ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }: AnnotationProviderProps) {
50
+ const prevKeyRef = useRef(`${sessionId}:${revision}`);
51
+ const [annotations, setAnnotations] = useState<Annotation[]>(() => {
52
+ const saved = loadPersisted(sessionId, revision);
53
+ return saved?.annotations ?? [];
54
+ });
55
+ const [generalNote, setGeneralNote] = useState(() => {
56
+ const saved = loadPersisted(sessionId, revision);
57
+ return saved?.generalNote ?? "";
58
+ });
59
+ const [activeAnnotationId, setActiveAnnotationId] = useState<string | null>(null);
60
+ const [responses, setResponses] = useState<Map<string, PlanResponse>>(() => {
61
+ const saved = loadPersisted(sessionId, revision);
62
+ return saved?.responses ? new Map(saved.responses) : new Map();
63
+ });
64
+ const [feedbackEntries, setFeedbackEntries] = useState<Map<string, FeedbackEntry>>(() => {
65
+ const saved = loadPersisted(sessionId, revision);
66
+ return saved?.feedbackEntries ? new Map(saved.feedbackEntries) : new Map();
67
+ });
68
+
69
+ // Reset state when session/revision changes
70
+ useEffect(() => {
71
+ const key = `${sessionId}:${revision}`;
72
+ if (key === prevKeyRef.current) return;
73
+ prevKeyRef.current = key;
74
+
75
+ const saved = loadPersisted(sessionId, revision);
76
+ setAnnotations(saved?.annotations ?? []);
77
+ setGeneralNote(saved?.generalNote ?? "");
78
+ setResponses(saved?.responses ? new Map(saved.responses) : new Map());
79
+ setFeedbackEntries(saved?.feedbackEntries ? new Map(saved.feedbackEntries) : new Map());
80
+ setActiveAnnotationId(null);
81
+ }, [sessionId, revision]);
82
+
83
+ // Persist to localStorage (debounced)
84
+ const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
85
+ useEffect(() => {
86
+ if (isReadOnly) return;
87
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
88
+ persistTimerRef.current = setTimeout(() => {
89
+ savePersisted(sessionId, revision, {
90
+ annotations,
91
+ generalNote,
92
+ responses: Array.from(responses.entries()),
93
+ feedbackEntries: Array.from(feedbackEntries.entries()),
94
+ });
95
+ }, 300);
96
+ return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
97
+ }, [annotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly]);
98
+
99
+ const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext) => {
100
+ setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context }]);
101
+ }, []);
102
+
103
+ const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
104
+ const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
105
+ addAnnotationWithId(id, snippet, note, filePath);
106
+ }, [addAnnotationWithId]);
107
+
108
+ const updateAnnotation = useCallback((id: string, note: string) => {
109
+ setAnnotations((prev) => prev.map((a) => (a.id === id ? { ...a, note } : a)));
110
+ }, []);
111
+
112
+ const removeAnnotation = useCallback((id: string) => {
113
+ removeMarksFromDom(id);
114
+ setAnnotations((prev) => prev.filter((a) => a.id !== id));
115
+ setActiveAnnotationId((prev) => (prev === id ? null : prev));
116
+ }, []);
117
+
118
+ const setResponse = useCallback((id: string, response: PlanResponse) => {
119
+ setResponses((prev) => {
120
+ const next = new Map(prev);
121
+ next.set(id, response);
122
+ return next;
123
+ });
124
+ }, []);
125
+
126
+ const setFeedbackEntry = useCallback((id: string, entry: FeedbackEntry) => {
127
+ setFeedbackEntries((prev) => {
128
+ const existing = prev.get(id);
129
+ if (existing && existing.markdown === entry.markdown && existing.label === entry.label && existing.required === entry.required) {
130
+ return prev; // same reference → no re-render
131
+ }
132
+ const next = new Map(prev);
133
+ next.set(id, entry);
134
+ return next;
135
+ });
136
+ }, []);
137
+
138
+ const removeFeedbackEntry = useCallback((id: string) => {
139
+ setFeedbackEntries((prev) => {
140
+ if (!prev.has(id)) return prev;
141
+ const next = new Map(prev);
142
+ next.delete(id);
143
+ return next;
144
+ });
145
+ }, []);
146
+
147
+ const clearAll = useCallback(() => {
148
+ for (const mark of document.querySelectorAll("[data-annotation-id]")) {
149
+ unwrapMark(mark as HTMLElement);
150
+ }
151
+ setAnnotations([]);
152
+ setGeneralNote("");
153
+ setActiveAnnotationId(null);
154
+ setResponses(new Map());
155
+ setFeedbackEntries(new Map());
156
+ clearPersisted(sessionId, revision);
157
+ }, [sessionId, revision]);
158
+
159
+ return (
160
+ <AnnotationCtx.Provider
161
+ value={{
162
+ annotations, addAnnotation, addAnnotationWithId, updateAnnotation, removeAnnotation,
163
+ generalNote, setGeneralNote, clearAll,
164
+ activeAnnotationId, setActiveAnnotationId,
165
+ responses, setResponse,
166
+ feedbackEntries, setFeedbackEntry, removeFeedbackEntry,
167
+ }}
168
+ >
169
+ {children}
170
+ </AnnotationCtx.Provider>
171
+ );
172
+ }
173
+
174
+ function removeMarksFromDom(id: string) {
175
+ const marks = document.querySelectorAll(`[data-annotation-id="${id}"]`);
176
+ for (const mark of marks) unwrapMark(mark as HTMLElement);
177
+ }
178
+
179
+ function unwrapMark(mark: HTMLElement) {
180
+ const parent = mark.parentNode;
181
+ if (!parent) return;
182
+ const text = document.createTextNode(mark.textContent || "");
183
+ parent.replaceChild(text, mark);
184
+ parent.normalize();
185
+ }
@@ -0,0 +1,314 @@
1
+ import React, { useRef, useEffect, useCallback, useState, useContext } from "react";
2
+ import { useAnnotations, Annotation } from "./AnnotationProvider";
3
+ import { setMarkActive } from "./highlightRange";
4
+ import { generateMarkdown, hasValue, getMissingRequired, getMissingRequiredFeedback } from "./generateMarkdown";
5
+ import { RevisionContext, ActiveViewContext } from "./App";
6
+ import { SessionContext } from "#canvas/runtime";
7
+ import { MarkdownPreview } from "./ResponsePreview";
8
+ import { FileIcon } from "./FileIcon";
9
+
10
+ interface AnnotationSidebarProps {
11
+ onPreview: () => void;
12
+ onSubmit: (feedback: string) => void;
13
+ collapseButton?: React.ReactNode;
14
+ }
15
+
16
+ export function AnnotationSidebar({ onPreview, onSubmit, collapseButton }: AnnotationSidebarProps) {
17
+ const { isReadOnly, selectedRevision, currentRevision, revisions } = useContext(RevisionContext);
18
+ const sessionId = useContext(SessionContext);
19
+ const isCurrentButSubmitted = isReadOnly && selectedRevision === currentRevision;
20
+ const selectedRevInfo = revisions.find((r) => r.revision === selectedRevision);
21
+ const roundLabel = selectedRevInfo?.label || `Round ${selectedRevision}`;
22
+
23
+ if (isReadOnly) {
24
+ return <FeedbackDisplay sessionId={sessionId} revision={selectedRevision} label={roundLabel} waitingForUpdate={isCurrentButSubmitted} collapseButton={collapseButton} />;
25
+ }
26
+
27
+ return <AnnotationSidebarInner onPreview={onPreview} onSubmit={onSubmit} collapseButton={collapseButton} />;
28
+ }
29
+
30
+ function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; collapseButton?: React.ReactNode }) {
31
+ const [feedback, setFeedback] = useState<string | null>(null);
32
+ const [loading, setLoading] = useState(true);
33
+
34
+ useEffect(() => {
35
+ setLoading(true);
36
+ fetch(`/api/session/${sessionId}/revision/${revision}/feedback`)
37
+ .then((r) => r.json())
38
+ .then((data: any) => { setFeedback(data.feedback || null); setLoading(false); })
39
+ .catch(() => { setFeedback(null); setLoading(false); });
40
+ }, [sessionId, revision]);
41
+
42
+ return (
43
+ <div className="flex flex-col h-full">
44
+ <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
45
+ <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body">
46
+ {waitingForUpdate ? "Feedback sent" : `Feedback — ${label}`}
47
+ </span>
48
+ {collapseButton}
49
+ </div>
50
+
51
+ {waitingForUpdate && (
52
+ <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">
53
+ <span className="w-2 h-2 rounded-full bg-accent-green animate-pulse" />
54
+ <span className="text-[12px] font-body text-accent-green">Waiting for next revision...</span>
55
+ </div>
56
+ )}
57
+
58
+ <div className="flex-1 overflow-y-auto px-4">
59
+ {loading ? (
60
+ <p className="text-[12px] text-text-tertiary font-body py-4">Loading...</p>
61
+ ) : feedback ? (
62
+ <MarkdownPreview text={feedback} />
63
+ ) : (
64
+ <p className="text-[12px] text-text-tertiary font-body py-4">No feedback was submitted for this revision.</p>
65
+ )}
66
+ </div>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: AnnotationSidebarProps) {
72
+ const {
73
+ annotations, updateAnnotation, removeAnnotation,
74
+ generalNote, setGeneralNote,
75
+ activeAnnotationId, setActiveAnnotationId,
76
+ responses, feedbackEntries,
77
+ } = useAnnotations();
78
+ const { setActiveView } = React.useContext(ActiveViewContext);
79
+
80
+ const listRef = useRef<HTMLDivElement>(null);
81
+ const annRefs = useRef<Map<string, HTMLDivElement>>(new Map());
82
+
83
+ const planAnnotations = annotations.filter((a) => !a.filePath);
84
+ const fileAnnotations = annotations.filter((a) => a.filePath);
85
+ const fileGroups: Record<string, Annotation[]> = {};
86
+ for (const ann of fileAnnotations) {
87
+ const key = ann.filePath!;
88
+ if (!fileGroups[key]) fileGroups[key] = [];
89
+ fileGroups[key].push(ann);
90
+ }
91
+
92
+ // Scroll to active annotation in sidebar
93
+ useEffect(() => {
94
+ if (!activeAnnotationId) return;
95
+ const el = annRefs.current.get(activeAnnotationId);
96
+ if (el) {
97
+ el.scrollIntoView({ behavior: "smooth", block: "nearest" });
98
+ }
99
+ }, [activeAnnotationId]);
100
+
101
+ // Hover on sidebar card → highlight inline mark + block + scroll to it
102
+ const handleMouseEnter = useCallback((annId: string) => {
103
+ setActiveAnnotationId(annId);
104
+ setMarkActive(annId, true);
105
+ // Scroll inline mark into view
106
+ const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
107
+ if (mark) {
108
+ mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
109
+ }
110
+ }, [setActiveAnnotationId]);
111
+
112
+ const handleMouseLeave = useCallback((annId: string) => {
113
+ setActiveAnnotationId(null);
114
+ setMarkActive(annId, false);
115
+ }, [activeAnnotationId]);
116
+
117
+ const hasResponses = Array.from(responses.values()).some(hasValue);
118
+ const hasFeedback = feedbackEntries.size > 0;
119
+ const hasContent = annotations.length > 0 || generalNote.trim().length > 0 || hasResponses || hasFeedback;
120
+ const [validationError, setValidationError] = useState<string | null>(null);
121
+
122
+ const renderAnnotation = (ann: Annotation) => (
123
+ <div
124
+ key={ann.id}
125
+ ref={(el) => { if (el) annRefs.current.set(ann.id, el); else annRefs.current.delete(ann.id); }}
126
+ className={`group/ann relative px-3 py-2.5 transition-colors duration-150 ${
127
+ activeAnnotationId === ann.id
128
+ ? "bg-highlight-selected"
129
+ : "odd:bg-bg-elevated-half hover:bg-bg-input"
130
+ }`}
131
+ onMouseEnter={() => handleMouseEnter(ann.id)}
132
+ onMouseLeave={() => handleMouseLeave(ann.id)}
133
+ onClick={() => {
134
+ if (ann.id === activeAnnotationId) {
135
+ setActiveAnnotationId(null);
136
+ } else {
137
+ setActiveAnnotationId(ann.id);
138
+ if (ann.filePath) {
139
+ setActiveView({ type: "file", path: ann.filePath });
140
+ // Scroll to mark after file view renders
141
+ setTimeout(() => {
142
+ const mark = document.querySelector(`[data-annotation-id="${ann.id}"]`) as HTMLElement | null;
143
+ if (mark) mark.scrollIntoView({ behavior: "smooth", block: "center" });
144
+ }, 150);
145
+ }
146
+ }
147
+ }}
148
+ >
149
+ {/* Snippet quote */}
150
+ <div className="text-[11px] text-text-tertiary italic line-clamp-2 mb-1.5 leading-snug font-body border-l-2 border-border-medium pl-2">
151
+ {ann.snippet.length > 80 ? ann.snippet.slice(0, 80) + "..." : ann.snippet}
152
+ </div>
153
+
154
+ {/* Seamless editable note */}
155
+ <textarea
156
+ value={ann.note}
157
+ onChange={(e) => updateAnnotation(ann.id, e.target.value)}
158
+ onClick={(e) => e.stopPropagation()}
159
+ className="w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none min-h-[20px]"
160
+ rows={1}
161
+ style={{ height: "auto", overflow: "hidden" }}
162
+ onInput={(e) => {
163
+ const t = e.target as HTMLTextAreaElement;
164
+ t.style.height = "auto";
165
+ t.style.height = t.scrollHeight + "px";
166
+ }}
167
+ ref={(el) => {
168
+ if (el) {
169
+ el.style.height = "auto";
170
+ el.style.height = el.scrollHeight + "px";
171
+ }
172
+ }}
173
+ />
174
+
175
+ {/* Delete — top right on hover */}
176
+ <div className="absolute top-1.5 right-1.5 opacity-0 group-hover/ann:opacity-100 transition-opacity duration-100">
177
+ <button
178
+ onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
179
+ className="w-5 h-5 flex items-center justify-center rounded text-text-tertiary hover:text-accent-red hover:bg-accent-red-muted transition-colors"
180
+ title="Delete annotation"
181
+ >
182
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
183
+ <polyline points="3 6 5 6 21 6"/>
184
+ <path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
185
+ </svg>
186
+ </button>
187
+ </div>
188
+ </div>
189
+ );
190
+
191
+ return (
192
+ <div className="flex flex-col h-full">
193
+ {/* Header */}
194
+ <div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
195
+ <span className="text-[11px] font-medium uppercase tracking-widest text-text-tertiary font-body flex items-center gap-2">
196
+ Annotations
197
+ {annotations.length > 0 && (
198
+ <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>
199
+ )}
200
+ </span>
201
+ {collapseButton}
202
+ </div>
203
+
204
+ {/* Annotation list */}
205
+ <div ref={listRef} className="flex-1 overflow-y-auto">
206
+ {annotations.length === 0 && (
207
+ <p className="text-[12px] text-text-tertiary px-3 py-4 leading-relaxed font-body">
208
+ Select text in the plan or in files to add annotations.
209
+ </p>
210
+ )}
211
+
212
+ {planAnnotations.length > 0 && fileAnnotations.length > 0 && (
213
+ <div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Plan</div>
214
+ )}
215
+ {planAnnotations.map(renderAnnotation)}
216
+
217
+ {Object.entries(fileGroups).map(([filePath, anns]) => (
218
+ <div key={filePath}>
219
+ <button
220
+ onClick={() => setActiveView({ type: "file", path: filePath })}
221
+ 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"
222
+ title={filePath}
223
+ >
224
+ <FileIcon name={filePath.split("/").pop() || filePath} type="file" />
225
+ <span className="truncate">{filePath}</span>
226
+ </button>
227
+ {anns.map(renderAnnotation)}
228
+ </div>
229
+ ))}
230
+ </div>
231
+
232
+ {/* General note — seamless */}
233
+ <div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
234
+ <textarea
235
+ value={generalNote}
236
+ onChange={(e) => setGeneralNote(e.target.value)}
237
+ className="w-full bg-transparent text-[13px] font-body text-text-primary resize-none leading-relaxed p-0 border-none ring-0 shadow-none outline-none focus:outline-none focus:ring-0 focus:border-none placeholder:text-text-disabled min-h-[40px]"
238
+ placeholder="General notes..."
239
+ onInput={(e) => {
240
+ const t = e.target as HTMLTextAreaElement;
241
+ t.style.height = "auto";
242
+ t.style.height = Math.max(40, t.scrollHeight) + "px";
243
+ }}
244
+ ref={(el) => {
245
+ if (el) {
246
+ el.style.height = "auto";
247
+ el.style.height = Math.max(40, el.scrollHeight) + "px";
248
+ }
249
+ }}
250
+ />
251
+ </div>
252
+
253
+ {/* Validation error */}
254
+ {validationError && (
255
+ <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">
256
+ <span>{validationError}</span>
257
+ <button
258
+ onClick={() => {
259
+ setValidationError(null);
260
+ const md = generateMarkdown(annotations, generalNote, responses, feedbackEntries);
261
+ onSubmit(md);
262
+ }}
263
+ className="text-[11px] text-text-tertiary hover:text-text-secondary font-body whitespace-nowrap underline"
264
+ >
265
+ Submit anyway
266
+ </button>
267
+ </div>
268
+ )}
269
+
270
+ {/* Action buttons */}
271
+ <div className="px-4 py-3 border-t border-border-subtle flex-shrink-0">
272
+ {hasContent ? (
273
+ <div className="flex gap-2">
274
+ <button
275
+ onClick={onPreview}
276
+ 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"
277
+ >
278
+ Preview
279
+ </button>
280
+ <button
281
+ onClick={() => {
282
+ const missingResponses = getMissingRequired(responses);
283
+ const missingFeedback = getMissingRequiredFeedback(feedbackEntries);
284
+ const allMissing = [
285
+ ...missingResponses.map((r) => r.label),
286
+ ...missingFeedback.map((e) => e.label || e.id),
287
+ ];
288
+ if (allMissing.length > 0) {
289
+ setValidationError(`Please answer: ${allMissing.join(", ")}`);
290
+ return;
291
+ }
292
+ setValidationError(null);
293
+ const md = generateMarkdown(annotations, generalNote, responses, feedbackEntries);
294
+ onSubmit(md);
295
+ }}
296
+ className="flex-1 py-2 rounded-lg font-body text-[13px] font-medium transition-all bg-btn-primary text-btn-primary-text hover:opacity-90 hover:-translate-y-px shadow-sm"
297
+ >
298
+ Submit
299
+ </button>
300
+ </div>
301
+ ) : (
302
+ <button
303
+ onClick={() => onSubmit("No feedback — looks good.")}
304
+ className="w-full py-2 rounded-lg font-body text-[13px] font-medium border border-border-medium text-text-secondary hover:text-text-primary hover:border-border-hover hover:bg-bg-input transition-all"
305
+ >
306
+ Submit without feedback
307
+ </button>
308
+ )}
309
+ </div>
310
+ </div>
311
+ );
312
+ }
313
+
314
+