agent-canvas 0.1.5 → 0.1.7

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/bin/agent-canvas.ts +26 -353
  4. package/bin/lib/commands/daemon.ts +51 -0
  5. package/bin/lib/commands/fetch.ts +20 -0
  6. package/bin/lib/commands/install.ts +48 -0
  7. package/bin/lib/commands/push.ts +66 -0
  8. package/bin/lib/commands/watch.ts +23 -0
  9. package/bin/lib/config.ts +10 -0
  10. package/bin/lib/daemon-lifecycle.ts +74 -0
  11. package/bin/lib/helpers.ts +78 -0
  12. package/daemon/build.ts +27 -61
  13. package/daemon/client/AnnotationProvider.tsx +2 -2
  14. package/daemon/client/AnnotationSidebar.tsx +199 -31
  15. package/daemon/client/App.tsx +4 -3
  16. package/daemon/client/FileBrowser.tsx +8 -7
  17. package/daemon/client/FileViewer.tsx +153 -16
  18. package/daemon/client/PlanRenderer.tsx +6 -3
  19. package/daemon/client/ResponsePreview.tsx +5 -116
  20. package/daemon/client/SessionSwitcher.tsx +21 -10
  21. package/daemon/client/compat/preact-all.ts +4 -0
  22. package/daemon/client/components/Markdown.tsx +44 -0
  23. package/daemon/client/components/ResponseNote.tsx +3 -8
  24. package/daemon/client/components/UserInput.tsx +2 -4
  25. package/daemon/client/components/index.ts +1 -0
  26. package/daemon/client/domDiff.ts +82 -2
  27. package/daemon/client/generateMarkdown.ts +46 -9
  28. package/daemon/client/highlightRange.ts +8 -6
  29. package/daemon/client/runtime.ts +2 -0
  30. package/daemon/client/styles.css +126 -0
  31. package/daemon/client/unifiedDiff.ts +155 -2
  32. package/daemon/client/utils.ts +10 -0
  33. package/daemon/dist/client.css +1 -1
  34. package/daemon/dist/client.js +83 -36
  35. package/daemon/dist/components.js +63 -5
  36. package/daemon/dist/index.html +5 -12
  37. package/daemon/dist/jsx-runtime.js +3 -0
  38. package/daemon/dist/preact-compat.js +1 -0
  39. package/daemon/src/compiler.ts +28 -2
  40. package/daemon/src/handlers/api.ts +150 -0
  41. package/daemon/src/handlers/files.ts +114 -0
  42. package/daemon/src/handlers/static.ts +61 -0
  43. package/daemon/src/handlers/utils.ts +14 -0
  44. package/daemon/src/router.ts +16 -0
  45. package/daemon/src/server.ts +28 -403
  46. package/daemon/src/session.ts +41 -23
  47. package/daemon/src/websocket.ts +83 -0
  48. package/package.json +3 -3
  49. package/skills/canvas/SKILL.md +32 -22
  50. package/skills/canvas/components.md +31 -0
  51. package/skills/canvas/flows.md +2 -2
  52. package/daemon/dist/jsx-dev-runtime-shim.js +0 -17
  53. package/daemon/dist/jsx-runtime-shim.js +0 -17
  54. package/daemon/dist/react-dom-client-shim.js +0 -4
  55. package/daemon/dist/react-dom-shim.js +0 -4
  56. package/daemon/dist/react-shim.js +0 -4
