agent-canvas 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/commands/push.ts +26 -10
- package/bun.lock +3 -0
- package/daemon/client/AnnotationProvider.tsx +2 -2
- package/daemon/client/App.tsx +125 -47
- package/daemon/client/CompareView.tsx +161 -31
- package/daemon/client/OverviewView.tsx +326 -0
- package/daemon/client/PlanRenderer.tsx +13 -8
- package/daemon/client/RevisionSelect.tsx +16 -7
- package/daemon/client/components/Mermaid.tsx +3 -2
- package/daemon/client/generateMarkdown.ts +24 -5
- package/daemon/client/runtime.ts +9 -2
- package/daemon/client/useTextAnnotation.tsx +6 -2
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +58 -58
- package/daemon/dist/components.js +23 -23
- package/daemon/dist/runtime.js +1 -1
- package/daemon/src/compiler.ts +100 -0
- package/daemon/src/handlers/api.ts +59 -24
- package/daemon/src/session.ts +140 -41
- package/daemon/src/watcher.ts +42 -30
- package/package.json +4 -3
- package/skills/canvas/SKILL.md +24 -19
- package/skills/canvas/components.md +29 -15
- package/skills/canvas/flows.md +1 -1
package/daemon/src/compiler.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { writeFileSync, unlinkSync, readFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
|
+
import { h, Fragment } from "preact";
|
|
5
|
+
import renderToString from "preact-render-to-string";
|
|
4
6
|
import { COMPILE_TEMP_DIR } from "./paths";
|
|
5
7
|
|
|
6
8
|
type CompileResult =
|
|
@@ -15,6 +17,96 @@ const { Section, Item, Task, FilePreview, CodeBlock, Callout,
|
|
|
15
17
|
Markdown, useFeedback, useAnnotations } = C;
|
|
16
18
|
`;
|
|
17
19
|
|
|
20
|
+
// --- Validation via Preact --------------------------------------------------
|
|
21
|
+
// After Bun.build() succeeds we strip ESM syntax from the compiled output,
|
|
22
|
+
// provide Preact as the React implementation with lightweight stub components,
|
|
23
|
+
// and actually render the component tree. Any runtime error (undefined vars,
|
|
24
|
+
// broken template literals, …) is caught and reported back to the CLI.
|
|
25
|
+
|
|
26
|
+
/** jsxDEV(type, props, key, isStaticChildren, source, self) → VNode */
|
|
27
|
+
function mockJsxDEV(type: any, props: any, key?: any) {
|
|
28
|
+
const { children, ...rest } = props || {};
|
|
29
|
+
return h(type, { ...rest, key }, children);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Passthrough component — just renders its children */
|
|
33
|
+
const Stub = ({ children }: any) => h(Fragment, null, children);
|
|
34
|
+
|
|
35
|
+
const STUB_COMPONENTS: Record<string, any> = {};
|
|
36
|
+
for (const name of [
|
|
37
|
+
"Section", "Item", "Task", "FilePreview", "CodeBlock", "Callout",
|
|
38
|
+
"Mermaid", "Table", "Priority", "Checklist", "Note", "Diff",
|
|
39
|
+
"Choice", "MultiChoice", "UserInput", "RangeInput", "ImageView", "Markdown",
|
|
40
|
+
]) {
|
|
41
|
+
STUB_COMPONENTS[name] = Stub;
|
|
42
|
+
}
|
|
43
|
+
// Hook stubs — return shapes that won't blow up when destructured
|
|
44
|
+
STUB_COMPONENTS.useFeedback = () => ({ submit: () => {}, value: null });
|
|
45
|
+
STUB_COMPONENTS.useAnnotations = () => ({ annotations: [], addAnnotation: () => {} });
|
|
46
|
+
|
|
47
|
+
const MOCK_REACT = {
|
|
48
|
+
createElement: h,
|
|
49
|
+
Fragment,
|
|
50
|
+
useState: (init: any) => [init, () => {}],
|
|
51
|
+
useEffect: () => {},
|
|
52
|
+
useRef: (init: any) => ({ current: init }),
|
|
53
|
+
useMemo: (fn: any) => fn(),
|
|
54
|
+
useCallback: (fn: any) => fn,
|
|
55
|
+
useReducer: (r: any, init: any) => [init, () => {}],
|
|
56
|
+
useContext: () => ({}),
|
|
57
|
+
createContext: () => ({ Provider: Stub, Consumer: Stub }),
|
|
58
|
+
useLayoutEffect: () => {},
|
|
59
|
+
useImperativeHandle: () => {},
|
|
60
|
+
useDebugValue: () => {},
|
|
61
|
+
useDeferredValue: (v: any) => v,
|
|
62
|
+
useTransition: () => [false, (fn: any) => fn()],
|
|
63
|
+
useId: () => "mock",
|
|
64
|
+
useSyncExternalStore: (sub: any, get: any) => get(),
|
|
65
|
+
memo: (c: any) => c,
|
|
66
|
+
forwardRef: (c: any) => c,
|
|
67
|
+
lazy: (c: any) => c,
|
|
68
|
+
Children: { map: (c: any, fn: any) => (Array.isArray(c) ? c : [c]).map(fn), toArray: (c: any) => (Array.isArray(c) ? c : [c]) },
|
|
69
|
+
isValidElement: () => true,
|
|
70
|
+
cloneElement: h,
|
|
71
|
+
Suspense: Stub,
|
|
72
|
+
StrictMode: Stub,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function validateCompiledPlan(js: string): { ok: true } | { ok: false; error: string } {
|
|
76
|
+
let code = js;
|
|
77
|
+
|
|
78
|
+
// Strip ESM import statements
|
|
79
|
+
code = code.replace(/import\s+\w+\s+from\s+"[^"]+";?/g, "");
|
|
80
|
+
code = code.replace(/import\s+\*\s+as\s+\w+\s+from\s+"[^"]+";?/g, "");
|
|
81
|
+
code = code.replace(/import\s*\{[^}]*\}\s*from\s+"[^"]+";?/g, "");
|
|
82
|
+
|
|
83
|
+
// Strip export, extract default name
|
|
84
|
+
let defaultName: string | null = null;
|
|
85
|
+
code = code.replace(/export\s*\{\s*(\w+)\s+as\s+default\s*\};?/g, (_, name) => {
|
|
86
|
+
defaultName = name;
|
|
87
|
+
return "";
|
|
88
|
+
});
|
|
89
|
+
if (!defaultName) {
|
|
90
|
+
code = code.replace(/export\s+default\s+(\w+);?/g, (_, name) => {
|
|
91
|
+
defaultName = name;
|
|
92
|
+
return "";
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const callDefault = defaultName
|
|
97
|
+
? `\nreturn typeof ${defaultName} === "function" ? ${defaultName}() : null;`
|
|
98
|
+
: "\nreturn null;";
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const fn = new Function("React", "C", "jsxDEV", "jsx", "Fragment", code + callDefault);
|
|
102
|
+
const vnode = fn(MOCK_REACT, STUB_COMPONENTS, mockJsxDEV, mockJsxDEV, Fragment);
|
|
103
|
+
if (vnode) renderToString(vnode);
|
|
104
|
+
return { ok: true };
|
|
105
|
+
} catch (e: any) {
|
|
106
|
+
return { ok: false, error: e?.message || String(e) };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
18
110
|
export async function compilePlan(jsx: string, projectRoot?: string): Promise<CompileResult> {
|
|
19
111
|
// Resolve file contents at compile time
|
|
20
112
|
if (projectRoot) {
|
|
@@ -49,6 +141,10 @@ export async function compilePlan(jsx: string, projectRoot?: string): Promise<Co
|
|
|
49
141
|
}
|
|
50
142
|
|
|
51
143
|
const js = await result.outputs[0].text();
|
|
144
|
+
const validation = validateCompiledPlan(js);
|
|
145
|
+
if (!validation.ok) {
|
|
146
|
+
return { ok: false, error: `Runtime error: ${validation.error}` };
|
|
147
|
+
}
|
|
52
148
|
return { ok: true, js };
|
|
53
149
|
} catch {
|
|
54
150
|
// Bun.build() can throw "Unknown Error, TODO" in long-running processes
|
|
@@ -78,6 +174,10 @@ async function compilePlanSubprocess(tmpFile: string): Promise<CompileResult> {
|
|
|
78
174
|
}
|
|
79
175
|
try {
|
|
80
176
|
const js = readFileSync(outFile, "utf-8");
|
|
177
|
+
const validation = validateCompiledPlan(js);
|
|
178
|
+
if (!validation.ok) {
|
|
179
|
+
return { ok: false, error: `Runtime error: ${validation.error}` };
|
|
180
|
+
}
|
|
81
181
|
return { ok: true, js };
|
|
82
182
|
} finally {
|
|
83
183
|
try { unlinkSync(outFile); } catch {}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from "fs";
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from "fs";
|
|
2
2
|
import type { SessionManager } from "../session";
|
|
3
3
|
import { compilePlan } from "../compiler";
|
|
4
4
|
import { watchSession } from "../watcher";
|
|
@@ -11,24 +11,36 @@ export interface ApiContext {
|
|
|
11
11
|
port: number;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
function readRevisionJsx(sessionManager: SessionManager, sessionId: string, rev: number): string | null {
|
|
15
|
-
try {
|
|
16
|
-
const path = sessionManager.getRevisionJsxPath(sessionId, rev);
|
|
17
|
-
return readFileSync(path, "utf-8");
|
|
18
|
-
} catch {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
14
|
export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
24
15
|
const { sessionManager, broadcastPlanUpdate, port } = ctx;
|
|
25
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Read *.jsx canvas files from a directory.
|
|
19
|
+
* Returns a Map of filename -> JSX content.
|
|
20
|
+
*/
|
|
21
|
+
function resolveCanvasFiles(directory: string): Map<string, string> | null {
|
|
22
|
+
if (!existsSync(directory)) return null;
|
|
23
|
+
const files = readdirSync(directory).filter(f => f.endsWith(".jsx")).sort();
|
|
24
|
+
if (files.length === 0) return null;
|
|
25
|
+
const map = new Map<string, string>();
|
|
26
|
+
for (const f of files) {
|
|
27
|
+
map.set(f, readFileSync(`${directory}/${f}`, "utf-8"));
|
|
28
|
+
}
|
|
29
|
+
return map;
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
async function handlePlanPost(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
27
33
|
const sessionId = match.pathname.groups.id!;
|
|
28
34
|
try {
|
|
29
35
|
const body = await req.json();
|
|
30
|
-
|
|
31
|
-
if (!
|
|
36
|
+
|
|
37
|
+
if (!body.directory) {
|
|
38
|
+
return jsonResponse({ error: "Missing directory" }, 400);
|
|
39
|
+
}
|
|
40
|
+
const canvasFiles = resolveCanvasFiles(body.directory);
|
|
41
|
+
if (!canvasFiles) {
|
|
42
|
+
return jsonResponse({ error: "No .jsx files found in directory" }, 400);
|
|
43
|
+
}
|
|
32
44
|
|
|
33
45
|
const unconsumed = sessionManager.getLatestUnconsumedFeedback(sessionId);
|
|
34
46
|
if (unconsumed) {
|
|
@@ -41,12 +53,26 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
|
41
53
|
}, 409);
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
const projectRoot = body.projectRoot || process.cwd();
|
|
44
57
|
const isNew = !sessionManager.get(sessionId);
|
|
45
|
-
const session = sessionManager.upsert(sessionId,
|
|
58
|
+
const session = sessionManager.upsert(sessionId, canvasFiles, projectRoot, body.label, body.response);
|
|
59
|
+
|
|
60
|
+
// Compile all canvas files in parallel
|
|
61
|
+
const errors: Record<string, string> = {};
|
|
62
|
+
let anyOk = false;
|
|
63
|
+
await Promise.all(
|
|
64
|
+
[...canvasFiles.entries()].map(async ([filename, jsx]) => {
|
|
65
|
+
const result = await compilePlan(jsx, session.projectRoot);
|
|
66
|
+
if (result.ok) {
|
|
67
|
+
sessionManager.saveCompiled(sessionId, filename, result.js, session.currentRevision);
|
|
68
|
+
anyOk = true;
|
|
69
|
+
} else {
|
|
70
|
+
errors[filename] = result.error;
|
|
71
|
+
}
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
46
74
|
|
|
47
|
-
|
|
48
|
-
if (result.ok) {
|
|
49
|
-
sessionManager.saveCompiled(sessionId, result.js, session.currentRevision);
|
|
75
|
+
if (anyOk) {
|
|
50
76
|
broadcastPlanUpdate(sessionId);
|
|
51
77
|
}
|
|
52
78
|
|
|
@@ -54,39 +80,47 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
|
54
80
|
|
|
55
81
|
const browserUrl = `http://localhost:${port}/s/${sessionId}`;
|
|
56
82
|
return jsonResponse({
|
|
57
|
-
ok:
|
|
83
|
+
ok: anyOk,
|
|
58
84
|
browserUrl,
|
|
59
85
|
isNew,
|
|
60
86
|
revision: session.currentRevision,
|
|
61
|
-
|
|
87
|
+
sessionId,
|
|
88
|
+
canvasFiles: [...canvasFiles.keys()],
|
|
89
|
+
...(Object.keys(errors).length > 0 ? { errors } : {}),
|
|
62
90
|
});
|
|
63
91
|
} catch (e: any) {
|
|
64
92
|
return jsonResponse({ error: e.message }, 500);
|
|
65
93
|
}
|
|
66
94
|
}
|
|
67
95
|
|
|
68
|
-
async function
|
|
96
|
+
async function handleCanvasJs(_req: Request, url: URL, match: URLPatternResult): Promise<Response> {
|
|
69
97
|
const sessionId = match.pathname.groups.id!;
|
|
98
|
+
const jsFilename = match.pathname.groups.filename!;
|
|
99
|
+
const jsxFilename = jsFilename.replace(/\.js$/, ".jsx");
|
|
70
100
|
const revParam = url.searchParams.get("rev");
|
|
71
101
|
const rev = revParam ? parseInt(revParam, 10) : undefined;
|
|
72
102
|
|
|
73
|
-
let compiled = sessionManager.getCompiled(sessionId, rev);
|
|
103
|
+
let compiled = sessionManager.getCompiled(sessionId, jsxFilename, rev);
|
|
74
104
|
|
|
75
105
|
if (!compiled && rev) {
|
|
76
106
|
const session = sessionManager.get(sessionId);
|
|
77
107
|
if (session) {
|
|
78
|
-
const jsx = readRevisionJsx(
|
|
108
|
+
const jsx = sessionManager.readRevisionJsx(sessionId, rev, jsxFilename);
|
|
79
109
|
if (jsx) {
|
|
80
110
|
const result = await compilePlan(jsx, session.projectRoot);
|
|
81
111
|
if (result.ok) {
|
|
82
|
-
sessionManager.saveCompiled(sessionId, result.js, rev);
|
|
112
|
+
sessionManager.saveCompiled(sessionId, jsxFilename, result.js, rev);
|
|
83
113
|
compiled = result.js;
|
|
114
|
+
} else {
|
|
115
|
+
console.warn(`[canvas.js] compilation failed for ${sessionId}/${jsxFilename} rev ${rev}: ${result.error}`);
|
|
84
116
|
}
|
|
117
|
+
} else {
|
|
118
|
+
console.warn(`[canvas.js] no JSX found for ${sessionId}/${jsxFilename} rev ${rev}`);
|
|
85
119
|
}
|
|
86
120
|
}
|
|
87
121
|
}
|
|
88
122
|
|
|
89
|
-
if (!compiled) return jsonResponse({ error: "No compiled
|
|
123
|
+
if (!compiled) return jsonResponse({ error: "No compiled canvas" }, 404);
|
|
90
124
|
return new Response(compiled, {
|
|
91
125
|
headers: {
|
|
92
126
|
"Content-Type": "application/javascript",
|
|
@@ -108,6 +142,7 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
|
108
142
|
projectRoot: session.projectRoot,
|
|
109
143
|
currentRevision: session.currentRevision,
|
|
110
144
|
revisions: session.revisions,
|
|
145
|
+
canvasFiles: session.canvasFiles,
|
|
111
146
|
createdAt: session.createdAt,
|
|
112
147
|
updatedAt: session.updatedAt,
|
|
113
148
|
});
|
|
@@ -141,7 +176,7 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
|
141
176
|
return [
|
|
142
177
|
{ method: "GET", pattern: new URLPattern({ pathname: "/health" }), handler: handleHealth },
|
|
143
178
|
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/plan" }), handler: handlePlanPost },
|
|
144
|
-
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/
|
|
179
|
+
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/canvas/:filename" }), handler: handleCanvasJs },
|
|
145
180
|
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/meta" }), handler: handleMeta },
|
|
146
181
|
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/feedback" }), handler: handleFeedbackGet },
|
|
147
182
|
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/feedback/consume" }), handler: handleFeedbackConsume },
|
package/daemon/src/session.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, renameSync } from "fs";
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, renameSync, copyFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { SESSIONS_DIR } from "./paths";
|
|
4
4
|
|
|
@@ -7,21 +7,25 @@ export interface DiffStats {
|
|
|
7
7
|
removed: number;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export interface CanvasFileInfo {
|
|
11
|
+
filename: string;
|
|
12
|
+
diffStats?: DiffStats;
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
export interface RevisionInfo {
|
|
11
16
|
revision: number;
|
|
12
17
|
label?: string;
|
|
13
|
-
|
|
18
|
+
canvasFiles: CanvasFileInfo[];
|
|
14
19
|
createdAt: string;
|
|
15
20
|
hasFeedback: boolean;
|
|
16
21
|
feedbackConsumed: boolean;
|
|
17
22
|
response?: string;
|
|
18
|
-
diffStats?: DiffStats;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export interface SessionData {
|
|
22
26
|
id: string;
|
|
23
27
|
projectRoot: string;
|
|
24
|
-
|
|
28
|
+
canvasFiles: string[];
|
|
25
29
|
currentRevision: number;
|
|
26
30
|
revisions: RevisionInfo[];
|
|
27
31
|
createdAt: string;
|
|
@@ -36,7 +40,7 @@ interface SessionMeta {
|
|
|
36
40
|
revisions: RevisionInfo[];
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
// Legacy format
|
|
43
|
+
// Legacy format (flat files, pre-revision)
|
|
40
44
|
interface LegacyMeta {
|
|
41
45
|
projectRoot: string;
|
|
42
46
|
createdAt: string;
|
|
@@ -44,6 +48,18 @@ interface LegacyMeta {
|
|
|
44
48
|
version: number;
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
// Previous format (single sourceFile per revision)
|
|
52
|
+
interface LegacyRevisionInfo {
|
|
53
|
+
revision: number;
|
|
54
|
+
label?: string;
|
|
55
|
+
sourceFile?: string;
|
|
56
|
+
createdAt: string;
|
|
57
|
+
hasFeedback: boolean;
|
|
58
|
+
feedbackConsumed: boolean;
|
|
59
|
+
response?: string;
|
|
60
|
+
diffStats?: DiffStats;
|
|
61
|
+
}
|
|
62
|
+
|
|
47
63
|
const STALE_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
48
64
|
|
|
49
65
|
function computeLineDiffStats(oldText: string, newText: string): DiffStats {
|
|
@@ -83,6 +99,32 @@ export class SessionManager {
|
|
|
83
99
|
return join(this.sessionDir(id), "revisions", String(rev));
|
|
84
100
|
}
|
|
85
101
|
|
|
102
|
+
/** Compute file order from revision history: first-appearance order, alpha for ties */
|
|
103
|
+
private deriveFileOrder(revisions: RevisionInfo[], currentFiles: string[]): string[] {
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
const ordered: string[] = [];
|
|
106
|
+
for (const rev of revisions) {
|
|
107
|
+
for (const cf of rev.canvasFiles) {
|
|
108
|
+
if (!seen.has(cf.filename)) {
|
|
109
|
+
seen.add(cf.filename);
|
|
110
|
+
ordered.push(cf.filename);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Keep only files that still exist, then append any unknown ones
|
|
115
|
+
const current = new Set(currentFiles);
|
|
116
|
+
const result = ordered.filter(f => current.has(f));
|
|
117
|
+
const remaining = currentFiles.filter(f => !seen.has(f)).sort();
|
|
118
|
+
return [...result, ...remaining];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** List *.jsx filenames in a revision directory */
|
|
122
|
+
getRevisionCanvasFiles(id: string, rev: number): string[] {
|
|
123
|
+
const dir = this.revisionDir(id, rev);
|
|
124
|
+
if (!existsSync(dir)) return [];
|
|
125
|
+
return readdirSync(dir).filter(f => f.endsWith(".jsx")).sort();
|
|
126
|
+
}
|
|
127
|
+
|
|
86
128
|
private loadFromDisk() {
|
|
87
129
|
if (!existsSync(SESSIONS_DIR)) return;
|
|
88
130
|
for (const name of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
|
|
@@ -94,17 +136,16 @@ export class SessionManager {
|
|
|
94
136
|
try {
|
|
95
137
|
const raw = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
96
138
|
|
|
97
|
-
// Detect and migrate legacy format
|
|
139
|
+
// Detect and migrate legacy flat-file format
|
|
98
140
|
if ("version" in raw && !("currentRevision" in raw)) {
|
|
99
141
|
this.migrateLegacy(name.name, raw as LegacyMeta);
|
|
100
|
-
// Re-read after migration
|
|
101
142
|
const migrated: SessionMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
143
|
+
const canvasFiles = this.getRevisionCanvasFiles(name.name, migrated.currentRevision);
|
|
144
|
+
if (canvasFiles.length === 0) continue;
|
|
104
145
|
this.sessions.set(name.name, {
|
|
105
146
|
id: name.name,
|
|
106
147
|
projectRoot: migrated.projectRoot,
|
|
107
|
-
|
|
148
|
+
canvasFiles,
|
|
108
149
|
currentRevision: migrated.currentRevision,
|
|
109
150
|
revisions: migrated.revisions,
|
|
110
151
|
createdAt: migrated.createdAt,
|
|
@@ -113,14 +154,34 @@ export class SessionManager {
|
|
|
113
154
|
continue;
|
|
114
155
|
}
|
|
115
156
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
157
|
+
let meta = raw as SessionMeta;
|
|
158
|
+
|
|
159
|
+
// Migrate single-sourceFile revision format to canvasFiles
|
|
160
|
+
if (meta.revisions.length > 0 && !("canvasFiles" in meta.revisions[0])) {
|
|
161
|
+
meta = {
|
|
162
|
+
...meta,
|
|
163
|
+
revisions: (meta.revisions as unknown as LegacyRevisionInfo[]).map(r => ({
|
|
164
|
+
revision: r.revision,
|
|
165
|
+
label: r.label,
|
|
166
|
+
canvasFiles: [{ filename: r.sourceFile || "plan.jsx", diffStats: r.diffStats }],
|
|
167
|
+
createdAt: r.createdAt,
|
|
168
|
+
hasFeedback: r.hasFeedback,
|
|
169
|
+
feedbackConsumed: r.feedbackConsumed,
|
|
170
|
+
response: r.response,
|
|
171
|
+
})),
|
|
172
|
+
};
|
|
173
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const diskFiles = this.getRevisionCanvasFiles(name.name, meta.currentRevision);
|
|
177
|
+
if (diskFiles.length === 0) continue;
|
|
178
|
+
// Derive file order from revision history (first-appearance order)
|
|
179
|
+
const canvasFiles = this.deriveFileOrder(meta.revisions, diskFiles);
|
|
119
180
|
|
|
120
181
|
this.sessions.set(name.name, {
|
|
121
182
|
id: name.name,
|
|
122
183
|
projectRoot: meta.projectRoot,
|
|
123
|
-
|
|
184
|
+
canvasFiles,
|
|
124
185
|
currentRevision: meta.currentRevision,
|
|
125
186
|
revisions: meta.revisions,
|
|
126
187
|
createdAt: meta.createdAt,
|
|
@@ -136,7 +197,6 @@ export class SessionManager {
|
|
|
136
197
|
const flatCompiled = join(dir, "plan.compiled.js");
|
|
137
198
|
const historyDir = join(dir, "history");
|
|
138
199
|
|
|
139
|
-
// Collect all historical revisions + current
|
|
140
200
|
const revisions: RevisionInfo[] = [];
|
|
141
201
|
|
|
142
202
|
// Migrate history files
|
|
@@ -148,7 +208,13 @@ export class SessionManager {
|
|
|
148
208
|
const revDir = this.revisionDir(id, num);
|
|
149
209
|
mkdirSync(revDir, { recursive: true });
|
|
150
210
|
renameSync(join(historyDir, file), join(revDir, "plan.jsx"));
|
|
151
|
-
revisions.push({
|
|
211
|
+
revisions.push({
|
|
212
|
+
revision: num,
|
|
213
|
+
canvasFiles: [{ filename: "plan.jsx" }],
|
|
214
|
+
createdAt: legacy.createdAt,
|
|
215
|
+
hasFeedback: false,
|
|
216
|
+
feedbackConsumed: false,
|
|
217
|
+
});
|
|
152
218
|
}
|
|
153
219
|
rmSync(historyDir, { recursive: true, force: true });
|
|
154
220
|
}
|
|
@@ -163,9 +229,14 @@ export class SessionManager {
|
|
|
163
229
|
if (existsSync(flatCompiled)) {
|
|
164
230
|
renameSync(flatCompiled, join(revDir, "plan.compiled.js"));
|
|
165
231
|
}
|
|
166
|
-
revisions.push({
|
|
232
|
+
revisions.push({
|
|
233
|
+
revision: currentRev,
|
|
234
|
+
canvasFiles: [{ filename: "plan.jsx" }],
|
|
235
|
+
createdAt: legacy.updatedAt,
|
|
236
|
+
hasFeedback: false,
|
|
237
|
+
feedbackConsumed: false,
|
|
238
|
+
});
|
|
167
239
|
|
|
168
|
-
// Write new meta
|
|
169
240
|
const meta: SessionMeta = {
|
|
170
241
|
projectRoot: legacy.projectRoot,
|
|
171
242
|
createdAt: legacy.createdAt,
|
|
@@ -176,37 +247,62 @@ export class SessionManager {
|
|
|
176
247
|
writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
|
|
177
248
|
}
|
|
178
249
|
|
|
179
|
-
|
|
250
|
+
readRevisionJsx(id: string, rev: number, filename: string): string | null {
|
|
180
251
|
try {
|
|
181
|
-
return readFileSync(join(this.revisionDir(id, rev),
|
|
252
|
+
return readFileSync(join(this.revisionDir(id, rev), filename), "utf-8");
|
|
182
253
|
} catch {
|
|
183
254
|
return null;
|
|
184
255
|
}
|
|
185
256
|
}
|
|
186
257
|
|
|
187
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Create or update a session with a set of canvas files.
|
|
260
|
+
* @param canvasFiles Map of filename -> JSX content
|
|
261
|
+
*/
|
|
262
|
+
upsert(id: string, canvasFiles: Map<string, string>, projectRoot: string, label?: string, response?: string): SessionData {
|
|
188
263
|
const existing = this.sessions.get(id);
|
|
189
264
|
const now = new Date().toISOString();
|
|
190
265
|
const revision = existing ? existing.currentRevision + 1 : 1;
|
|
191
266
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
const prevJsx = this.readRevisionJsx(id,
|
|
199
|
-
if (prevJsx)
|
|
267
|
+
// Compute per-file diffStats against the previous revision
|
|
268
|
+
const prevRev = existing ? existing.currentRevision : 0;
|
|
269
|
+
const canvasFileInfos: CanvasFileInfo[] = [];
|
|
270
|
+
for (const [filename, jsx] of canvasFiles) {
|
|
271
|
+
let diffStats: DiffStats | undefined;
|
|
272
|
+
if (prevRev > 0) {
|
|
273
|
+
const prevJsx = this.readRevisionJsx(id, prevRev, filename);
|
|
274
|
+
if (prevJsx) {
|
|
275
|
+
diffStats = computeLineDiffStats(prevJsx, jsx);
|
|
276
|
+
}
|
|
200
277
|
}
|
|
278
|
+
canvasFileInfos.push({
|
|
279
|
+
filename,
|
|
280
|
+
...(diffStats ? { diffStats } : {}),
|
|
281
|
+
});
|
|
201
282
|
}
|
|
202
283
|
|
|
203
|
-
const revInfo: RevisionInfo = {
|
|
284
|
+
const revInfo: RevisionInfo = {
|
|
285
|
+
revision,
|
|
286
|
+
canvasFiles: canvasFileInfos,
|
|
287
|
+
createdAt: now,
|
|
288
|
+
hasFeedback: false,
|
|
289
|
+
feedbackConsumed: false,
|
|
290
|
+
...(label ? { label } : {}),
|
|
291
|
+
...(response ? { response } : {}),
|
|
292
|
+
};
|
|
204
293
|
const revisions = existing ? [...existing.revisions, revInfo] : [revInfo];
|
|
294
|
+
// Order: files from previous revision first (preserving their order), then new files alphabetically
|
|
295
|
+
const prevOrder = existing?.canvasFiles ?? [];
|
|
296
|
+
const currentNames = new Set(canvasFiles.keys());
|
|
297
|
+
const filenames = [
|
|
298
|
+
...prevOrder.filter(f => currentNames.has(f)),
|
|
299
|
+
...[...currentNames].filter(f => !prevOrder.includes(f)).sort(),
|
|
300
|
+
];
|
|
205
301
|
|
|
206
302
|
const session: SessionData = {
|
|
207
303
|
id,
|
|
208
304
|
projectRoot,
|
|
209
|
-
|
|
305
|
+
canvasFiles: filenames,
|
|
210
306
|
currentRevision: revision,
|
|
211
307
|
revisions,
|
|
212
308
|
createdAt: existing?.createdAt ?? now,
|
|
@@ -214,7 +310,7 @@ export class SessionManager {
|
|
|
214
310
|
};
|
|
215
311
|
|
|
216
312
|
this.sessions.set(id, session);
|
|
217
|
-
this.persistToDisk(session);
|
|
313
|
+
this.persistToDisk(session, canvasFiles);
|
|
218
314
|
return session;
|
|
219
315
|
}
|
|
220
316
|
|
|
@@ -229,10 +325,12 @@ export class SessionManager {
|
|
|
229
325
|
writeFileSync(join(this.sessionDir(session.id), "meta.json"), JSON.stringify(meta, null, 2));
|
|
230
326
|
}
|
|
231
327
|
|
|
232
|
-
private persistToDisk(session: SessionData) {
|
|
328
|
+
private persistToDisk(session: SessionData, canvasFiles: Map<string, string>) {
|
|
233
329
|
const revDir = this.revisionDir(session.id, session.currentRevision);
|
|
234
330
|
mkdirSync(revDir, { recursive: true });
|
|
235
|
-
|
|
331
|
+
for (const [filename, jsx] of canvasFiles) {
|
|
332
|
+
writeFileSync(join(revDir, filename), jsx);
|
|
333
|
+
}
|
|
236
334
|
this.persistMeta(session);
|
|
237
335
|
}
|
|
238
336
|
|
|
@@ -249,21 +347,23 @@ export class SessionManager {
|
|
|
249
347
|
rmSync(this.sessionDir(id), { recursive: true, force: true });
|
|
250
348
|
}
|
|
251
349
|
|
|
252
|
-
saveCompiled(id: string, js: string, rev?: number) {
|
|
350
|
+
saveCompiled(id: string, filename: string, js: string, rev?: number) {
|
|
253
351
|
const session = this.sessions.get(id);
|
|
254
352
|
const revision = rev ?? session?.currentRevision;
|
|
255
353
|
if (!revision) return;
|
|
256
354
|
const revDir = this.revisionDir(id, revision);
|
|
257
355
|
mkdirSync(revDir, { recursive: true });
|
|
258
|
-
|
|
356
|
+
const compiledName = filename.replace(/\.jsx$/, ".compiled.js");
|
|
357
|
+
writeFileSync(join(revDir, compiledName), js);
|
|
259
358
|
}
|
|
260
359
|
|
|
261
|
-
getCompiled(id: string, rev?: number): string | null {
|
|
360
|
+
getCompiled(id: string, filename: string, rev?: number): string | null {
|
|
262
361
|
const session = this.sessions.get(id);
|
|
263
362
|
const revision = rev ?? session?.currentRevision;
|
|
264
363
|
if (!revision) return null;
|
|
364
|
+
const compiledName = filename.replace(/\.jsx$/, ".compiled.js");
|
|
265
365
|
try {
|
|
266
|
-
return readFileSync(join(this.revisionDir(id, revision),
|
|
366
|
+
return readFileSync(join(this.revisionDir(id, revision), compiledName), "utf-8");
|
|
267
367
|
} catch {
|
|
268
368
|
return null;
|
|
269
369
|
}
|
|
@@ -305,7 +405,6 @@ export class SessionManager {
|
|
|
305
405
|
getLatestUnconsumedFeedback(id: string): { revision: number; feedback: string } | null {
|
|
306
406
|
const session = this.sessions.get(id);
|
|
307
407
|
if (!session) return null;
|
|
308
|
-
// Search from newest to oldest
|
|
309
408
|
for (let i = session.revisions.length - 1; i >= 0; i--) {
|
|
310
409
|
const ri = session.revisions[i];
|
|
311
410
|
if (ri.hasFeedback && !ri.feedbackConsumed) {
|
|
@@ -316,8 +415,8 @@ export class SessionManager {
|
|
|
316
415
|
return null;
|
|
317
416
|
}
|
|
318
417
|
|
|
319
|
-
getRevisionJsxPath(id: string, rev: number): string {
|
|
320
|
-
return join(this.revisionDir(id, rev),
|
|
418
|
+
getRevisionJsxPath(id: string, rev: number, filename: string): string {
|
|
419
|
+
return join(this.revisionDir(id, rev), filename);
|
|
321
420
|
}
|
|
322
421
|
|
|
323
422
|
cleanupStale(maxAge = STALE_TIMEOUT_MS) {
|