Skip to content

New project modes, canvas content on disk, and canvas tab actions - #170

Merged
Jing-yilin merged 20 commits into
mainfrom
new-project-modes
Sep 24, 2026
Merged

Jing-yilin merged 20 commits into
mainfrom
new-project-modes

Conversation

@Jing-yilin

@Jing-yilin Jing-yilin commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

What changes

  • New project dialog has two modes. Clone takes screenshots and recordings (dropped or browsed), copies them into refs/ and opens the project on /clone-prototype. Build is the idea-first flow with the /define-product offer. The name is optional.
  • A project with no canvases opens on a blank canvas. Its title comes from project.json.
  • The strip's + makes an empty "Untitled" canvas after the existing ones and puts its tab into inline rename. Double-click renames later.
  • What a person puts on a canvas is saved in its folder: canvas.json holds the records, files/ the pasted files. The file wins on load. A pasted link to a board or picture becomes an image the person owns. The person's shapes get the + (add to chat) and ⌘C links. The agent's bridge can change only shapes it made.
  • Uploads stream to disk with no size cap on a served page. /board answers Range requests, so video seeks.
  • Canvas tab right-click: Show in Finder, and Move to Trash… after a confirm. Examples get neither. The project tab already had Show in Finder.
  • Security: SAFE_NAME now refuses a leading dot, so .. can no longer name the project folder or its parent on any route.

The design and what the adversarial review changed: docs/2026-09-24-canvas-content-on-disk.md.

Checks

  • tsc -b, oxlint: clean
  • vitest: 188 passed, 1 skipped
  • A 1.1 GB upload streamed in under a second at about 120 MB RSS. A Range request returns 206.

🤖 Generated with Claude Code


Devin Review

Jing-yilin and others added 2 commits September 24, 2026 02:51
The New project dialog lists two ways to start down its side. Clone takes
screenshots and screen recordings, dropped anywhere on the dialog or browsed
for, copies them into the project's refs/ and opens it on /clone-prototype
naming them. Build is the dialog as it was, with the /define-product offer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…from the strip

A canvas's pasted and dropped content is written to canvas.json beside its
boards, with the files it points at under files/, instead of living only in
the browser. The file wins on load, an emptied page removes it, and a link
to a board or picture pasted onto the canvas becomes an image the person
owns. The agent reads that content and changes only its own. Uploads stream
to disk with no size cap, and /board answers Range requests for video.

The strip's + makes an empty "Untitled" canvas after the others and puts its
tab into rename. A canvas tab's menu shows its folder, or moves it to the
Trash after asking. Folder names that start with a dot are refused
everywhere, so no request can name the project folder or the one above it.

docs/2026-09-24-canvas-content-on-disk.md says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Deploying super-prototyping with  Cloudflare Pages  Cloudflare Pages

Latest commit: 31dd9b2
Status: ✅  Deploy successful!
Preview URL: https://1eff5b6c.super-prototyping.pages.dev
Branch Preview URL: https://new-project-modes.super-prototyping.pages.dev

View logs

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 8 potential issues.

Devin Review

Comment thread canvas/src/canvasContent.ts Outdated
Comment on lines +182 to +187
written.set(slug, body);
void fetch(`${import.meta.env.BASE_URL}__sp/canvas-content`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ slug, file: body ? file : null }),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Canvas edits lost after failed saves

When a canvas save fails or overlaps a later save, flush marks it written before the server confirms it. Failed writes are not retried, and an older response can overwrite newer edits on disk.

Learn more

The editor serializes each project page and sends it to the canvas-content endpoint. written advances before the request completes. A failed request therefore leaves the new records only in IndexedDB; subsequent flushes skip them because the body matches written. Overlapping requests for the same slug can also finish in reverse order, so an older body replaces a newer one. The server's content endpoint unconditionally writes the last request it receives.

Example: A user moves a shape, then moves it again after the first 500 ms flush. If the first POST finishes last, canvas.json holds the first position even though the editor displays the second.

