agent-canvas 0.1.4 → 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.
- 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 +66 -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/daemon/build.ts +27 -61
- package/daemon/client/AnnotationProvider.tsx +2 -2
- package/daemon/client/AnnotationSidebar.tsx +199 -31
- package/daemon/client/App.tsx +4 -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/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 +41 -23
- package/daemon/src/websocket.ts +83 -0
- package/package.json +3 -3
- package/skills/canvas/SKILL.md +32 -22
- package/skills/canvas/components.md +31 -0
- package/skills/canvas/flows.md +2 -2
- 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
|
@@ -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
|
|
71
|
-
|
|
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/
|
|
105
|
-
"react-dom": "/assets/
|
|
106
|
-
"react-dom/client": "/assets/
|
|
107
|
-
"react/jsx-runtime": "/assets/jsx-runtime
|
|
108
|
-
"react/jsx-dev-runtime": "/assets/jsx-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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 =
|
|
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
|
|
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)}`);
|