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,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
|
+
|