Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d63d7c7
Start a new project by cloning an app or building from an idea
Jing-yilin Sep 24, 2026
8a104c7
Save what a person puts on a canvas in its folder, and make canvases …
Jing-yilin Sep 24, 2026
e8eab95
Keep canvas saves in order, and keep a shape's link and a typed idea
Jing-yilin Sep 24, 2026
e6badd7
Tell other windows about a canvas save, save on leaving, and define b…
Jing-yilin Sep 24, 2026
5d41085
Plain wording in the branch's comments and design note
Jing-yilin Sep 24, 2026
7d482f9
Glow round the canvas the agent is writing to, and offer to continue …
Jing-yilin Sep 24, 2026
2e11b02
Merge remote-tracking branch 'origin/new-project-modes' into agent-wo…
Jing-yilin Sep 24, 2026
4f3a562
Number canvas saves, forget a write once heard, and upload refs whole
Jing-yilin Sep 24, 2026
b254750
Write canvas.json whole, leave a broken one alone, resend unanswered …
Jing-yilin Sep 24, 2026
e095463
Refuse an agent create over an existing id, no first board, paste AVIF
Jing-yilin Sep 24, 2026
b66bd4d
Keep an agent update stamped and locked, open a project past a ref th…
Jing-yilin Sep 24, 2026
4a58550
Merge remote-tracking branch 'origin/new-project-modes' into agent-wo…
Jing-yilin Sep 24, 2026
7170b56
No renaming on the hosted canvas, no cancelling a project being made
Jing-yilin Sep 24, 2026
25781cc
Keep a deleted item deleted with the canvas open in two pages
Jing-yilin Sep 24, 2026
f79fb33
Glow on the canvas the agent writes to, rings on boards it adds or ch…
Jing-yilin Sep 24, 2026
ac89c74
Ask before binning a folder in a modal of our own, centred on the window
Jing-yilin Sep 24, 2026
cb62355
Continue in the interrupted turn's project, refetch boards rewritten …
Jing-yilin Sep 24, 2026
70c54bf
Skip only the canvas another window saved on the way out, and drop a …
Jing-yilin Sep 24, 2026
9197aa9
Merge pull request #172 from ReScienceLab/agent-working-glow
Jing-yilin Sep 24, 2026
31dd9b2
Say plainly what the new comments and the canvas-content doc mean
Jing-yilin Sep 24, 2026
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
18 changes: 18 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ Everything below is on `main` and reaches no install until a version is cut.
longer fetched again.
- A failed update download is no longer silent. `sp upgrade` says it failed and
why, and the app tries again every four hours while it is open.
- **Start a project by cloning an app or from an idea.** New project asks which.
Clone takes screenshots and screen recordings, dropped or browsed for, and
starts the agent on them. Build takes the idea in your own words, and the name
is optional for both.
- **What you paste on a canvas is kept in the project.** Images, videos, text
and links pasted or dropped on a canvas are saved in its folder, so they
survive a new browser, go into Git, and the agent can see them. Videos have no
size limit, and a link to a board pasted on the canvas becomes a picture you
can move, crop and draw over.
- **Make a canvas yourself.** The **+** after the canvas tabs adds an empty one
and lets you name it. Double-click a tab to rename it, and right-click it to
show its folder or move it to the Trash.
- **See where the agent is working.** The canvas it is writing to glows blue,
and its tab shimmers. A board it adds rings blue and one it changes rings
green until you point at it. An interrupted turn has **Continue**, which
picks up where it stopped, in the project it was working on.
- Deleting something you pasted keeps it deleted, even with the same canvas
open in two windows.

## v1.6.1

