Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
12 changes: 12 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ 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.

## v1.6.1

Expand Down
6 changes: 5 additions & 1 deletion canvas/server/boards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,15 @@ export function boardIndex(
thumbs: [] as string[],
assets: assetIndex(folder),
comments: readJson(path.join(folder, "comments.json")),
content: readJson(path.join(folder, "canvas.json")),
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
132 changes: 55 additions & 77 deletions canvas/server/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
* home page, at `/home.html`, the examples, and the agent behind the chat panel, at
* `/__sp/agent`, so the app works with no project at all.
*/
import { execFile } from "node:child_process";
import fs from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { createAgentServer } from "./agent.ts";
import { CANVASES } from "./boards.ts";
import { createSpServer, sameOrigin } from "./sp.ts";
import { createSpServer, reveal, sameOrigin, trash } from "./sp.ts";

/**
* The folder every project is in, and where a new one goes. The desktop app's is Electron's
Expand All @@ -33,75 +33,6 @@ export function projectsDirFromEnv() {
);
}

/**
* Shows a folder in the OS's file manager, selected in the folder it is in. Rejects when the
* folder was not shown: on a Linux without xdg-open, or with one that has no file manager to
* hand the folder around it to.
*/
function reveal(dir: string) {
const [file, args] =
process.platform === "darwin"
? ["open", ["-R", dir]]
: process.platform === "win32"
? ["explorer", ["/select,", dir]]
: ["xdg-open", [path.dirname(dir)]];
return new Promise<void>((resolve, reject) => {
// Explorer exits 1 when it has shown the folder, so there only a missing command is a
// failure. `open` and `xdg-open` mean their exit codes, and a non-zero one from them is the
// only sign that nothing came up.
execFile(file, args, (error) =>
!error ||
(process.platform === "win32" &&
(error as NodeJS.ErrnoException).code !== "ENOENT")
? resolve()
: reject(error),
);
});
}

/**
* Moves a folder to the Trash, or the Recycle Bin, where the user can put it back. macOS goes
* through Foundation rather than Finder, which would ask for permission to be scripted; Windows
* gets the path through the environment, so no quoting of it can go wrong.
*/
function trash(dir: string) {
const [file, args] =
process.platform === "darwin"
? [
"osascript",
[
"-l",
"JavaScript",
"-e",
'function run(argv) { ObjC.import("Foundation"); if (!$.NSFileManager.defaultManager' +
".trashItemAtURLResultingItemURLError($.NSURL.fileURLWithPath(argv[0]), null, null))" +
' throw new Error("it could not be moved to the Trash") }',
dir,
],
]
: process.platform === "win32"
? [
"powershell",
[
"-NoProfile",
"-Command",
"Add-Type -AssemblyName Microsoft.VisualBasic; " +
"[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory(" +
"$env:SP_TRASH, 'OnlyErrorDialogs', 'SendToRecycleBin')",
],
]
: ["gio", ["trash", dir]];
return new Promise<void>((resolve, reject) => {
execFile(
file,
args,
{ env: { ...process.env, SP_TRASH: dir } },
(error, _stdout, stderr) =>
error ? reject(new Error(stderr.trim() || error.message)) : resolve(),
);
});
}

/**
* Moves a project's boards, once, from where they used to be, `mockups/canvases`, to
* `canvases`, and removes `mockups` if that left nothing in it but the `.DS_Store` Finder leaves
Expand Down Expand Up @@ -191,6 +122,47 @@ export function createProjectsServer(options: {
}
if (pathname.startsWith("/__sp/agent/"))
return agent.handle(req, res, next);
// A reference a new clone starts from, dropped on the New project dialog
// (NewProjectDialog.tsx): the request's body, written as it arrives into the project's
// `refs`, which is what the first message to the agent names. A screen recording can be
// hundreds of MB, so it is streamed rather than read into memory.
if (pathname === "/__sp/projects/ref") {
if (req.method !== "POST") return next();
const send = (code: number, message: string) => {
res.statusCode = code;
res.end(message);
};
if (!sameOrigin(req)) return send(403, "cross-site request");
const params = new URLSearchParams(query);
const dir = projects().get(params.get("name") ?? "");
if (dir === undefined) return send(404, "no such project");
const file = params.get("file") ?? "";
if (file === "" || file.startsWith(".") || path.basename(file) !== file)
return send(
400,
"A file name cannot start with a dot or have a slash in it.",
);
const refs = path.join(dir, "refs");
fs.mkdirSync(refs, { recursive: true });
// A reference already there is never written over. The rest is written beside its name
// and renamed once whole, so an upload cut off leaves nothing a retry would be refused by.
const target = path.join(refs, file);
if (fs.existsSync(target)) {
req.resume();
return send(409, `${file} is already in refs`);
}
const partial = `${target}.part`;
pipeline(req, fs.createWriteStream(partial))
.then(() => {
fs.renameSync(partial, target);
send(204, "");
})
.catch((error) => {
fs.rmSync(partial, { force: true });
send(500, String(error));
});
return;
}
if (/^\/__sp\/projects(\/(reveal|delete))?$/.test(pathname)) {
if (req.method !== "POST") return next();
const send = (code: number, message: string) => {
Expand Down Expand Up @@ -232,11 +204,15 @@ export function createProjectsServer(options: {
send(500, `“${parsed.name}” is still there: ${error.message}`),
);
}
// A new project needs only a name, as in Screen Studio. It goes under the projects folder,
// so there is no place to pick. These are the checks the app's dialog made, since the
// field's `required` lets a name of spaces through and knows nothing of folders.
const name = typeof parsed.name === "string" ? parsed.name.trim() : "";
if (name === "") return send(400, "Give the project a name first.");
// A new project needs nothing, not even a name: without one it is the first free
// "Untitled", and its agent names it (sp.ts, PROJECT_JSON). It goes under the projects
// folder, so there is no place to pick. The checks are the ones the dialog cannot make.
let name = typeof parsed.name === "string" ? parsed.name.trim() : "";
if (name === "") {
name = "Untitled";
for (let n = 2; fs.existsSync(path.join(projectsDir, name)); n++)
name = `Untitled ${n}`;
}
if (name.startsWith(".") || path.basename(name) !== name)
return send(
400,
Expand All @@ -261,7 +237,9 @@ export function createProjectsServer(options: {
);
}
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ url: `/p/${encodeURIComponent(name)}/` }));
res.end(
JSON.stringify({ name, url: `/p/${encodeURIComponent(name)}/` }),
);
});
return;
}
Expand Down
Loading
Loading