agent-canvas 0.1.5 → 0.1.8
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/.github/workflows/ci.yml +23 -0
- package/.github/workflows/release.yml +36 -0
- package/CLAUDE.md +86 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/bin/agent-canvas.ts +26 -353
- package/bin/lib/commands/daemon.ts +51 -0
- package/bin/lib/commands/fetch.ts +20 -0
- package/bin/lib/commands/install.ts +48 -0
- package/bin/lib/commands/push.ts +67 -0
- package/bin/lib/commands/watch.ts +23 -0
- package/bin/lib/config.ts +10 -0
- package/bin/lib/daemon-lifecycle.ts +74 -0
- package/bin/lib/helpers.ts +78 -0
- package/bun.lock +176 -0
- package/daemon/build.ts +27 -61
- package/daemon/bunfig.toml +2 -0
- package/daemon/client/AnnotationProvider.tsx +2 -2
- package/daemon/client/AnnotationSidebar.tsx +199 -31
- package/daemon/client/App.tsx +39 -3
- package/daemon/client/FileBrowser.tsx +8 -7
- package/daemon/client/FileViewer.tsx +153 -16
- package/daemon/client/PlanRenderer.tsx +6 -3
- package/daemon/client/ResponsePreview.tsx +5 -116
- package/daemon/client/SessionSwitcher.tsx +21 -10
- package/daemon/client/compat/preact-all.ts +4 -0
- package/daemon/client/components/Markdown.tsx +44 -0
- package/daemon/client/components/ResponseNote.tsx +3 -8
- package/daemon/client/components/UserInput.tsx +2 -4
- package/daemon/client/components/index.ts +1 -0
- package/daemon/client/domDiff.ts +82 -2
- package/daemon/client/generateMarkdown.ts +46 -9
- package/daemon/client/highlightRange.ts +8 -6
- package/daemon/client/runtime.ts +2 -0
- package/daemon/client/styles.css +126 -0
- package/daemon/client/unifiedDiff.ts +155 -2
- package/daemon/client/utils.ts +10 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +83 -36
- package/daemon/dist/components.js +63 -5
- package/daemon/dist/index.html +5 -12
- package/daemon/dist/jsx-runtime.js +3 -0
- package/daemon/dist/preact-compat.js +1 -0
- package/daemon/langMap.ts +8 -0
- package/daemon/src/compiler.ts +28 -2
- package/daemon/src/handlers/api.ts +150 -0
- package/daemon/src/handlers/files.ts +114 -0
- package/daemon/src/handlers/static.ts +61 -0
- package/daemon/src/handlers/utils.ts +14 -0
- package/daemon/src/router.ts +16 -0
- package/daemon/src/server.ts +28 -403
- package/daemon/src/session.ts +43 -24
- package/daemon/src/websocket.ts +83 -0
- package/daemon/tsconfig.json +20 -0
- package/package.json +4 -14
- package/skills/canvas/SKILL.md +47 -22
- package/skills/canvas/components.md +31 -0
- package/skills/canvas/flows.md +2 -2
- package/tsconfig.json +14 -0
- package/daemon/dist/jsx-dev-runtime-shim.js +0 -17
- package/daemon/dist/jsx-runtime-shim.js +0 -17
- package/daemon/dist/react-dom-client-shim.js +0 -4
- package/daemon/dist/react-dom-shim.js +0 -4
- package/daemon/dist/react-shim.js +0 -4
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { AnnotationCtx } from "#canvas/runtime";
|
|
3
3
|
import type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue } from "#canvas/runtime";
|
|
4
|
+
import { generateAnnotationId } from "./utils";
|
|
4
5
|
|
|
5
6
|
// Re-export types for convenience
|
|
6
7
|
export type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue };
|
|
@@ -101,8 +102,7 @@ export function AnnotationProvider({ sessionId, revision, isReadOnly, children }
|
|
|
101
102
|
}, []);
|
|
102
103
|
|
|
103
104
|
const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
|
|
104
|
-
|
|
105
|
-
addAnnotationWithId(id, snippet, note, filePath);
|
|
105
|
+
addAnnotationWithId(generateAnnotationId(), snippet, note, filePath);
|
|
106
106
|
}, [addAnnotationWithId]);
|
|
107
107
|
|
|
108
108
|
const updateAnnotation = useCallback((id: string, note: string) => {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import React, { useRef, useEffect, useCallback, useState, useContext } from "react";
|
|
2
2
|
import { useAnnotations, Annotation } from "./AnnotationProvider";
|
|
3
3
|
import { setMarkActive } from "./highlightRange";
|
|
4
|
-
import { generateMarkdown, hasValue,
|
|
5
|
-
import { RevisionContext, ActiveViewContext } from "./App";
|
|
4
|
+
import { generateMarkdown, hasValue, getMissingRequiredLabels } from "./generateMarkdown";
|
|
5
|
+
import { RevisionContext, ActiveViewContext, type ActiveView } from "./App";
|
|
6
6
|
import { SessionContext } from "#canvas/runtime";
|
|
7
7
|
import { MarkdownPreview } from "./ResponsePreview";
|
|
8
8
|
import { FileIcon } from "./FileIcon";
|
|
9
|
+
import { autoResizeTextarea } from "./utils";
|
|
9
10
|
|
|
10
11
|
interface AnnotationSidebarProps {
|
|
11
12
|
onPreview: () => void;
|
|
@@ -21,7 +22,7 @@ export function AnnotationSidebar({ onPreview, onSubmit, collapseButton }: Annot
|
|
|
21
22
|
const roundLabel = selectedRevInfo?.label || `Round ${selectedRevision}`;
|
|
22
23
|
|
|
23
24
|
if (isReadOnly) {
|
|
24
|
-
return <
|
|
25
|
+
return <ReadOnlyAnnotationSidebar sessionId={sessionId} revision={selectedRevision} label={roundLabel} waitingForUpdate={isCurrentButSubmitted} collapseButton={collapseButton} />;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
return <AnnotationSidebarInner onPreview={onPreview} onSubmit={onSubmit} collapseButton={collapseButton} />;
|
|
@@ -68,6 +69,196 @@ function FeedbackDisplay({ sessionId, revision, label, waitingForUpdate, collaps
|
|
|
68
69
|
);
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
type ReadOnlyTab = "feedback" | "annotations";
|
|
73
|
+
|
|
74
|
+
function ReadOnlyAnnotationSidebar({ sessionId, revision, label, waitingForUpdate, collapseButton }: { sessionId: string; revision: number; label: string; waitingForUpdate?: boolean; collapseButton?: React.ReactNode }) {
|
|
75
|
+
const { annotations, generalNote, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
76
|
+
const { setActiveView } = useContext(ActiveViewContext);
|
|
77
|
+
const hasAnnotations = annotations.length > 0 || generalNote.trim().length > 0;
|
|
78
|
+
const [activeTab, setActiveTab] = useState<ReadOnlyTab>("feedback");
|
|
79
|
+
|
|
80
|
+
// If no annotations in localStorage, just show feedback
|
|
81
|
+
if (!hasAnnotations) {
|
|
82
|
+
return <FeedbackDisplay sessionId={sessionId} revision={revision} label={label} waitingForUpdate={waitingForUpdate} collapseButton={collapseButton} />;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex flex-col h-full">
|
|
87
|
+
{/* Header with tab switcher */}
|
|
88
|
+
<div className="px-4 py-3 flex items-center justify-between flex-shrink-0">
|
|
89
|
+
<div className="flex items-center gap-1 bg-bg-input rounded-md p-0.5">
|
|
90
|
+
<button
|
|
91
|
+
onClick={() => setActiveTab("feedback")}
|
|
92
|
+
className={`px-2.5 py-1 rounded text-[11px] font-medium font-body transition-colors ${
|
|
93
|
+
activeTab === "feedback"
|
|
94
|
+
? "bg-bg-elevated text-text-primary shadow-sm"
|
|
95
|
+
: "text-text-tertiary hover:text-text-secondary"
|
|
96
|
+
}`}
|
|
97
|
+
>
|
|
98
|
+
Feedback
|
|
99
|
+
</button>
|
|
100
|
+
<button
|
|
101
|
+
onClick={() => setActiveTab("annotations")}
|
|
102
|
+
className={`px-2.5 py-1 rounded text-[11px] font-medium font-body transition-colors flex items-center gap-1.5 ${
|
|
103
|
+
activeTab === "annotations"
|
|
104
|
+
? "bg-bg-elevated text-text-primary shadow-sm"
|
|
105
|
+
: "text-text-tertiary hover:text-text-secondary"
|
|
106
|
+
}`}
|
|
107
|
+
>
|
|
108
|
+
Annotations
|
|
109
|
+
<span className="inline-flex items-center justify-center min-w-[16px] h-[16px] px-1 rounded-full bg-border-subtle text-[10px] font-medium">{annotations.length}</span>
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
{collapseButton}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{waitingForUpdate && (
|
|
116
|
+
<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">
|
|
117
|
+
<span className="w-2 h-2 rounded-full bg-accent-green animate-pulse" />
|
|
118
|
+
<span className="text-[12px] font-body text-accent-green">Waiting for next revision...</span>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{activeTab === "feedback" ? (
|
|
123
|
+
<FeedbackDisplayContent sessionId={sessionId} revision={revision} />
|
|
124
|
+
) : (
|
|
125
|
+
<ReadOnlyAnnotationList
|
|
126
|
+
annotations={annotations}
|
|
127
|
+
generalNote={generalNote}
|
|
128
|
+
activeAnnotationId={activeAnnotationId}
|
|
129
|
+
setActiveAnnotationId={setActiveAnnotationId}
|
|
130
|
+
setActiveView={setActiveView}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function FeedbackDisplayContent({ sessionId, revision }: { sessionId: string; revision: number }) {
|
|
138
|
+
const [feedback, setFeedback] = useState<string | null>(null);
|
|
139
|
+
const [loading, setLoading] = useState(true);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
setLoading(true);
|
|
143
|
+
fetch(`/api/session/${sessionId}/revision/${revision}/feedback`)
|
|
144
|
+
.then((r) => r.json())
|
|
145
|
+
.then((data: any) => { setFeedback(data.feedback || null); setLoading(false); })
|
|
146
|
+
.catch(() => { setFeedback(null); setLoading(false); });
|
|
147
|
+
}, [sessionId, revision]);
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="flex-1 overflow-y-auto px-4">
|
|
151
|
+
{loading ? (
|
|
152
|
+
<p className="text-[12px] text-text-tertiary font-body py-4">Loading...</p>
|
|
153
|
+
) : feedback ? (
|
|
154
|
+
<MarkdownPreview text={feedback} />
|
|
155
|
+
) : (
|
|
156
|
+
<p className="text-[12px] text-text-tertiary font-body py-4">No feedback was submitted for this revision.</p>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function ReadOnlyAnnotationList({ annotations, generalNote, activeAnnotationId, setActiveAnnotationId, setActiveView }: {
|
|
163
|
+
annotations: Annotation[];
|
|
164
|
+
generalNote: string;
|
|
165
|
+
activeAnnotationId: string | null;
|
|
166
|
+
setActiveAnnotationId: (id: string | null) => void;
|
|
167
|
+
setActiveView: (view: ActiveView) => void;
|
|
168
|
+
}) {
|
|
169
|
+
const planAnnotations = annotations.filter((a) => !a.filePath);
|
|
170
|
+
const fileAnnotations = annotations.filter((a) => a.filePath);
|
|
171
|
+
const fileGroups: Record<string, Annotation[]> = {};
|
|
172
|
+
for (const ann of fileAnnotations) {
|
|
173
|
+
const key = ann.filePath!;
|
|
174
|
+
if (!fileGroups[key]) fileGroups[key] = [];
|
|
175
|
+
fileGroups[key].push(ann);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const handleMouseEnter = (annId: string) => {
|
|
179
|
+
setActiveAnnotationId(annId);
|
|
180
|
+
setMarkActive(annId, true);
|
|
181
|
+
const mark = document.querySelector(`[data-annotation-id="${annId}"]`) as HTMLElement | null;
|
|
182
|
+
if (mark) mark.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const handleMouseLeave = (annId: string) => {
|
|
186
|
+
setActiveAnnotationId(null);
|
|
187
|
+
setMarkActive(annId, false);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const handleClick = (ann: Annotation) => {
|
|
191
|
+
if (ann.id === activeAnnotationId) {
|
|
192
|
+
setActiveAnnotationId(null);
|
|
193
|
+
} else {
|
|
194
|
+
setActiveAnnotationId(ann.id);
|
|
195
|
+
if (ann.filePath) {
|
|
196
|
+
setActiveView({ type: "file", path: ann.filePath });
|
|
197
|
+
setTimeout(() => {
|
|
198
|
+
const mark = document.querySelector(`[data-annotation-id="${ann.id}"]`) as HTMLElement | null;
|
|
199
|
+
if (mark) mark.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
200
|
+
}, 150);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const renderAnnotation = (ann: Annotation) => (
|
|
206
|
+
<div
|
|
207
|
+
key={ann.id}
|
|
208
|
+
className={`relative px-3 py-2.5 transition-colors duration-150 cursor-pointer ${
|
|
209
|
+
activeAnnotationId === ann.id
|
|
210
|
+
? "bg-highlight-selected"
|
|
211
|
+
: "odd:bg-bg-elevated-half hover:bg-bg-input"
|
|
212
|
+
}`}
|
|
213
|
+
onMouseEnter={() => handleMouseEnter(ann.id)}
|
|
214
|
+
onMouseLeave={() => handleMouseLeave(ann.id)}
|
|
215
|
+
onClick={() => handleClick(ann)}
|
|
216
|
+
>
|
|
217
|
+
<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">
|
|
218
|
+
{ann.snippet.length > 80 ? ann.snippet.slice(0, 80) + "..." : ann.snippet}
|
|
219
|
+
</div>
|
|
220
|
+
{ann.note.trim() && (
|
|
221
|
+
<div className="text-[13px] font-body text-text-primary leading-relaxed">
|
|
222
|
+
{ann.note}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<>
|
|
230
|
+
<div className="flex-1 overflow-y-auto">
|
|
231
|
+
{planAnnotations.length > 0 && fileAnnotations.length > 0 && (
|
|
232
|
+
<div className="text-[10px] uppercase tracking-widest text-text-tertiary font-body px-3 mb-1 mt-1">Plan</div>
|
|
233
|
+
)}
|
|
234
|
+
{planAnnotations.map(renderAnnotation)}
|
|
235
|
+
|
|
236
|
+
{Object.entries(fileGroups).map(([filePath, anns]) => (
|
|
237
|
+
<div key={filePath}>
|
|
238
|
+
<button
|
|
239
|
+
onClick={() => setActiveView({ type: "file", path: filePath })}
|
|
240
|
+
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"
|
|
241
|
+
title={filePath}
|
|
242
|
+
>
|
|
243
|
+
<FileIcon name={filePath.split("/").pop() || filePath} type="file" />
|
|
244
|
+
<span className="truncate">{filePath}</span>
|
|
245
|
+
</button>
|
|
246
|
+
{anns.map(renderAnnotation)}
|
|
247
|
+
</div>
|
|
248
|
+
))}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{generalNote.trim() && (
|
|
252
|
+
<div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
|
|
253
|
+
<div className="text-[13px] font-body text-text-primary leading-relaxed">
|
|
254
|
+
{generalNote}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
71
262
|
function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: AnnotationSidebarProps) {
|
|
72
263
|
const {
|
|
73
264
|
annotations, updateAnnotation, removeAnnotation,
|
|
@@ -159,17 +350,8 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
159
350
|
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
351
|
rows={1}
|
|
161
352
|
style={{ height: "auto", overflow: "hidden" }}
|
|
162
|
-
onInput={(e) =>
|
|
163
|
-
|
|
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
|
-
}}
|
|
353
|
+
onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement)}
|
|
354
|
+
ref={(el) => { if (el) autoResizeTextarea(el); }}
|
|
173
355
|
/>
|
|
174
356
|
|
|
175
357
|
{/* Delete — top right on hover */}
|
|
@@ -236,17 +418,8 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
236
418
|
onChange={(e) => setGeneralNote(e.target.value)}
|
|
237
419
|
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
420
|
placeholder="General notes..."
|
|
239
|
-
onInput={(e) =>
|
|
240
|
-
|
|
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
|
-
}}
|
|
421
|
+
onInput={(e) => autoResizeTextarea(e.target as HTMLTextAreaElement, 40)}
|
|
422
|
+
ref={(el) => { if (el) autoResizeTextarea(el, 40); }}
|
|
250
423
|
/>
|
|
251
424
|
</div>
|
|
252
425
|
|
|
@@ -279,12 +452,7 @@ function AnnotationSidebarInner({ onPreview, onSubmit, collapseButton }: Annotat
|
|
|
279
452
|
</button>
|
|
280
453
|
<button
|
|
281
454
|
onClick={() => {
|
|
282
|
-
const
|
|
283
|
-
const missingFeedback = getMissingRequiredFeedback(feedbackEntries);
|
|
284
|
-
const allMissing = [
|
|
285
|
-
...missingResponses.map((r) => r.label),
|
|
286
|
-
...missingFeedback.map((e) => e.label || e.id),
|
|
287
|
-
];
|
|
455
|
+
const allMissing = getMissingRequiredLabels(responses, feedbackEntries);
|
|
288
456
|
if (allMissing.length > 0) {
|
|
289
457
|
setValidationError(`Please answer: ${allMissing.join(", ")}`);
|
|
290
458
|
return;
|
package/daemon/client/App.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useLayoutEffect, useState, useCallback, useRef, createContext, useMemo } from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { marked } from "marked";
|
|
3
4
|
import { SessionContext, ActiveViewCtx } from "#canvas/runtime";
|
|
4
5
|
import { AnnotationProvider, useAnnotations } from "./AnnotationProvider";
|
|
5
6
|
import { PlanRenderer } from "./PlanRenderer";
|
|
@@ -11,6 +12,7 @@ import { SessionSwitcher } from "./SessionSwitcher";
|
|
|
11
12
|
import { exportCanvasToMarkdown } from "./exportMarkdown";
|
|
12
13
|
import { CompareView } from "./CompareView";
|
|
13
14
|
import { RevisionSelect } from "./RevisionSelect";
|
|
15
|
+
import { generateAnnotationId } from "./utils";
|
|
14
16
|
|
|
15
17
|
export type ActiveView = { type: "plan" } | { type: "file"; path: string };
|
|
16
18
|
|
|
@@ -20,6 +22,7 @@ export interface RevisionInfo {
|
|
|
20
22
|
sourceFile?: string;
|
|
21
23
|
createdAt: string;
|
|
22
24
|
hasFeedback: boolean;
|
|
25
|
+
response?: string;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export const ActiveViewContext = createContext<{
|
|
@@ -101,7 +104,7 @@ function RevisionSelector() {
|
|
|
101
104
|
const isLatest = selectedRevision === currentRevision;
|
|
102
105
|
|
|
103
106
|
return (
|
|
104
|
-
<div className="flex
|
|
107
|
+
<div className="flex flex-col gap-1">
|
|
105
108
|
<RevisionSelect
|
|
106
109
|
value={selectedRevision}
|
|
107
110
|
onChange={setSelectedRevision}
|
|
@@ -123,7 +126,7 @@ function RevisionSelector() {
|
|
|
123
126
|
}
|
|
124
127
|
setCompareRevision({ left, right: currentRevision });
|
|
125
128
|
}}
|
|
126
|
-
className="text-[11px] font-body font-medium px-2 py-0.5 rounded-md text-accent-blue hover:bg-accent-blue-muted transition-colors"
|
|
129
|
+
className="text-[11px] font-body font-medium px-2 py-0.5 rounded-md text-accent-blue hover:bg-accent-blue-muted transition-colors self-start"
|
|
127
130
|
>
|
|
128
131
|
Compare
|
|
129
132
|
</button>
|
|
@@ -337,6 +340,9 @@ function App() {
|
|
|
337
340
|
<polyline points="20 6 9 17 4 12"/>
|
|
338
341
|
</svg>
|
|
339
342
|
</button>
|
|
343
|
+
{selectedRevInfo?.response && (
|
|
344
|
+
<ResponseBanner markdown={selectedRevInfo.response} />
|
|
345
|
+
)}
|
|
340
346
|
<PlanRenderer revision={selectedRevision} />
|
|
341
347
|
</div>
|
|
342
348
|
</div>
|
|
@@ -515,6 +521,36 @@ function ResizableSidebar({ children, collapsed, onToggle }: { children: React.R
|
|
|
515
521
|
);
|
|
516
522
|
}
|
|
517
523
|
|
|
524
|
+
function ResponseBanner({ markdown }: { markdown: string }) {
|
|
525
|
+
const [dismissed, setDismissed] = useState(false);
|
|
526
|
+
const html = useMemo(() => marked.parse(markdown, { async: false }) as string, [markdown]);
|
|
527
|
+
|
|
528
|
+
// Reset dismissed state when markdown changes
|
|
529
|
+
useEffect(() => { setDismissed(false); }, [markdown]);
|
|
530
|
+
|
|
531
|
+
if (dismissed) return null;
|
|
532
|
+
|
|
533
|
+
return (
|
|
534
|
+
<div className="mb-6 rounded-lg border border-accent-blue/20 bg-accent-blue/5 relative">
|
|
535
|
+
<button
|
|
536
|
+
onClick={() => setDismissed(true)}
|
|
537
|
+
className="absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated transition-colors"
|
|
538
|
+
>
|
|
539
|
+
<span className="text-xs">✕</span>
|
|
540
|
+
</button>
|
|
541
|
+
<div className="px-4 py-3 pr-10">
|
|
542
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
543
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent-blue flex-shrink-0">
|
|
544
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
545
|
+
</svg>
|
|
546
|
+
<span className="text-[11px] font-medium uppercase tracking-widest text-accent-blue font-body">Agent Response</span>
|
|
547
|
+
</div>
|
|
548
|
+
<div className="prose-canvas text-sm" dangerouslySetInnerHTML={{ __html: html }} />
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
518
554
|
function ContentTabs() {
|
|
519
555
|
const { activeView, setActiveView, openFiles, closeFile } = React.useContext(ActiveViewContext);
|
|
520
556
|
const sessionId = React.useContext(SessionContext);
|
|
@@ -595,7 +631,7 @@ function ContentTabs() {
|
|
|
595
631
|
const data = await res.json() as any;
|
|
596
632
|
if (!data.error) {
|
|
597
633
|
const preview = data.content.split("\n").slice(0, 3).join("\n") + (data.content.split("\n").length > 3 ? "\n..." : "");
|
|
598
|
-
const id =
|
|
634
|
+
const id = generateAnnotationId();
|
|
599
635
|
addAnnotationWithId(id, preview, "", path);
|
|
600
636
|
setActiveAnnotationId(id);
|
|
601
637
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useState, useContext, useCallback } from "react";
|
|
1
|
+
import React, { useEffect, useState, useContext, useCallback, useMemo } from "react";
|
|
2
2
|
import { SessionContext } from "#canvas/runtime";
|
|
3
3
|
import { useAnnotations } from "./AnnotationProvider";
|
|
4
4
|
import { ActiveViewContext } from "./App";
|
|
@@ -14,12 +14,13 @@ export function FileBrowser({ embedded }: { embedded?: boolean } = {}) {
|
|
|
14
14
|
const [collapsed, setCollapsed] = useState(false);
|
|
15
15
|
const [dirs, setDirs] = useState<Record<string, DirState>>({});
|
|
16
16
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
const { annCountMap, annotatedFiles } = useMemo(() => {
|
|
18
|
+
const map = new Map<string, number>();
|
|
19
|
+
for (const a of annotations) {
|
|
20
|
+
if (a.filePath) map.set(a.filePath, (map.get(a.filePath) || 0) + 1);
|
|
21
|
+
}
|
|
22
|
+
return { annCountMap: map, annotatedFiles: [...map.keys()] };
|
|
23
|
+
}, [annotations]);
|
|
23
24
|
|
|
24
25
|
const loadDir = async (path: string) => {
|
|
25
26
|
const res = await fetch(`/api/tree?session=${sessionId}&path=${encodeURIComponent(path)}`);
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import React, { useEffect, useState, useContext, useCallback, useRef, useMemo } from "react";
|
|
2
2
|
import { SessionContext } from "#canvas/runtime";
|
|
3
|
+
import type { AnnotationContext } from "#canvas/runtime";
|
|
3
4
|
import { useAnnotations } from "./AnnotationProvider";
|
|
4
5
|
import { wrapRangeWithMark, updateAllMarkStates, renameMarkId, unwrapMarks, restoreMarks } from "./highlightRange";
|
|
5
6
|
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
6
7
|
import { LANG_MAP } from "../langMap";
|
|
8
|
+
import { generateAnnotationId } from "./utils";
|
|
9
|
+
|
|
10
|
+
/** Walk up from a node to find the parent line div with data-line-num */
|
|
11
|
+
function findLineDiv(node: Node): HTMLElement | null {
|
|
12
|
+
let el: HTMLElement | null = node instanceof HTMLElement ? node : node.parentElement;
|
|
13
|
+
while (el) {
|
|
14
|
+
if (el.hasAttribute("data-line-num")) return el;
|
|
15
|
+
el = el.parentElement;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
7
19
|
|
|
8
20
|
interface FileViewerProps {
|
|
9
21
|
path: string;
|
|
@@ -21,9 +33,16 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
21
33
|
const [pendingMarkId, setPendingMarkId] = useState<string | null>(null);
|
|
22
34
|
const pendingSnippetRef = useRef("");
|
|
23
35
|
|
|
36
|
+
// Line gutter selection state
|
|
37
|
+
const [lineSelectStart, setLineSelectStart] = useState<number | null>(null);
|
|
38
|
+
const [lineSelectEnd, setLineSelectEnd] = useState<number | null>(null);
|
|
39
|
+
const lineSelectStartRef = useRef<number | null>(null);
|
|
40
|
+
const lineSelectEndRef = useRef<number | null>(null);
|
|
41
|
+
const linesRef = useRef<string[]>([]);
|
|
42
|
+
|
|
24
43
|
// Popover state
|
|
25
44
|
const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
|
|
26
|
-
const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string } | null>(null);
|
|
45
|
+
const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx?: AnnotationContext } | null>(null);
|
|
27
46
|
|
|
28
47
|
const fileAnns = annotations.filter((a) => a.filePath === path);
|
|
29
48
|
|
|
@@ -51,7 +70,11 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
51
70
|
}, [content, path]);
|
|
52
71
|
|
|
53
72
|
// Update mark active states
|
|
54
|
-
|
|
73
|
+
const prevActiveRef = useRef<string | null>(null);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
|
|
76
|
+
prevActiveRef.current = activeAnnotationId;
|
|
77
|
+
}, [activeAnnotationId]);
|
|
55
78
|
|
|
56
79
|
// Click handler for marks
|
|
57
80
|
useEffect(() => {
|
|
@@ -108,6 +131,30 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
108
131
|
if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
|
|
109
132
|
const snippet = sel.toString().trim();
|
|
110
133
|
if (snippet.length < 2) return;
|
|
134
|
+
|
|
135
|
+
// Compute line numbers from DOM
|
|
136
|
+
const startLineDiv = findLineDiv(range.startContainer);
|
|
137
|
+
const endLineDiv = findLineDiv(range.endContainer);
|
|
138
|
+
const lineStart = startLineDiv ? parseInt(startLineDiv.getAttribute("data-line-num")!, 10) : undefined;
|
|
139
|
+
const lineEnd = endLineDiv ? parseInt(endLineDiv.getAttribute("data-line-num")!, 10) : undefined;
|
|
140
|
+
|
|
141
|
+
// Compute before/after context for short snippets on a single line
|
|
142
|
+
let before = "";
|
|
143
|
+
let after = "";
|
|
144
|
+
if (snippet.length < 30 && startLineDiv && startLineDiv === endLineDiv) {
|
|
145
|
+
const code = startLineDiv.querySelector("code");
|
|
146
|
+
if (code) {
|
|
147
|
+
const lineText = code.textContent || "";
|
|
148
|
+
const idx = lineText.indexOf(snippet);
|
|
149
|
+
if (idx >= 0) {
|
|
150
|
+
before = lineText.slice(0, idx).trimStart();
|
|
151
|
+
after = lineText.slice(idx + snippet.length).trimEnd();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const ctx: AnnotationContext = { before, after, hierarchy: [], lineStart, lineEnd };
|
|
157
|
+
|
|
111
158
|
const tempId = `__pending_${Date.now()}`;
|
|
112
159
|
const cloned = range.cloneRange();
|
|
113
160
|
try { wrapRangeWithMark(cloned, tempId); } catch {}
|
|
@@ -119,7 +166,82 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
119
166
|
|
|
120
167
|
pendingSnippetRef.current = snippet;
|
|
121
168
|
setPendingMarkId(tempId);
|
|
122
|
-
setCreatePopover({ anchorEl: lastMark, tempId, snippet });
|
|
169
|
+
setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
// Line gutter: mousedown starts line selection
|
|
173
|
+
const handleGutterMouseDown = useCallback((e: React.MouseEvent, lineNum: number) => {
|
|
174
|
+
e.preventDefault(); // Prevent text selection
|
|
175
|
+
lineSelectStartRef.current = lineNum;
|
|
176
|
+
lineSelectEndRef.current = lineNum;
|
|
177
|
+
setLineSelectStart(lineNum);
|
|
178
|
+
setLineSelectEnd(lineNum);
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
// Line gutter: mousemove + mouseup for drag selection
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
const handleMouseMoveForLines = (e: MouseEvent) => {
|
|
184
|
+
if (lineSelectStartRef.current === null) return;
|
|
185
|
+
const lineDiv = (e.target as HTMLElement).closest("[data-line-num]") as HTMLElement;
|
|
186
|
+
if (lineDiv) {
|
|
187
|
+
const num = parseInt(lineDiv.getAttribute("data-line-num")!, 10);
|
|
188
|
+
lineSelectEndRef.current = num;
|
|
189
|
+
setLineSelectEnd(num);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const handleMouseUpForLines = () => {
|
|
194
|
+
const start = lineSelectStartRef.current;
|
|
195
|
+
if (start === null) return;
|
|
196
|
+
lineSelectStartRef.current = null;
|
|
197
|
+
const end = lineSelectEndRef.current ?? start;
|
|
198
|
+
const startLine = Math.min(start, end);
|
|
199
|
+
const endLine = Math.max(start, end);
|
|
200
|
+
const currentLines = linesRef.current;
|
|
201
|
+
const snippet = currentLines.slice(startLine - 1, endLine).join("\n");
|
|
202
|
+
if (!snippet.trim()) {
|
|
203
|
+
setLineSelectStart(null);
|
|
204
|
+
setLineSelectEnd(null);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const tempId = `__pending_${Date.now()}`;
|
|
209
|
+
const ctx: AnnotationContext = { before: "", after: "", hierarchy: [], lineStart: startLine, lineEnd: endLine };
|
|
210
|
+
|
|
211
|
+
// Wrap code content of selected lines with marks
|
|
212
|
+
if (contentRef.current) {
|
|
213
|
+
const lineDivs = contentRef.current.querySelectorAll("[data-line-num]");
|
|
214
|
+
const firstDiv = lineDivs[startLine - 1] as HTMLElement;
|
|
215
|
+
const lastDiv = lineDivs[endLine - 1] as HTMLElement;
|
|
216
|
+
if (firstDiv && lastDiv) {
|
|
217
|
+
const firstCode = firstDiv.querySelector("code");
|
|
218
|
+
const lastCode = lastDiv.querySelector("code");
|
|
219
|
+
if (firstCode && lastCode) {
|
|
220
|
+
try {
|
|
221
|
+
const range = document.createRange();
|
|
222
|
+
range.setStartBefore(firstCode.firstChild || firstCode);
|
|
223
|
+
range.setEndAfter(lastCode.lastChild || lastCode);
|
|
224
|
+
wrapRangeWithMark(range, tempId);
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const anchorEl = lineDivs[endLine - 1] as HTMLElement;
|
|
229
|
+
if (anchorEl) {
|
|
230
|
+
pendingSnippetRef.current = snippet;
|
|
231
|
+
setPendingMarkId(tempId);
|
|
232
|
+
setCreatePopover({ anchorEl, tempId, snippet, ctx });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
setLineSelectStart(null);
|
|
236
|
+
setLineSelectEnd(null);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
document.addEventListener("mousemove", handleMouseMoveForLines);
|
|
240
|
+
document.addEventListener("mouseup", handleMouseUpForLines);
|
|
241
|
+
return () => {
|
|
242
|
+
document.removeEventListener("mousemove", handleMouseMoveForLines);
|
|
243
|
+
document.removeEventListener("mouseup", handleMouseUpForLines);
|
|
244
|
+
};
|
|
123
245
|
}, []);
|
|
124
246
|
|
|
125
247
|
// Highlight content
|
|
@@ -140,10 +262,18 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
140
262
|
if (!content) return null;
|
|
141
263
|
|
|
142
264
|
const lines = content.split("\n");
|
|
265
|
+
linesRef.current = lines;
|
|
266
|
+
|
|
267
|
+
// Computed selected line range for highlighting during drag
|
|
268
|
+
const selectedLineRange = useMemo(() => {
|
|
269
|
+
if (lineSelectStart === null) return null;
|
|
270
|
+
const end = lineSelectEnd ?? lineSelectStart;
|
|
271
|
+
return { start: Math.min(lineSelectStart, end), end: Math.max(lineSelectStart, end) };
|
|
272
|
+
}, [lineSelectStart, lineSelectEnd]);
|
|
143
273
|
|
|
144
274
|
const addWholeFileWithPopup = () => {
|
|
145
275
|
const preview = content!.split("\n").slice(0, 3).join("\n") + (lines.length > 3 ? "\n..." : "");
|
|
146
|
-
const id =
|
|
276
|
+
const id = generateAnnotationId();
|
|
147
277
|
addAnnotationWithId(id, preview, "", path);
|
|
148
278
|
// Focus the new annotation's note in sidebar by activating it
|
|
149
279
|
setActiveAnnotationId(id);
|
|
@@ -155,16 +285,23 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
155
285
|
<div>
|
|
156
286
|
{/* Code */}
|
|
157
287
|
<pre ref={contentRef} className="text-code font-mono text-text-code bg-bg-base select-text cursor-text py-3 hljs">
|
|
158
|
-
{lines.map((line, i) =>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
288
|
+
{lines.map((line, i) => {
|
|
289
|
+
const lineNum = i + 1;
|
|
290
|
+
const isSelected = selectedLineRange && lineNum >= selectedLineRange.start && lineNum <= selectedLineRange.end;
|
|
291
|
+
return (
|
|
292
|
+
<div key={i} data-line-num={lineNum} className={`flex px-5 ${isSelected ? "bg-accent-blue/10" : "hover:bg-bg-elevated-half"}`}>
|
|
293
|
+
<span
|
|
294
|
+
className="text-text-tertiary select-none w-10 text-right pr-4 shrink-0 py-px text-[12px] cursor-pointer opacity-40 hover:opacity-100 hover:text-accent-blue"
|
|
295
|
+
onMouseDown={(e) => handleGutterMouseDown(e, lineNum)}
|
|
296
|
+
>{lineNum}</span>
|
|
297
|
+
{highlightedLines ? (
|
|
298
|
+
<code className="py-px" dangerouslySetInnerHTML={{ __html: highlightedLines[i] || " " }} />
|
|
299
|
+
) : (
|
|
300
|
+
<code className="py-px">{line || " "}</code>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
})}
|
|
168
305
|
</pre>
|
|
169
306
|
|
|
170
307
|
{editPopover && (() => {
|
|
@@ -188,9 +325,9 @@ export function FileViewer({ path }: FileViewerProps) {
|
|
|
188
325
|
scrollContainer={scrollContainer}
|
|
189
326
|
snippet={createPopover.snippet}
|
|
190
327
|
onAdd={(note) => {
|
|
191
|
-
const id =
|
|
328
|
+
const id = generateAnnotationId();
|
|
192
329
|
renameMarkId(createPopover.tempId, id);
|
|
193
|
-
addAnnotationWithId(id, createPopover.snippet, note, path);
|
|
330
|
+
addAnnotationWithId(id, createPopover.snippet, note, path, createPopover.ctx);
|
|
194
331
|
setPendingMarkId(null);
|
|
195
332
|
setCreatePopover(null);
|
|
196
333
|
}}
|