Expand Down
91 changes: 63 additions & 28 deletions canvas/server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
newRun,
sseFrame,
type Run,
type RunEvent,
type Session,
} from "../src/agentRun.ts";
import {
Expand Down Expand Up @@ -56,9 +57,11 @@ export function createAgentServer(options: {
// and each is written to disk as it goes (`say`, below) for History after a restart.
//
// Two steps rather than one streaming response: the agent's work is a board written to
// disk, and the project's watcher (sp.ts) answers that with a full reload, so a stream bound
// to the fetch that started the run would die exactly when the run succeeds. The page comes
// disk, and the project's watcher (sp.ts) used to answer that with a full reload, and a
// canvas.json written from outside still does, so a stream bound to the fetch that started
// the run could die exactly when the run succeeds. The page comes
// back with the run's id and reads its events from wherever it left off (agentRun.ts).
const QUIT = "The app quit before this turn finished.";
const runs = new Map<
string,
Run & {
Expand Down Expand Up @@ -159,12 +162,17 @@ export function createAgentServer(options: {
// never take a run down with it.
const commands = new Map<string, string[]>();
// The same frame, and Codex's first, also say what the agent calls the session, which the next
// turn resumes it by.
// turn resumes it by. Saved the moment it is known rather than when the run closes: an app that
// quits mid-run never gets there, and Continue on an interrupted first turn would then start a
// conversation with no memory of it.
const harvest = (def: AgentDef, line: string, session: Session) => {
try {
const list = def.commands?.(line);
if (list) commands.set(def.id, list);
session.resume ??= def.session(line);
if (session.resume === null) {
session.resume = def.session(line);
if (session.resume !== null) save(session);
}
} catch {
// Not the line that carries them.
}
Expand Down Expand Up @@ -434,7 +442,7 @@ export function createAgentServer(options: {
`Before touching a board folder, read ${repoRoot}/skills/prototype-canvas/SKILL.md, ` +
"the prototype-canvas skill of the super-prototyping plugin: one folder is one canvas " +
"page, one .html file in it is one board, layout.json places them, and the open canvas " +
"reloads by itself when a board is rewritten.",
"shows a rewritten board by itself, without a reload, while the user keeps working on it.",
dir !== undefined &&
`${path.join(dir, "PRD.md")}, when there is one, is a tab of the canvas ` +
"and says what the product is for. When they start from an idea, or ask for screens " +
Expand Down Expand Up @@ -516,9 +524,9 @@ export function createAgentServer(options: {
runs.delete(oldId);
}
}
const finish = (message: string) => {
const finish = (message: string, interrupted?: true) => {
if (!ended(run))
say(run, "end", { kind: "end", ok: false, message });
say(run, "end", { kind: "end", ok: false, message, interrupted });
};
let stderr = "";
run.child.stderr
Expand Down Expand Up @@ -600,16 +608,17 @@ export function createAgentServer(options: {
settle();
if (ended(run)) return;
const tail = stderr.trim().split("\n").slice(-5).join("\n");
// `claude` is a launcher around the real process: a SIGTERM to it comes back as
// exit 143, not as a signal. Harmless for codex, which dies by the signal.
// Stopped only when the person pressed Stop. Anything else that ends the CLI before its
// result frame — a crash, a kill from outside — cut the turn off, and the panel offers
// to continue it. The app quitting is `close()` below, which ends the run first.
if (run.stopped) return finish("stopped");
finish(
signal || code === 143 || run.stopped
? "stopped"
: `${def.bin} exited with code ${code}` +
(tail ? `\n${tail}` : "") +
(def.id === "claude" && /unknown option/i.test(stderr)
? "\nThe panel needs Claude Code 2.1.275 or newer."
: ""),
(signal ? `${def.bin} was killed (${signal})` : `${def.bin} exited with code ${code}`) +
(tail ? `\n${tail}` : "") +
(def.id === "claude" && /unknown option/i.test(stderr)
? "\nThe panel needs Claude Code 2.1.275 or newer."
: ""),
true,
);
});
res.setHeader("content-type", "application/json");
Expand All @@ -628,7 +637,10 @@ export function createAgentServer(options: {
}
if (req.method === "GET" && url.pathname === "/sessions") {
// Every session there is, newest first, off the records, so a restarted server still lists
// them. Whether one is running is this process's to say.
// them. Whether one is running is this process's to say. Interrupted: its last run was
// cut off, which the panel reopens on after a relaunch to offer Continue. A run with no end
// on disk was taken down with the server; one whose end says so was ended by the quit or
// lost its CLI.
const ids = fs.existsSync(workspaces)
? fs
.readdirSync(workspaces)
Expand All @@ -642,13 +654,34 @@ export function createAgentServer(options: {
ids
.map(load)
.sort((a, b) => b.updated - a.updated)
.map((s) => ({
...s,
running: s.runs.some((id) => {
.map((s) => {
const running = s.runs.some((id) => {
const run = runs.get(id);
return run !== undefined && !ended(run);
}),
})),
});
const last = s.runs.at(-1);
// ponytail: reads each session's last run whole for its last line; read the tail
// alone if a History list gets slow.
// A run from before runs were kept on disk has no file.
const file = last && path.join(keptOf(last), "events.jsonl");
const tail =
!file || running || !fs.existsSync(file)
? undefined
: fs
.readFileSync(file, "utf8")
.trimEnd()
.split("\n")
.at(-1);
const end = tail && (JSON.parse(tail) as RunEvent);
return {
...s,
running,
interrupted:
!!end &&
(end.event !== "end" ||
(end.data as { interrupted?: boolean }).interrupted === true),
};
}),
),
);
}
Expand All @@ -670,11 +703,7 @@ export function createAgentServer(options: {
.map((line) => JSON.parse(line));
// The server went down mid-run and took the agent with it.
if (!ended(run))
emit(run, "end", {
kind: "end",
ok: false,
message: "The app quit before this turn finished.",
});
emit(run, "end", { kind: "end", ok: false, message: QUIT, interrupted: true });
}
if (!run) return send(404, "no such run");
if (
Expand Down Expand Up @@ -742,7 +771,13 @@ export function createAgentServer(options: {
// The agent is this process's child and no one else's. A SIGTERM to the server alone, which is
// how the macOS app stops it, would otherwise leave the agent editing the project with nobody
// watching. This is the same kill the Stop button sends.
for (const run of runs.values()) if (!ended(run)) stop(run.child);
// Ended here, before the kill, so the turn reads as cut off by the quit rather than
// stopped by the person, whichever of the child's exit and this process's comes first.
for (const run of runs.values())
if (!ended(run)) {
say(run, "end", { kind: "end", ok: false, message: QUIT, interrupted: true });
stop(run.child);
}
},
};
}
10 changes: 9 additions & 1 deletion canvas/server/boards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,19 @@ export function boardIndex(
thumbs: [] as string[],
assets: assetIndex(folder),
comments: readJson(path.join(folder, "comments.json")),
// null for a canvas.json that is there but unreadable, a merge left half done say:
// the page must not take it for none and write over it (canvasContent.ts).
content: fs.existsSync(path.join(folder, "canvas.json"))
? (readJson(path.join(folder, "canvas.json")) ?? null)
: undefined,
Comment on lines +306 to +308

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count persisted canvas edits in project timestamps

When a person pastes, moves, or deletes content on an existing canvas, this new persistence path changes canvas.json (and sometimes files/) but boardIndex.updated still considers only HTML mtimes, while /__sp/projects.json derives the project's timestamp from that value, root-directory mtime, and documents. Consequently Home's “Edited” label and last-edited ordering never reflect these user edits; include the persisted canvas files in the canvas/project timestamp.

Useful? React with 👍 / 👎.

docs: readDocs(folder),
};
})
.filter(
(b): b is NonNullable<typeof b> => b !== null && b.html.length > 0,
// A folder with no board yet is a canvas once it has a layout.json, which is what the
// canvas strip's "+" makes (sp.ts, /__sp/new-canvas).
(b): b is NonNullable<typeof b> =>
b !== null && (b.html.length > 0 || b.layout !== undefined),
Comment on lines 312 to +316

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Let layout-only project canvases shadow examples

For a project canvas whose slug matches a shipped example, admitting a layout-only folder here is inconsistent with folderOf, which still considers the project copy present only when it contains an HTML board. During the new define-first flow such a canvas is consequently filtered out as an example and all rename/content operations are rejected as read-only until its first board appears; update ownership detection to recognize the same layout-only canvases as this index.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: folderOf now counts a folder with a layout.json as the project's own, matching the index, so a layout-only canvas under an example's name is writable. Covered in sp.test.ts.

);
return {
served: options.served,
Expand Down
70 changes: 52 additions & 18 deletions canvas/server/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ it("serves every project at its own address and makes new ones", async () => {
fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true });
fs.writeFileSync(path.join(tmp, rel), text);
};
write(
"root/canvas/package.json",
JSON.stringify({ version: "1.0.0" }),
);
write("root/canvas/package.json", JSON.stringify({ version: "1.0.0" }));
write(
"root/skills/alpha/SKILL.md",
"---\nname: alpha\nmetadata:\n managed-by: super-prototyping\n---\nAlpha.\n",
Expand All @@ -38,7 +35,9 @@ it("serves every project at its own address and makes new ones", async () => {
res.writeHead(200).end(`static ${req.url}`),
),
);
expect(fs.existsSync(path.join(tmp, "projects/alpha/canvases/one"))).toBe(true);
expect(fs.existsSync(path.join(tmp, "projects/alpha/canvases/one"))).toBe(
true,
);
expect(fs.existsSync(path.join(tmp, "projects/alpha/mockups"))).toBe(false);
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
const { port } = server.address() as AddressInfo;
Expand Down Expand Up @@ -67,8 +66,13 @@ it("serves every project at its own address and makes new ones", async () => {
});
try {
// Nothing opened yet: the root is home, and the examples' window.
expect(await ask("/")).toMatchObject({ status: 302, location: "/home.html" });
expect((await ask("/?canvas=00-welcome")).text).toBe("static /?canvas=00-welcome");
expect(await ask("/")).toMatchObject({
status: 302,
location: "/home.html",
});
expect((await ask("/?canvas=00-welcome")).text).toBe(
"static /?canvas=00-welcome",
);
const rootIndex = JSON.parse((await ask("/__sp/index.json")).text);
expect(rootIndex.project).toBeUndefined();
expect(rootIndex.boards.map((b: any) => [b.slug, b.example])).toEqual([
Expand All @@ -77,19 +81,31 @@ it("serves every project at its own address and makes new ones", async () => {
expect((await ask("/board/00-welcome/01-a.html")).text).toBe("welcome");
const copy = { slug: "00-welcome", name: "Mine" };
expect((await ask("/__sp/clone-canvas", copy)).status).toBe(409);
expect(fs.readdirSync(path.join(tmp, "root/canvases"))).toEqual(["00-welcome"]);
expect(fs.readdirSync(path.join(tmp, "root/canvases"))).toEqual([
"00-welcome",
]);
// The agent is no project's, and is at the root once. Nothing is written before it first runs.
const sessions = await ask("/__sp/agent/sessions");
expect(sessions).toMatchObject({ status: 200, text: "[]" });
const run = (body: object) => ask("/__sp/agent/run", { message: "hi", ...body });
const run = (body: object) =>
ask("/__sp/agent/run", { message: "hi", ...body });
expect((await run({ project: "nowhere" })).status).toBe(404);
expect((await run({ session: "00000000-0000-4000-8000-000000000000" })).status).toBe(404);
expect(
(await run({ session: "00000000-0000-4000-8000-000000000000" })).status,
).toBe(404);
expect((await run({ session: "../x" })).status).toBe(404);
expect(
(await ask("/__sp/agent/run", { message: "hi" }, { "sec-fetch-site": "cross-site" }))
.status,
(
await ask(
"/__sp/agent/run",
{ message: "hi" },
{ "sec-fetch-site": "cross-site" },
)
).status,
).toBe(403);
expect((await ask("/p/alpha/__sp/agent/sessions")).text).toBe("static /__sp/agent/sessions");
expect((await ask("/p/alpha/__sp/agent/sessions")).text).toBe(
"static /__sp/agent/sessions",
);
expect(fs.existsSync(path.join(tmp, "projects/.workspaces"))).toBe(false);
// A run an earlier server kept replays after a restart, pictures and all. One it died in the
// middle of replays with the end it never wrote.
Expand Down Expand Up @@ -147,15 +163,33 @@ it("serves every project at its own address and makes new ones", async () => {
// A new project, then opened. It gets no skills: the agent's are in its own folder.
const made = await ask("/__sp/projects", { name: " beta " });
expect(made.status).toBe(200);
expect(JSON.parse(made.text)).toEqual({ url: "/p/beta/" });
expect(
fs.existsSync(path.join(tmp, "projects/beta/canvases")),
).toBe(true);
expect(JSON.parse(made.text)).toEqual({ name: "beta", url: "/p/beta/" });
expect(fs.existsSync(path.join(tmp, "projects/beta/canvases"))).toBe(true);
expect(fs.existsSync(path.join(tmp, "projects/beta/.claude"))).toBe(false);
expect((await ask("/p/beta/__sp/index.json")).status).toBe(200);
expect((await ask("/__sp/projects", { name: "beta" })).status).toBe(409);
expect((await ask("/__sp/projects", { name: " " })).status).toBe(400);
// No name: the first free "Untitled", which its agent names in project.json.
for (const url of ["/p/Untitled/", "/p/Untitled%202/"])
expect(JSON.parse((await ask("/__sp/projects", { name: " " })).text).url).toBe(url);
write("projects/Untitled/project.json", JSON.stringify({ name: "Gamma" }));
const titled = JSON.parse((await ask("/__sp/projects.json")).text);
expect(titled.find((p: any) => p.name === "Untitled").title).toBe("Gamma");
expect(JSON.parse((await ask("/p/Untitled/__sp/index.json")).text).title).toBe("Gamma");
expect((await ask("/__sp/projects", { name: "a/b" })).status).toBe(400);

// A reference for a clone: into the project's `refs`, once, and only under a plain name.
const ref = "/__sp/projects/ref?name=beta&file=home.png";
expect((await ask(ref, { png: 1 })).status).toBe(204);
expect(
fs.readFileSync(path.join(tmp, "projects/beta/refs/home.png"), "utf8"),
).toBe('{"png":1}');
expect((await ask(ref, { png: 2 })).status).toBe(409);
expect(
(await ask("/__sp/projects/ref?name=beta&file=../x.png", {})).status,
).toBe(400);
expect(
(await ask("/__sp/projects/ref?name=nope&file=x.png", {})).status,
).toBe(404);
expect(fs.existsSync(path.join(tmp, "projects/gamma"))).toBe(false);
expect(
(
Expand Down
Loading
Loading