@@ -0,0 +1,23 @@
1
+ import { parseArgs } from "util";
2
+ import { ensureDaemon } from "../daemon-lifecycle.ts";
3
+ import { getSessionId, consumeFeedback, waitForFeedback } from "../helpers.ts";
4
+
5
+ export async function handleWatch(args: string[]) {
6
+ const { values } = parseArgs({
7
+ args,
8
+ options: {
9
+ session: { type: "string" },
10
+ },
11
+ });
12
+
13
+ const sessionId = getSessionId(values.session);
14
+ await ensureDaemon();
15
+
16
+ const feedback = await consumeFeedback(sessionId);
17
+ if (feedback) {
18
+ process.stdout.write(feedback);
19
+ process.exit(0);
20
+ }
21
+
22
+ await waitForFeedback(sessionId);
23
+ }
@@ -0,0 +1,10 @@
1
+ import { resolve, join, dirname } from "path";
2
+ import { tmpdir } from "os";
3
+
4
+ export const PACKAGE_ROOT = resolve(join(dirname(import.meta.path), "../.."));
5
+ export const TEMP_DIR = join(tmpdir(), "agent-canvas");
6
+ export const DAEMON_PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
7
+ export const BASE_URL = `http://localhost:${DAEMON_PORT}`;
8
+ export const WS_URL = `ws://localhost:${DAEMON_PORT}`;
9
+ export const TIMEOUT_MS = parseInt(process.env.CANVAS_TIMEOUT || String(60 * 60 * 1000), 10);
10
+ export const PID_FILE = join(TEMP_DIR, "daemon.pid");
@@ -0,0 +1,74 @@
1
+ import { spawn } from "child_process";
2
+ import { openSync, closeSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
3
+ import { join } from "path";
4
+ import { PACKAGE_ROOT, TEMP_DIR, BASE_URL, PID_FILE } from "./config.ts";
5
+
6
+ export async function isDaemonRunning(): Promise<boolean> {
7
+ try {
8
+ const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(1000) });
9
+ const data = await res.json() as any;
10
+ return data.ok === true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ export async function startDaemon(): Promise<void> {
17
+ console.error("Starting canvas daemon...");
18
+ mkdirSync(TEMP_DIR, { recursive: true });
19
+
20
+ const daemonScript = join(PACKAGE_ROOT, "daemon", "src", "server.ts");
21
+ const stderrPath = join(TEMP_DIR, "daemon-startup.log");
22
+ const stderrFd = openSync(stderrPath, "w");
23
+
24
+ const child = spawn("bun", ["run", daemonScript], {
25
+ detached: true,
26
+ stdio: ["ignore", "ignore", stderrFd],
27
+ cwd: join(PACKAGE_ROOT, "daemon"),
28
+ });
29
+
30
+ child.unref();
31
+ closeSync(stderrFd);
32
+
33
+ for (let i = 0; i < 30; i++) {
34
+ await new Promise((r) => setTimeout(r, 100));
35
+ if (await isDaemonRunning()) {
36
+ console.error("Daemon started.");
37
+ return;
38
+ }
39
+ }
40
+
41
+ if (existsSync(stderrPath)) {
42
+ const stderr = readFileSync(stderrPath, "utf-8").trim();
43
+ if (stderr) {
44
+ console.error("Daemon stderr:\n" + stderr);
45
+ }
46
+ }
47
+
48
+ console.error("Error: Daemon failed to start within 3 seconds.");
49
+ process.exit(1);
50
+ }
51
+
52
+ export async function ensureDaemon(): Promise<void> {
53
+ if (await isDaemonRunning()) return;
54
+ await startDaemon();
55
+ }
56
+
57
+ export function stopDaemon(): boolean {
58
+ if (existsSync(PID_FILE)) {
59
+ const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
60
+ try {
61
+ process.kill(pid, "SIGTERM");
62
+ console.log("Daemon stopped.");
63
+ try { unlinkSync(PID_FILE); } catch {}
64
+ return true;
65
+ } catch {
66
+ console.log("Daemon was not running (stale PID file).");
67
+ try { unlinkSync(PID_FILE); } catch {}
68
+ return false;
69
+ }
70
+ } else {
71
+ console.log("No daemon PID file found.");
72
+ return false;
73
+ }
74
+ }
@@ -0,0 +1,78 @@
1
+ import { spawn } from "child_process";
2
+ import { randomUUID } from "crypto";
3
+ import { BASE_URL, WS_URL, TIMEOUT_MS } from "./config.ts";
4
+
5
+ export function getSessionId(session?: string): string {
6
+ if (session) return session;
7
+ return process.env.CANVAS_SESSION_ID || (() => {
8
+ const id = randomUUID();
9
+ console.error(`Warning: CANVAS_SESSION_ID not set, using generated ID: ${id}`);
10
+ return id;
11
+ })();
12
+ }
13
+
14
+ export async function consumeFeedback(sessionId: string): Promise<string | null> {
15
+ const res = await fetch(`${BASE_URL}/api/session/${sessionId}/feedback/consume`, { method: "POST" });
16
+ const data = await res.json() as any;
17
+ if (data.found) return data.feedback;
18
+ return null;
19
+ }
20
+
21
+ export function openBrowser(url: string) {
22
+ const candidates = process.platform === "darwin"
23
+ ? ["open"]
24
+ : ["xdg-open", "wslview", "sensible-browser"];
25
+
26
+ for (const cmd of candidates) {
27
+ const which = Bun.spawnSync(["which", cmd]);
28
+ if (which.exitCode === 0) {
29
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
30
+ return;
31
+ }
32
+ }
33
+ console.error(`Open this URL in your browser: ${url}`);
34
+ }
35
+
36
+ export async function waitForFeedback(sessionId: string): Promise<void> {
37
+ return new Promise((resolveP, rejectP) => {
38
+ const ws = new WebSocket(`${WS_URL}/ws/wait/${sessionId}`);
39
+ const timeout = setTimeout(() => {
40
+ ws.close();
41
+ console.error("Error: Timeout waiting for feedback.");
42
+ process.exit(1);
43
+ }, TIMEOUT_MS);
44
+
45
+ ws.onmessage = (event) => {
46
+ try {
47
+ const data = JSON.parse(typeof event.data === "string" ? event.data : "");
48
+ if (data.type === "submit") {
49
+ clearTimeout(timeout);
50
+ process.stdout.write(data.feedback);
51
+ ws.close();
52
+ process.exit(0);
53
+ }
54
+ } catch {}
55
+ };
56
+
57
+ ws.onerror = () => {
58
+ clearTimeout(timeout);
59
+ console.error("Error: WebSocket connection failed.");
60
+ process.exit(1);
61
+ };
62
+
63
+ ws.onclose = () => {
64
+ clearTimeout(timeout);
65
+ };
66
+ });
67
+ }
68
+
69
+ export async function readLine(): Promise<string> {
70
+ const buf: number[] = [];
71
+ for await (const chunk of Bun.stdin.stream()) {
72
+ for (const byte of chunk) {
73
+ if (byte === 10) return Buffer.from(buf).toString("utf-8");
74
+ buf.push(byte);
75
+ }
76
+ }
77
+ return Buffer.from(buf).toString("utf-8");
78
+ }
package/daemon/build.ts CHANGED
@@ -12,6 +12,26 @@ async function build() {
12
12
 
13
13
  const REACT_EXTERNALS = ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "react/jsx-dev-runtime"];
14
14
 
15
+ // 0. Build preact-compat bundle
16
+ console.log(" Building preact-compat...");
17
+ const preactResult = await Bun.build({
18
+ entrypoints: [join(ROOT, "client/compat/preact-all.ts")],
19
+ outdir: DIST,
20
+ format: "esm",
21
+ naming: "preact-compat.js",
22
+ minify: true,
23
+ });
24
+
25
+ if (!preactResult.success) {
26
+ console.error("Preact-compat build failed:", preactResult.logs);
27
+ process.exit(1);
28
+ }
29
+
30
+ // Write jsx-runtime shim
31
+ writeFileSync(join(DIST, "jsx-runtime.js"),
32
+ `import { jsx, jsxs, Fragment } from "./preact-compat.js";\nexport { jsx, jsxs, Fragment };\nexport const jsxDEV = jsx;\n`
33
+ );
34
+
15
35
  // 1. Build runtime (shared context between app and components)
16
36
  console.log(" Building #canvas/runtime...");
17
37
  const runtimeResult = await Bun.build({
@@ -67,11 +87,9 @@ async function build() {
67
87
  // 5. Read theme.css to inline into HTML for browser Tailwind runtime
68
88
  const themeCss = readFileSync(join(ROOT, "client/theme.css"), "utf-8");
69
89
 
70
- // 6. Create React shims for import maps
71
- // These re-export from the global React loaded via UMD
72
- console.log(" Creating React shims...");
90
+ // 6. Create index.html
91
+ console.log(" Creating index.html...");
73
92
 
74
- // Write the index.html to use CDN React
75
93
  const indexHtml = `<!DOCTYPE html>
76
94
  <html lang="en" data-theme="dark">
77
95
  <head>
@@ -91,21 +109,14 @@ ${themeCss}
91
109
  <script src="https://unpkg.com/@highlightjs/cdn-assets@11.11.1/highlight.min.js"></script>
92
110
  <script src="https://unpkg.com/mermaid@11.4.1/dist/mermaid.min.js"></script>
93
111
  <script>mermaid.initialize({ startOnLoad: false, theme: document.documentElement.dataset.theme === 'light' ? 'default' : 'dark' });</script>
94
- <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
95
- <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
96
- <script>
97
- // Create ESM modules from UMD globals
98
- window.__REACT_ESM__ = Object.keys(React).reduce((m, k) => { m[k] = React[k]; return m; }, { default: React, __esModule: true });
99
- window.__REACT_DOM_ESM__ = Object.keys(ReactDOM).reduce((m, k) => { m[k] = ReactDOM[k]; return m; }, { default: ReactDOM, __esModule: true });
100
- </script>
101
112
  <script type="importmap">
102
113
  {
103
114
  "imports": {
104
- "react": "/assets/react-shim.js",
105
- "react-dom": "/assets/react-dom-shim.js",
106
- "react-dom/client": "/assets/react-dom-client-shim.js",
107
- "react/jsx-runtime": "/assets/jsx-runtime-shim.js",
108
- "react/jsx-dev-runtime": "/assets/jsx-dev-runtime-shim.js",
115
+ "react": "/assets/preact-compat.js",
116
+ "react-dom": "/assets/preact-compat.js",
117
+ "react-dom/client": "/assets/preact-compat.js",
118
+ "react/jsx-runtime": "/assets/jsx-runtime.js",
119
+ "react/jsx-dev-runtime": "/assets/jsx-runtime.js",
109
120
  "#canvas/components": "/assets/components.js",
110
121
  "#canvas/runtime": "/assets/runtime.js"
111
122
  }
@@ -117,51 +128,6 @@ ${themeCss}
117
128
 
118
129
  writeFileSync(join(DIST, "index.html"), indexHtml);
119
130
 
120
- // React ESM shims
121
- writeFileSync(join(DIST, "react-shim.js"), `
122
- const R = window.React;
123
- export default R;
124
- export const { useState, useEffect, useRef, useCallback, useContext, useMemo, useReducer, createContext, createElement, Fragment, Children, cloneElement, isValidElement, memo, forwardRef, lazy, Suspense, startTransition, useTransition, useDeferredValue, useId, useSyncExternalStore, useInsertionEffect, useImperativeHandle, useLayoutEffect, useDebugValue, Component, PureComponent } = R;
125
- `);
126
-
127
- writeFileSync(join(DIST, "react-dom-shim.js"), `
128
- const RD = window.ReactDOM;
129
- export default RD;
130
- export const { createPortal, flushSync, hydrate, render, unmountComponentAtNode, findDOMNode, unstable_batchedUpdates } = RD;
131
- `);
132
-
133
- writeFileSync(join(DIST, "react-dom-client-shim.js"), `
134
- const RD = window.ReactDOM;
135
- export const { createRoot, hydrateRoot } = RD;
136
- export default { createRoot: RD.createRoot, hydrateRoot: RD.hydrateRoot };
137
- `);
138
-
139
- // jsxDEV signature: (type, props, key, isStaticChildren, source, self)
140
- // jsx/jsxs signature: (type, props, key)
141
- // createElement signature: (type, props, ...children)
142
- // We need to adapt: extract children from props and pass to createElement
143
- const jsxShimCode = `
144
- const R = window.React;
145
- export const Fragment = R.Fragment;
146
- function _jsx(type, props, key) {
147
- if (props && 'children' in props) {
148
- const { children, ...rest } = props;
149
- if (key !== undefined && key !== null) rest.key = key;
150
- return Array.isArray(children)
151
- ? R.createElement(type, rest, ...children)
152
- : R.createElement(type, rest, children);
153
- }
154
- if (key !== undefined && key !== null) props = { ...props, key };
155
- return R.createElement(type, props);
156
- }
157
- export const jsx = _jsx;
158
- export const jsxs = _jsx;
159
- export function jsxDEV(type, props, key) { return _jsx(type, props, key); }
160
- `;
161
-
162
- writeFileSync(join(DIST, "jsx-runtime-shim.js"), jsxShimCode);
163
- writeFileSync(join(DIST, "jsx-dev-runtime-shim.js"), jsxShimCode);
164
-
165
131
  console.log("Build complete! Output in dist/");
166
132
  }
167
133
 
@@ -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
- const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
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, getMissingRequired, getMissingRequiredFeedback } from "./generateMarkdown";
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 <FeedbackDisplay sessionId={sessionId} revision={selectedRevision} label={roundLabel} waitingForUpdate={isCurrentButSubmitted} collapseButton={collapseButton} />;
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
- 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
- }}
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
- 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
- }}
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 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
- ];
455
+ const allMissing = getMissingRequiredLabels(responses, feedbackEntries);
288
456
  if (allMissing.length > 0) {
289
457
  setValidationError(`Please answer: ${allMissing.join(", ")}`);
290
458
  return;
@@ -11,6 +11,7 @@ import { SessionSwitcher } from "./SessionSwitcher";
11
11
  import { exportCanvasToMarkdown } from "./exportMarkdown";
12
12
  import { CompareView } from "./CompareView";
13
13
  import { RevisionSelect } from "./RevisionSelect";
14
+ import { generateAnnotationId } from "./utils";
14
15
 
15
16
  export type ActiveView = { type: "plan" } | { type: "file"; path: string };
16
17
 
@@ -101,7 +102,7 @@ function RevisionSelector() {
101
102
  const isLatest = selectedRevision === currentRevision;
102
103
 
103
104
  return (
104
- <div className="flex items-center gap-1.5">
105
+ <div className="flex flex-col gap-1">
105
106
  <RevisionSelect
106
107
  value={selectedRevision}
107
108
  onChange={setSelectedRevision}
@@ -123,7 +124,7 @@ function RevisionSelector() {
123
124
  }
124
125
  setCompareRevision({ left, right: currentRevision });
125
126
  }}
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"
127
+ 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
128
  >
128
129
  Compare
129
130
  </button>
@@ -595,7 +596,7 @@ function ContentTabs() {
595
596
  const data = await res.json() as any;
596
597
  if (!data.error) {
597
598
  const preview = data.content.split("\n").slice(0, 3).join("\n") + (data.content.split("\n").length > 3 ? "\n..." : "");
598
- const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
599
+ const id = generateAnnotationId();
599
600
  addAnnotationWithId(id, preview, "", path);
600
601
  setActiveAnnotationId(id);
601
602
  }
@@ -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 fileAnnotations = annotations.filter((a) => a.filePath);
18
- const annCountMap = new Map<string, number>();
19
- for (const a of fileAnnotations) {
20
- annCountMap.set(a.filePath!, (annCountMap.get(a.filePath!) || 0) + 1);
21
- }
22
- const annotatedFiles = [...annCountMap.keys()];
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)}`);