Recommended fix: Serialize writes per slug, retain the latest unsent body, and advance the acknowledged baseline only after a successful response. Surface or retry failures and flush pending changes before teardown where possible.

Devin Review


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

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: writes are chained per slug, so they land in order, and a failed write clears the baseline so the next change resends the page. Not flushed on teardown: a reload within 500 ms of an edit can still drop that edit.

Comment thread canvas/src/App.tsx
Comment on lines +1138 to +1143
const own = named?.startsWith("shape:") && editor.getShape(named as TLShapeId);
if (own) {
show.board(null);
editor.select(own);
requestAnimationFrame(() => editor.zoomToSelection({ animation: { duration: 0 } }));
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Shape links lose their target on opening

When a shape link opens, show.board(null) clears the URL writer's target. write(false) then removes the shape hash, so copying or reloading the address no longer targets that shape.

Learn more

A person's shape is linked as ?canvas=<slug>#shape:<id>. On opening it, apply always follows applyCanvasFromUrl with write(false). The writer takes its target from opened.current, which show.board(null) resets. The URL is immediately replaced with the bare canvas URL, even though the selection remains visible.

Example: Opening ?canvas=untitled#shape:note1 selects note1, but the address becomes ?canvas=untitled; copying that address no longer shares note1.

Recommended fix: Keep the selected shape id in the URL sync's target state and preserve it through write(false) until selection or tab navigation changes.

Devin Review


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

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: when no board or picture is open, the address keeps the id of a single selected person's shape, so an opened shape link keeps its #shape.

Comment on lines +260 to +262
const own = found.filter((f) => !("board" in f!)).map((f) => f!.shape.id);
const content = own.length ? editor.getContentFromCurrentPage(own) : undefined;
if (content) editor.putContentOntoCurrentPage(content, { point, select: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Cross-canvas shape links paste nothing

When a shape link from another canvas is pasted, fromLinks finds the shape but copies only the current page. It consumes the paste without adding that shape.

Learn more

The link parser accepts any owned shape found in the editor, regardless of its page. getContentFromCurrentPage reads only the page receiving the paste, so an id from another canvas does not yield a copy. The handler still returns true instead of falling back, leaving nothing pasted.

Example: Copy a link to a note on canvas alpha, switch to canvas beta, and paste the link. The parser recognizes the note but asks for its content from beta; no note appears.

Recommended fix: Read content from the source shape's page, or explicitly reject cross-page links and let the default paste handler handle them.

Devin Review


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

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: a shape on another page is no longer taken, so the paste falls through to tldraw's default and pastes as a link.

Comment on lines +206 to +221
<Tabs.Content value="build">
<h3>Build from an idea</h3>
<p className="new-project-lede">
Work out what the product is with the agent, then mock its
screens.
</p>
{nameField}
{/* Whatever they have, in their words: the agent starts from it (AppShell.tsx). */}
<label className="new-project-idea">
<span>
What's the idea? <small>Optional</small>
</span>
<textarea
name="idea"
placeholder="Say whatever comes to mind: who it's for, what bugs you about how it's done now, an app it should feel like, a screen you can already picture…"
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Project idea disappears on mode switch

Switching away from Build unmounts its Tabs.Content, discarding the uncontrolled idea field. Returning to Build shows an empty idea, so creating the project omits the user's draft.

Learn more

The dialog holds the project name in React state, but the Build idea is an uncontrolled textarea under a Radix tab panel. Tab panels unmount while inactive. Selecting Clone and returning to Build therefore recreates an empty textarea, and the submit handler reads this empty value through FormData.

Example: Type A photo organizer in Build, inspect Clone, then return to Build and press Create. The initial agent message has no idea text.

Recommended fix: Hold the idea and checkbox values in component state across tab switches, or keep the Build panel mounted and reset its state only when the dialog closes.

Devin Review


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

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: idea and the define checkbox are held in state like the name, and reset when the dialog closes.

Comment on lines +15 to +21
/**
* The New project dialog: an optional name, and which of two ways the project starts, listed
* down the side so a third has somewhere to go. Clone takes screenshots and screen recordings,
* dropped anywhere on the dialog or browsed for, and hands them to the clone-prototype skill.
* Build is the dialog as it was, a name and the offer of define-product. The modes are Radix's
* tabs, for their keys and roles; the dialog is the browser's, which already traps focus and
* closes on Escape. It is one size for both, so switching moves nothing (home.css).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Add an Unreleased entry

This PR changes the project dialog and canvas workflow but leaves ## Unreleased unchanged. The repository requires a release-note entry for user-visible changes.

Devin Review


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

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.

Added three lines under ## Unreleased.

Comment thread canvas/src/NewProjectDialog.tsx Outdated
Comment on lines +12 to +13
/** Pictures and recordings, which is what clone-prototype measures from. */
const isReference = (file: File) => /^(image|video)\//.test(file.type);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Inline the single-use reference filter

isReference has one call site in add. The repository review rules favor inlining helpers used only once.

Devin Review


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

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.

Inlined.

Comment on lines +14 to +18
1. **Two owners, one page.** The agent's output is boards and `layout.json`'s pictures and cards.
`layout.json` lays it out, it is locked, and every load rebuilds it from the files. Everything
else on the page is the person's: whatever they pasted or dropped there.
2. **Anything tldraw can paste can be pasted.** That covers images, videos, SVG, text, links and
tldraw's own shapes. tldraw's default handlers decide what each becomes. What tldraw can show

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Remove em dashes from the design note

The design note uses em dashes, which the repository's writing rules explicitly flag in human-facing text.

Devin Review


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

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.

The note has no em dashes (grep finds none).

Comment thread canvas/server/sp.ts
Comment on lines +641 to +647
if (action === "reveal")
return reveal(folder).then(
() => send(204, ""),
() => send(501, "No file manager here could show that folder."),
);
if (action !== "delete") return send(400, "reveal or delete");
if (isExample(slug)) return send(403, READ_ONLY);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟥 Example folder access bypass in reveal action

When a project folder shadows an example without boards, reveal runs before isExample checks the canvas. The route exposes a folder action for a read-only example.

Devin Review


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

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.

No change: that folder is the project's own (an empty folder an example shadows), and reveal only shows it in Finder. Delete, the destructive action, is refused for it, and the client shows neither row on an example tab.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8a104c7aa6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/server/sp.ts
Comment on lines +1006 to +1009
if (
(fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "") !==
wroteContent.get(file)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Broadcast canvas saves to other open pages

When two windows have the same project open, a save from either window writes the shared wroteContent value, so this comparison treats the change as local and suppresses the reload for every SSE client—not just the requester. The other window remains stale and its next save can overwrite the first window's changes; the suppression needs requester-specific tracking or the updated content must be broadcast to the other pages.

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: the canvas-content endpoint now broadcasts a content event carrying the saving page's id, and every other page on the project reloads onto the file.

Comment thread canvas/src/canvasContent.ts Outdated
Comment on lines +195 to +197
() => {
clearTimeout(pending);
pending = setTimeout(flush, 500);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Flush debounced canvas edits before navigation

If a user edits an existing canvas and switches projects, reloads, or closes the window within this 500 ms debounce, the timer never gets to post the new canvas.json. IndexedDB may retain the edit, but the next load explicitly lets the older on-disk file win and removes the local version, causing data loss; add an unload-safe flush using keepalive/sendBeacon or otherwise persist before the frame is replaced.

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: on pagehide a waiting save is sent at once with keepalive. Known ceiling: keepalive caps a body at 64 KB, so a larger page's last 500 ms can still be lost.

Comment thread canvas/src/canvasContent.ts Outdated
Comment on lines +181 to +187
if (written.get(slug) === body) continue;
written.set(slug, body);
void fetch(`${import.meta.env.BASE_URL}__sp/canvas-content`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ slug, file: body ? file : null }),
});

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 Retry canvas saves that fail

The body is marked as written before the request has succeeded, and the fetch response and rejection are ignored. If the local server returns an error—for example because the disk is full—or the request is interrupted, an unchanged page is never retried, no error is shown, and its older canvas.json will replace the browser state on the next load; update written only after a successful response and retain or surface failed saves.

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 in the previous push: writes are chained per slug, and a failed write clears the baseline and logs, so the next change resends the page.

Comment thread canvas/src/AppShell.tsx
Comment on lines +213 to +217
const first = [
name.trim() === "" &&
`name this project: write a short name for it as {"name": "…"} in project.json at the project's root (if you cannot tell yet what it is, make that your first question to me)`,
`make the canvas the work goes in and name it: a folder under canvases/ with its "name" in layout.json and a first board, so it opens on my screen`,
].filter(Boolean);

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 Define the product before requesting its first board

In the default “Build from an idea” flow, this unconditional instruction tells the agent to create a first board before /define-product runs. That directly reverses skills/define-product/SKILL.md's requirement to establish the product before drawing a screen, so the default checked flow asks the agent to invent a board before learning the user, problem, scope, or screen inventory; create only the empty canvas here and request the first board after product definition.

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: with define-product checked, the first message asks for the named, empty canvas only, and no board until PRD.md says what the product is.

Jing-yilin and others added 5 commits September 24, 2026 04:34
From review. A canvas's writes go one at a time, so an older one cannot
land after a newer, and a failed write is sent again on the next change
instead of counted as saved. An opened shape link keeps its #shape in the
address. A shape link from another canvas pastes as a link rather than
nothing. The idea typed under Build survives a look at Clone. The release
notes say what the branch changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efore drawing

From review. A canvas save is broadcast to every other page on the
project, which reloads onto the file, since the watcher takes the write for
the saving page's own. A save still waiting when the page goes away is sent
at once with keepalive. Build with define-product asks the agent for an
empty canvas, and no board until PRD.md says what the product is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Metaphors and arrows in docs/2026-09-24-canvas-content-on-disk.md say
what they mean, a compressed comment in CanvasStrip.tsx is spelled out,
and the em dashes in the branch's comments and the idea prompt are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a cut-off turn

A rewritten board now swaps in live rather than reloading the page, so the
person can keep drawing while the agent works. The canvas in front glows
while the running turn writes to it, and the other canvases and projects it
writes to get a dot. A turn cut off by a quit or a dead CLI says so in the
panel with a Continue button, and the resume id is saved as soon as it is
known so Continue keeps the conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rking-glow

# Conflicts:
#	canvas/server/sp.ts
#	canvas/src/canvasContent.ts
#	canvas/src/canvasIndex.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d41085464

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/src/canvasContent.ts Outdated
Comment on lines +199 to +201
if (leaving) {
void post();
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve save ordering during pagehide

When an ordinary save for this slug is still in flight and a newer edit is pending as the page closes, this branch sends the newer body outside the per-slug saving chain. The earlier request can therefore reach the server after the keepalive request and overwrite the last edit. Fresh evidence after the earlier ordering fix is the later pagehide branch bypassing that chain; enqueue the final request after the outstanding save while retaining unload-safe delivery.

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: each save carries a per-page sequence number, and the server drops a write older than the last it took from that page for that canvas, so the keepalive save on pagehide can't be overwritten by an earlier one still in flight. The client-side chain is gone, since the server now does the ordering. Covered in sp.test.ts.

Comment thread canvas/server/sp.ts
Comment on lines +1009 to +1012
if (
(fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "") !==
wroteContent.get(file)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clear write suppression after consuming the matching event

When an external operation later restores canvas.json to bytes previously written through this endpoint—for example git checkout reverting the file—the comparison still matches because wroteContent entries are never cleared. The open page then misses the external change and its next edit can overwrite the revert. Fresh evidence after the prior review is that the new content broadcast handles peer pages but does not expire this watcher-suppression state.

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: the watcher deletes the wroteContent entry once it has compared it, so a later checkout that restores the same bytes reloads the page.

Comment thread canvas/server/projects.ts Outdated
send(error.code === "EEXIST" ? 409 : 500, error.message),
);
out.on("finish", () => send(204, ""));
req.pipe(out);

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 Remove partial clone references after interrupted uploads

If a large screenshot or recording upload is interrupted by a reload, app exit, or connection reset, req.pipe(out) leaves the destination stream open and the partial file remains at its final refs/<name> path. A later retry is rejected by the wx flag as already existing, so the clone is permanently handed a truncated reference unless the user repairs the project folder manually. Stream into a temporary file with abort cleanup and rename only after completion, as the canvas-file endpoint already does.

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: the ref is streamed to .part with pipeline, renamed once whole, and removed on failure, as canvas-file does. An existing ref still answers 409.

From review. A canvas save carries a number, and the server drops one older
than the last it took from that page, so the save sent as the page closes
cannot be overwritten by one still on its way. The watcher forgets a write
once it has heard it, so a checkout that puts the same bytes back reloads
the page. A reference is uploaded beside its name and renamed once whole, so
an upload cut off leaves nothing a retry is refused by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f3a5624a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/server/sp.ts Outdated
Comment on lines +739 to +741
wroteContent.set(target, text);
if (text) fs.writeFileSync(target, text);
else fs.rmSync(target, { force: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Write canvas content atomically

When replacing an existing canvas.json, a disk-full error or process termination after writeFileSync truncates the target can leave the previous valid file empty or partially written even though the request fails. Because this file wins over IndexedDB on the next load, that destroys the last durable copy of the canvas; write to a temporary sibling and rename only after the write completes, as the upload endpoints do.

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: written to canvas.json.part and renamed over canvas.json. The watcher ignores the .part name.

Comment thread canvas/src/canvasContent.ts Outdated
Comment on lines +164 to +168
editor.store.put(
wanted.filter(
(record) =>
record.typeName !== "binding" ||
(kept.has(record.fromId) && kept.has(record.toId)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Distinguish a malformed canvas file from a missing one

When a merge or interrupted external edit leaves canvas.json malformed and this browser has persisted shapes, boardIndex's readJson returns undefined, so this branch treats the file as never having existed and the initial flush() overwrites it with potentially stale IndexedDB state. Propagate the parse failure and stop loudly rather than using the migration path for both states.

AGENTS.md reference: AGENTS.md:L129-L130

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: the index sends content: null for a canvas.json that exists but won't parse. The page leaves that canvas out of loading and saving, and alerts with the path. Covered in sp.test.ts.

() => {
clearTimeout(pending);
pending = setTimeout(() => flush(), 500);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resend an in-flight save when the page is hidden

If the debounce has already fired and its ordinary fetch is still in flight when the user closes, reloads, or switches projects, pending is already undefined, so this handler sends no keepalive request and navigation may cancel the only save. Fresh evidence after the earlier pagehide fix is that the guard covers only a waiting timer, not an unacknowledged request; retain the latest unacknowledged body and resend it with a newer sequence on pagehide.

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: canvases with a save sent but not yet answered are tracked, and on pagehide they are resent with keepalive and a newer sequence number, along with any waiting save.

Comment thread canvas/server/boards.ts
Comment on lines 307 to +311
.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),

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.

…saves

From review. canvas.json is written beside itself and renamed over, so a
write cut off leaves the last whole file. One that will not parse reaches
the page as null, which neither loads nor writes over it and says so. A
save sent and not yet answered is sent again with keepalive when the page
goes away. A folder with only a layout.json is the project's own, so one
begun under an example's name is not read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b25475070d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/src/agentBridge.ts
case 'create':
editor.markHistoryStoppingPoint('agent:create')
editor.createShapes(command.shapes)
editor.createShapes(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject existing shape IDs on agent creates

When an agent passes an explicit ID from get to create, this path writes it without the ownership check applied to update and delete. If that ID belongs to a person's shape, createShapes can replace that record while stamping it as agent-owned and locked, bypassing the protection this change is intended to provide; reject any supplied ID that already exists or apply the same ownership check before creation.

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: create now refuses any supplied id that already names a shape, with 'exists; update it instead', so update's ownership check can't be bypassed through create.

Comment thread canvas/src/AppShell.tsx Outdated
Comment on lines +217 to +219
start.mode === "build" && start.define
? `make the canvas the work goes in and name it: a folder under canvases/ with its "name" in layout.json, so it opens on my screen, and no board in it until PRD.md says what the product is`
: `make the canvas the work goes in and name it: a folder under canvases/ with its "name" in layout.json and a first board, so it opens on my screen`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Delay clone boards until the clone workflow reaches HTML

When “Clone an app” is selected, this condition is false, so the prompt still says to create a first board before running /clone-prototype—and, with no files supplied, before even asking which app or references to use. The earlier define-product case is exempted, but the clone skill likewise requires collecting references and deriving measured tokens before HTML, so this default flow can create a guessed board that violates the clone workflow; create only the empty named canvas until the skill reaches its board-generation phase.

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: the first message asks for the empty, named canvas in every mode, with no board until the work reaches one. The clone and define-product skills each decide when the first board is due.

Comment thread canvas/server/sp.ts
Comment on lines +409 to +413
const CANVAS_FILE_MIME: Record<string, string> = {
...IMAGE_MIME,
".mp4": FILE_MIME[".mp4"],
".webm": FILE_MIME[".webm"],
".mov": FILE_MIME[".mov"],

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 Accept browser-supported image formats for canvas files

On a project canvas, an otherwise pasteable image such as an AVIF or BMP is routed through the new asset store with its original extension, but this allowlist contains only the six formats in IMAGE_MIME; /__sp/canvas-file therefore returns 415 and the paste fails. The same file can still be handled by the inline store outside project pages, so persistence introduces a format-dependent regression; either extend the served image MIME set to the formats tldraw accepts or normalize uploads to a supported format.

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: added .apng and .avif, the rest of tldraw's DEFAULT_SUPPORTED_IMAGE_TYPES. BMP isn't in tldraw's list, so tldraw doesn't paste it as an image anyway.

From review. The bridge's create refuses an id that already names a shape,
which would have replaced it, a person's included, stamped as the agent's.
A new project's first message asks for an empty named canvas in every mode,
since both skills say when the first board is due. APNG and AVIF, which
tldraw pastes as images, are saved to a canvas like the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e095463f35

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/src/canvasIndex.ts Outdated
Comment on lines +114 to +115
events.addEventListener("content", (event) => {
if (JSON.parse(event.data).by !== PAGE_ID) window.location.reload();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Defer peer reloads while a document draft is open

When another window saves any canvas in the same project, this unconditionally reloads every other project frame, including one currently editing PRD.md. DocTab only attempts a keepalive save during pagehide; if the document changed since editing began, that save returns 409 and the reload destroys the in-memory draft before the error can be shown. Fresh evidence after the earlier broadcast fix is that the new handler reloads every SSE client without accounting for an active document draft, so peer canvas activity can discard conflicting document edits.

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.

No change here. A reload doesn't throw the draft away: DocTab's pagehide handler saves an open PRD.md draft with keepalive before the page goes. It is the same path taken when you leave for another project, and the existing reload event relies on it too. What can still be lost is a draft whose save is refused (the base changed underneath it, or it is over the 64 KiB keepalive limit). The reload event carries the same risk today, so fixing it belongs with that path, not in this PR.

Comment thread canvas/src/AppShell.tsx Outdated
Comment on lines +201 to +205
// The project is made by now, so trying again would only be told the name is taken.
if (!up.ok)
return setSaid(
`The project was made, but ${file.name} could not be copied into it: ${await up.text()}`,
);

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 Recover the project after a reference upload fails

If a screenshot or recording upload returns an error after the project directory has already been created, this leaves the dialog open but provides no way to resume opening that project. Submitting again with the same name is rejected as an existing project, while choosing another name leaves the partially created project and successfully uploaded references behind; a rejected fetch additionally escapes without clearing the dialog's busy state. Continue into the created project with the completed uploads, retry the failed reference, or roll the project back.

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 in b66bd4d. After the project exists, a ref that fails (a non-OK response or a rejected fetch) is added to one alert and left out. The project then opens with the refs that did copy, and the agent's message names only those. The dialog's submit now wraps create in try/finally, so busy always clears.

Comment thread canvas/src/agentBridge.ts Outdated
agentsOnly(editor, command.shapes.map((shape) => shape.id))
editor.markHistoryStoppingPoint('agent:update')
editor.updateShapes(command.shapes)
editor.run(() => editor.updateShapes(command.shapes), { ignoreShapeLock: true })

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 Preserve agent ownership on updates

When an agent updates one of its existing shapes, parseShapes permits arbitrary meta and isLocked fields and this call applies them verbatim under ignoreShapeLock. An update containing meta: {} removes the ownership stamp so subsequent updates and deletes are refused, while isLocked: false makes an agent-owned shape editable by the person, violating the bridge's stated ownership boundary. Reapply meta.by: "agent" and isLocked: true, or reject updates to those fields.

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 in b66bd4d: update now re-applies isLocked: true and meta.by: 'agent' to every shape it writes, as create does, so an update can't unlock a shape or remove its stamp.

Jing-yilin and others added 2 commits September 24, 2026 05:17
…at will not copy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rking-glow

# Conflicts:
#	canvas/src/canvasContent.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b66bd4da04

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/src/NewProjectDialog.tsx Outdated
{mode === "clone" && (
<span>You can add more later in the chat</span>
)}
<button type="button" onClick={() => dialog.current!.close()}>

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 Stop project creation when the dialog is cancelled

While a clone's potentially hundreds-of-megabytes references are uploading, this Cancel button remains enabled and merely closes the dialog; the awaited create() continues uploading and subsequently loads the new project and sends its agent prompt. A user who cancels during a long upload is therefore unexpectedly navigated into the project later, so cancellation should abort the in-flight work or be disabled once submission begins.

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 in 7170b56: once Create is pressed, Cancel is disabled and the dialog's cancel event (Escape) is prevented while busy. The dialog stays open until create finishes, and the try/finally from the last round re-enables both.

Comment thread canvas/src/CanvasStrip.tsx Outdated
className="sp-canvas-tab"
aria-current={canvas === here ? "page" : undefined}
onClick={() => openTab({ kind: "canvas", slug: canvas })}
onDoubleClick={() => tab.kind !== "example" && setRenaming(canvas)}

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 Keep hosted canvas tabs read-only

In the hosted build, served is false but its canvases are not marked as example tabs, so this condition still opens the inline rename field. Blurring it calls the unavailable /__sp/canvas-name endpoint, and rename() ignores the failed response and exits rename mode, making the hosted UI appear to accept a name that is immediately lost; gate renaming on canvasIndex().served as the other write controls are.

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 in 7170b56: double-click renaming now also requires canvasIndex().served, like the "+" and the folder menu rows, so hosted tabs are read-only.

Jing-yilin and others added 7 commits September 24, 2026 05:25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page reloading onto a canvas.json another window saved no longer saves its own older copy
on the way out, which put the deleted item back and reloaded the window that deleted it. An
emptied canvas keeps its file with no records: no file is what a page that has never saved
sees, and it writes back what the browser held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anges

The canvas the agent is writing to gets a thin blue glow inside its edge while the run lasts,
and its tab a metallic sheen across the name. A board the agent adds is ringed blue and one
whose bytes changed green, which the server tells by hashing each board. Continue after an
interrupted run sends just "continue", since the resumed session has its own record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The canvas tab's and the home page's Move to Trash asked through the browser's confirm(),
which heads itself with the address and cannot be styled. They share one <dialog> now,
opened in the top window so it sits in the middle of the whole app, chat included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…off-page, load canvases added live

- Continue posts the turn's own project, and asks the prompt again for a turn that never got as
  far as the agent naming its session.
- A project's first canvas, added live, comes in front of the blank that stood in for it.
- A board rewritten while its page is hidden, or while its first fetch is out, is fetched again.
- A canvas added live loads its canvas.json before any save, and a broken one is never written over.
- The shell's bridge is in place before the frame reads it.
- Release notes for the glow, Continue and the two-window delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…placement rule layout.json cannot follow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Glow round the canvas the agent is writing to, and offer to continue a cut-off turn

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9197aa9e5e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/src/agentBridge.ts
Comment on lines +102 to +105
function agentsOnly(editor: Editor, ids: TLShapeId[]) {
for (const id of ids) {
if (editor.getShape(id)?.meta.by !== 'agent') {
throw new Error(`${id} is not the agent's to change; add a shape beside it instead`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate descendant ownership before mutating containers

When an agent-owned frame contains shapes later added by the person, this check approves an update or deletion based only on the frame's ID. Moving that frame indirectly moves the person's children, and deleteShapes recursively removes them while ignoreShapeLock is enabled, permanently saving their deletion to canvas.json; validate every affected descendant's ownership or prevent agent-owned containers from acquiring person-owned children.

Useful? React with 👍 / 👎.

Comment thread canvas/src/ChatPanel.tsx
Comment on lines +383 to +387
if (!turns.length)
void fetch("/__sp/agent/sessions")
.then(async (res) => {
const [newest] = res.ok ? await res.json() : [];
if (newest?.interrupted) pick(newest);

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 Preserve a remembered empty session on reload

When the selected session predates persisted run logs, session is remembered but turns is legitimately empty, as the empty-session UI below explicitly supports. Reloading in that state enters this branch and silently replaces the selected conversation with whichever newest session is marked interrupted; only auto-pick an interrupted session when both turns and the remembered session are absent.

Useful? React with 👍 / 👎.

Comment thread canvas/server/boards.ts
Comment on lines +305 to +307
content: fs.existsSync(path.join(folder, "canvas.json"))
? (readJson(path.join(folder, "canvas.json")) ?? null)
: undefined,

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 👍 / 👎.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Jing-yilin
Jing-yilin merged commit f41a2f9 into main Sep 24, 2026
10 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31dd9b2ee3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/server/sp.ts
// The set of canvases changed, which only a reload shows, and the page that asked reloads
// itself onto the new one. Taken off the watcher, whose reload could land before the page
// had read the answer and so on the canvas it was on.
signature = boardSetSignature(canvasesDir, list);

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 Notify other pages when creating a canvas

When one window creates an empty canvas, assigning the new boardSetSignature here prevents the watcher from recognizing the folder as a changed canvas set. The requesting window reloads itself, but every other open window receives at most a layout event for a slug absent from its index and therefore never adds the new tab until a later board-set change; broadcast the updated index after answering the creator rather than silently consuming the signature change.

Useful? React with 👍 / 👎.

Comment thread canvas/server/sp.ts
Comment on lines +1003 to +1007
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));

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 Filter hash candidates through board discovery

If a canvas contains a directory whose name ends in .html, this initialization passes it to readFileSync, which throws and prevents that project's server from being created. boardIndex() deliberately accepts only actual files, so the new hash cache needs to reuse that discovery predicate instead of treating every matching directory entry as a board.

AGENTS.md reference: AGENTS.md:L20-L22

Useful? React with 👍 / 👎.

Comment on lines +267 to +269
const ext =
/\.[a-z0-9]+$/i.exec(file.name)?.[0].toLowerCase() ??
`.${file.type.split("/")[1]?.replace("+xml", "")}`;

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 Derive persisted asset suffixes from supported MIME types

For a browser-supported image whose filename has a non-allowlisted suffix—for example a image/jpeg file named photo.jfif, or a pasted image/png named image.bin—this prefers that suffix and /__sp/canvas-file returns 415, although tldraw accepted the file and the inline store handled it previously. Fresh evidence after the earlier allowlist fix is that the client still trusts any filename extension before consulting the MIME type; normalize supported MIME types to a served suffix and retain the original extension only when it is recognized.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant