Repository navigation
New project modes, canvas content on disk, and canvas tab actions - #170
Conversation
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>
Deploying super-prototyping with
|
| 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 |
| 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 }), | ||
| }); |
There was a problem hiding this comment.
🔴 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
| 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 }); |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed: a shape on another page is no longer taken, so the paste falls through to tldraw's default and pastes as a link.
| <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…" | ||
| /> |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed: idea and the define checkbox are held in state like the name, and reset when the dialog closes.
| /** | ||
| * 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). |
There was a problem hiding this comment.
Added three lines under ## Unreleased.
| /** Pictures and recordings, which is what clone-prototype measures from. */ | ||
| const isReference = (file: File) => /^(image|video)\//.test(file.type); |
| 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 |
There was a problem hiding this comment.
The note has no em dashes (grep finds none).
| 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); |
There was a problem hiding this comment.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| if ( | ||
| (fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "") !== | ||
| wroteContent.get(file) | ||
| ) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| () => { | ||
| clearTimeout(pending); | ||
| pending = setTimeout(flush, 500); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| 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 }), | ||
| }); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
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
There was a problem hiding this comment.
💡 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".
| if (leaving) { | ||
| void post(); | ||
| continue; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| if ( | ||
| (fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "") !== | ||
| wroteContent.get(file) | ||
| ) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Fixed: the watcher deletes the wroteContent entry once it has compared it, so a later checkout that restores the same bytes reloads the page.
| send(error.code === "EEXIST" ? 409 : 500, error.message), | ||
| ); | ||
| out.on("finish", () => send(204, "")); | ||
| req.pipe(out); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
💡 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".
| wroteContent.set(target, text); | ||
| if (text) fs.writeFileSync(target, text); | ||
| else fs.rmSync(target, { force: true }); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Fixed: written to canvas.json.part and renamed over canvas.json. The watcher ignores the .part name.
| editor.store.put( | ||
| wanted.filter( | ||
| (record) => | ||
| record.typeName !== "binding" || | ||
| (kept.has(record.fromId) && kept.has(record.toId)), |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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); | ||
| }, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| .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), |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
💡 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".
| case 'create': | ||
| editor.markHistoryStoppingPoint('agent:create') | ||
| editor.createShapes(command.shapes) | ||
| editor.createShapes( |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| 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`, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| const CANVAS_FILE_MIME: Record<string, string> = { | ||
| ...IMAGE_MIME, | ||
| ".mp4": FILE_MIME[".mp4"], | ||
| ".webm": FILE_MIME[".webm"], | ||
| ".mov": FILE_MIME[".mov"], |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
💡 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".
| events.addEventListener("content", (event) => { | ||
| if (JSON.parse(event.data).by !== PAGE_ID) window.location.reload(); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| // 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()}`, | ||
| ); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| agentsOnly(editor, command.shapes.map((shape) => shape.id)) | ||
| editor.markHistoryStoppingPoint('agent:update') | ||
| editor.updateShapes(command.shapes) | ||
| editor.run(() => editor.updateShapes(command.shapes), { ignoreShapeLock: true }) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
…at will not copy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rking-glow # Conflicts: # canvas/src/canvasContent.ts
There was a problem hiding this comment.
💡 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".
| {mode === "clone" && ( | ||
| <span>You can add more later in the chat</span> | ||
| )} | ||
| <button type="button" onClick={() => dialog.current!.close()}> |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| className="sp-canvas-tab" | ||
| aria-current={canvas === here ? "page" : undefined} | ||
| onClick={() => openTab({ kind: "canvas", slug: canvas })} | ||
| onDoubleClick={() => tab.kind !== "example" && setRenaming(canvas)} |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Fixed in 7170b56: double-click renaming now also requires canvasIndex().served, like the "+" and the folder menu rows, so hosted tabs are read-only.
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
There was a problem hiding this comment.
💡 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".
| 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`) |
There was a problem hiding this comment.
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 👍 / 👎.
| if (!turns.length) | ||
| void fetch("/__sp/agent/sessions") | ||
| .then(async (res) => { | ||
| const [newest] = res.ok ? await res.json() : []; | ||
| if (newest?.interrupted) pick(newest); |
There was a problem hiding this comment.
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 👍 / 👎.
| content: fs.existsSync(path.join(folder, "canvas.json")) | ||
| ? (readJson(path.join(folder, "canvas.json")) ?? null) | ||
| : undefined, |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
💡 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".
| // 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); |
There was a problem hiding this comment.
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 👍 / 👎.
| 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)); |
There was a problem hiding this comment.
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 👍 / 👎.
| const ext = | ||
| /\.[a-z0-9]+$/i.exec(file.name)?.[0].toLowerCase() ?? | ||
| `.${file.type.split("/")[1]?.replace("+xml", "")}`; |
There was a problem hiding this comment.
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 👍 / 👎.
What changes
refs/and opens the project on/clone-prototype. Build is the idea-first flow with the/define-productoffer. The name is optional.project.json.canvas.jsonholds 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./boardanswersRangerequests, so video seeks.SAFE_NAMEnow 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: cleanvitest: 188 passed, 1 skippedRangerequest returns 206.🤖 Generated with Claude Code