Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions canvas/canvas.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- The theme picked in the top bar (src/theme.ts), on the page before it paints,
and again whenever another page of the app picks one. -->
<script>
(function () {
function pick() {
var theme = "system";
try {
theme = localStorage.getItem("sp-theme") || theme;
} catch (e) {}
document.documentElement.dataset.theme = theme;
}
pick();
addEventListener("storage", function (e) {
if (e.key === "sp-theme") pick();
});
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Super Prototyping</title>
<meta name="robots" content="noindex" />
Expand Down
17 changes: 17 additions & 0 deletions canvas/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- The theme picked in the top bar (src/theme.ts), on the page before it paints,
and again whenever another page of the app picks one. -->
<script>
(function () {
function pick() {
var theme = "system";
try {
theme = localStorage.getItem("sp-theme") || theme;
} catch (e) {}
document.documentElement.dataset.theme = theme;
}
pick();
addEventListener("storage", function (e) {
if (e.key === "sp-theme") pick();
});
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
Expand Down
17 changes: 17 additions & 0 deletions canvas/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- The theme picked in the top bar (src/theme.ts), on the page before it paints,
and again whenever another page of the app picks one. -->
<script>
(function () {
function pick() {
var theme = "system";
try {
theme = localStorage.getItem("sp-theme") || theme;
} catch (e) {}
document.documentElement.dataset.theme = theme;
}
pick();
addEventListener("storage", function (e) {
if (e.key === "sp-theme") pick();
});
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
Expand Down
23 changes: 21 additions & 2 deletions canvas/server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import fs from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import os from "node:os";
import path from "node:path";
import { CANVASES } from "./boards.ts";
import { CANVASES, hashBoards } from "./boards.ts";
import { command, stop } from "./command.ts";
import { AGENT_SKILLS, installSkills } from "./skills.ts";
import { canvasFile, folderOf, sameOrigin } from "./sp.ts";
Expand Down Expand Up @@ -102,11 +102,28 @@ export function createAgentServer(options: {
// above have moved on. The panel's own copy, not the agent's transcript: Claude Code's and
// Codex's files are theirs, change shape between releases, and hold no attachments by number.
const keptOf = (id: string) => path.join(workspaces, ".runs", id);
const say = (run: Run, event: string, data: unknown) =>
// The boards each live run's project held as it started, so its end can say which it made
// or changed: the panel puts a button under the reply for each, which finds it on the canvas.
// Off the files rather than the tool lines, which cannot say what a generator wrote.
const boardsBefore = new Map<string, { dir: string; hashes: Map<string, string> }>();
const say = (run: Run, event: string, data: unknown) => {
const before = event === "end" && boardsBefore.get(run.id);
if (before) {
boardsBefore.delete(run.id);
const made = [...hashBoards(before.dir)]
.filter(([board, hash]) => before.hashes.get(board) !== hash)
Comment on lines +113 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New projects lose board jump buttons

When a first turn names an unnamed project, named moves its folder before the end event. hashBoards(before.dir) then sees no boards at the old path, so the reply has no jump buttons.

Learn more

An unnamed project starts in an Untitled folder. On a successful run, the child close handler calls named before it finishes the run. named moves the entire project folder, while before.dir still names the old canvases directory. hashBoards treats the missing directory as empty, and the end event loses all created or updated boards.

Example: Start a new Untitled project and ask the agent to name it and make canvases/launch/01-home.html on the first turn. The folder becomes its new project name, but the reply has no 01 Home button.

Recommended fix: Compute the after-hashes before calling named, or update the run's tracked directory when named moves it. Preserve the original before-hashes for the comparison.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

.map(([board]) => ({
board,
status: before.hashes.has(board) ? "updated" : "new",
}))
.sort((a, b) => a.board.localeCompare(b.board));
data = { ...(data as object), made };
}
fs.appendFileSync(
path.join(keptOf(run.id), "events.jsonl"),
JSON.stringify(emit(run, event, data)) + "\n",
);
};
const picture = (id: string, name: string, type: string, data: Buffer) => {
const file = path.join(keptOf(id), `${name}.${type.slice(6)}`);
fs.writeFileSync(file, data);
Expand Down Expand Up @@ -549,6 +566,7 @@ export function createAgentServer(options: {
};
},
);
const hashes = hashBoards(boards);
const c = command(
def.bin,
def.args({
Expand Down Expand Up @@ -576,6 +594,7 @@ export function createAgentServer(options: {
dir,
});
runs.set(run.id, run);
boardsBefore.set(run.id, { dir: boards, hashes });
record.runs.push(run.id);
record.updated = Date.now();
save(record);
Expand Down
31 changes: 31 additions & 0 deletions canvas/server/boards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,41 @@
* `/__sp/index.json`. The server scans on every request, and the build writes the same shape
* into `dist` once. `src/canvasIndex.ts` declares the shape the client reads.
*/
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { svgSignature } from "../src/svgSignature.ts";

export const boardHash = (file: string) =>
createHash("sha1").update(fs.readFileSync(file)).digest("hex");

/**
* What each board in a boards directory holds, by `<slug>/<file>.html`: a generator writes every
* board on each run, and only the ones whose bytes changed are news. An unreadable or missing
* folder holds none, and a dot-file or a folder named like a board is no board.
*/
export function hashBoards(canvasesDir: string) {
const list = (dir: string) => {
Comment on lines +19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 New features have no adjacent tests

No tests accompany board hashing, run-end enrichment, or the theme switch. The repository asks for tests beside changed viewer modules.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

try {
return fs.readdirSync(dir);
} catch {
return [];
}
};
const hashes = new Map<string, string>();
for (const slug of list(canvasesDir))
for (const name of list(path.join(canvasesDir, slug))) {
const file = path.join(canvasesDir, slug, name);
if (
name.endsWith(".html") &&
!name.startsWith(".") &&
fs.statSync(file, { throwIfNoEntry: false })?.isFile()
)
hashes.set(`${slug}/${name}`.normalize("NFC"), boardHash(file));
}
return hashes;
}

/**
* A project's boards: this folder under it, one subfolder per canvas. The same folder under the
* plugin root is the examples every project is shown beside its own.
Expand Down
68 changes: 43 additions & 25 deletions canvas/server/sp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
CANVASES,
DOCS,
IMAGE_MIME,
boardHash,
boardIndex,
hashBoards,
readDocs,
canvasesNamespace,
readJson,
Expand Down Expand Up @@ -75,7 +77,10 @@ export function folderOf(
fs.statSync(own, { throwIfNoEntry: false })?.isDirectory() &&
fs
.readdirSync(own)
.some((f) => f === "layout.json" || (!f.startsWith(".") && f.endsWith(".html")));
.some(
(f) =>
f === "layout.json" || (!f.startsWith(".") && f.endsWith(".html")),
);
const example = path.join(examplesDir, slug);
return !isCanvas && fs.existsSync(example) ? example : own;
}
Expand Down Expand Up @@ -266,7 +271,9 @@ export function shoot(board: string, size: number[], res: ServerResponse) {
// running without it, and Chrome is its own ask.
fail(
res,
(error as NodeJS.ErrnoException | null)?.code === "ENOENT" ? 503 : 500,
(error as NodeJS.ErrnoException | null)?.code === "ENOENT"
? 503
: 500,
error
? `could not render the board: ${error.message}`
: "refkit wrote no image",
Expand Down Expand Up @@ -390,7 +397,8 @@ export function createSpServer(options: {
.map((b) => ({ ...b, example: true })),
].sort((a, b) => (a.slug < b.slug ? -1 : 1));
const docs = projectDir === undefined ? undefined : readDocs(projectDir);
const title = projectDir === undefined ? undefined : readProjectJson(projectDir).name;
const title =
projectDir === undefined ? undefined : readProjectJson(projectDir).name;
return { ...index, boards, project: projectName(), title, docs };
};

Expand Down Expand Up @@ -432,7 +440,11 @@ export function createSpServer(options: {
updated: Math.max(
fs.statSync(dir).mtimeMs,
...canvases.map((c) => c.updated),
...DOCS.map((doc) => fs.statSync(path.join(dir, doc), { throwIfNoEntry: false })?.mtimeMs ?? 0),
...DOCS.map(
(doc) =>
fs.statSync(path.join(dir, doc), { throwIfNoEntry: false })
?.mtimeMs ?? 0,
),
),
canvases,
cover: projectCover(boards, json.cover),
Expand Down Expand Up @@ -527,8 +539,11 @@ export function createSpServer(options: {
const size = fs.statSync(file).size;
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
if (range && (range[1] || range[2])) {
const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]));
const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
const start = range[1]
? Number(range[1])
: Math.max(0, size - Number(range[2]));
const end =
range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
if (start > end) {
res.setHeader("Content-Range", `bytes */${size}`);
return send(416, "range not satisfiable");
Expand Down Expand Up @@ -673,7 +688,8 @@ export function createSpServer(options: {
try {
const { slug, name } = JSON.parse(body || "{}");
if (!SAFE_NAME.test(slug ?? "")) return send(400, "bad canvas name");
if (typeof name !== "string" || !name.trim()) return send(400, "empty name");
if (typeof name !== "string" || !name.trim())
return send(400, "empty name");
if (isExample(slug)) return send(403, READ_ONLY);
if (!fs.existsSync(path.join(canvasesDir, slug)))
return send(404, `no canvas folder named ${slug}`);
Expand Down Expand Up @@ -717,7 +733,8 @@ export function createSpServer(options: {
if (isExample(slug)) return send(403, READ_ONLY);
trash(folder).then(
() => send(204, ""),
(error: Error) => send(500, `“${slug}” is still there: ${error.message}`),
(error: Error) =>
send(500, `“${slug}” is still there: ${error.message}`),
);
});
});
Expand Down Expand Up @@ -1017,20 +1034,26 @@ export function createSpServer(options: {
res.statusCode = code;
res.end(message);
};
if (projectDir === undefined) return send(409, "Open a project to write its documents.");
if (projectDir === undefined)
return send(409, "Open a project to write its documents.");
let name: unknown, text: unknown, base: unknown;
try {
({ name, text, base } = JSON.parse(body || "{}"));
} catch {
return send(400, "bad json");
}
if (typeof name !== "string" || !DOCS.includes(name)) return send(400, "not a document");
if (typeof text !== "string" || typeof base !== "string") return send(400, "no text");
if (typeof name !== "string" || !DOCS.includes(name))
return send(400, "not a document");
if (typeof text !== "string" || typeof base !== "string")
return send(400, "no text");
const file = path.join(projectDir, name);
// The text the edit began from, so a rewrite since, an agent's, is not overwritten unseen.
try {
if (fs.readFileSync(file, "utf8") !== base)
return send(409, `${name} changed since you began editing. Copy your text, then reopen it.`);
return send(
409,
`${name} changed since you began editing. Copy your text, then reopen it.`,
);
fs.writeFileSync(file, text);
} catch (error) {
return send(500, String(error));
Expand Down Expand Up @@ -1114,9 +1137,10 @@ export function createSpServer(options: {
0,
...list(canvasesDir).map(
(had) =>
(readJson(path.join(canvasesDir, had, "layout.json")) as
| { order?: number }
| undefined)?.order ?? 0,
(
readJson(path.join(canvasesDir, had, "layout.json")) as
{ order?: number } | undefined
)?.order ?? 0,
),
) + 1;
fs.mkdirSync(path.join(canvasesDir, slug));
Expand Down Expand Up @@ -1146,15 +1170,7 @@ export function createSpServer(options: {
// index says so on its own.
const boardKey = (file: string) =>
path.relative(canvasesDir, file).split(path.sep).join("/").normalize("NFC");
const boardHash = (file: string) =>
createHash("sha1").update(fs.readFileSync(file)).digest("hex");
const boardHashes = new Map<string, string>();
for (const slug of list(canvasesDir))
for (const name of list(path.join(canvasesDir, slug)))
if (name.endsWith(".html")) {
const file = path.join(canvasesDir, slug, name);
boardHashes.set(boardKey(file), boardHash(file));
}
const boardHashes = hashBoards(canvasesDir);

/**
* One settled batch of writes, answered once.
Expand Down Expand Up @@ -1289,7 +1305,9 @@ export function createSpServer(options: {
let docWatcher: fs.FSWatcher | undefined;
let docBatch: ReturnType<typeof setTimeout> | undefined;
if (projectDir !== undefined) {
let names = readDocs(projectDir).map((doc) => doc.name).join();
let names = readDocs(projectDir)
.map((doc) => doc.name)
.join();
let title = readProjectJson(projectDir).name;
docWatcher = fs.watch(projectDir, (_event, name) => {
if (!name || ![...DOCS, PROJECT_JSON].includes(name.toString())) return;
Expand Down
Loading
Loading