Skip to content

canvas: the panels move to Geist, and the canvas opens dark - #97

Merged
Jing-yilin merged 34 commits into
canvas-agent-chatfrom
canvas-geist-ui
Sep 18, 2026
Merged

Jing-yilin merged 34 commits into
canvas-agent-chatfrom
canvas-geist-ui

Conversation

@Jing-yilin

Copy link
Copy Markdown
Contributor

Stacked on #96 (canvas-agent-chat), which is this PR's base, so the diff is only the two commits on top of it.

The panels move to Geist, and the canvas opens dark

The panel already carried nineteen steps of Geist's scale under its own --ds-* names, but as hex approximations of oklch values, light-only, scoped to the panel, with two dozen plain literals around them. This finishes the adoption:

  • The scale at the values Vercel publishes, with the four steps Geist does not publish interpolated along its own ramp (color-mix(in oklab, …)) instead of picked by eye. Transcribed rather than installed: there is no design-system package on the other side of a plugin install.
  • Every step is a light-dark() pair and color-scheme: dark on :root is the whole theme — no second stylesheet, no theme provider. tldraw gets the same preference on mount rather than a hand re-skin of its chrome, and --tl-color-background is pinned to --ds-background-100 so the canvas and the panels beside it are one ground, separated by a hairline rather than by shade.
  • That retires WelcomeGround, which blacked out the canvas under the welcome board to solve exactly the light ground it no longer has.
  • Sixteen Geist path strings replace eighteen hand-drawn SVGs of mixed geometry and stroke weight — smaller than the geist-icons dependency, let alone the trust in a 455-icon single-maintainer mirror.
  • Geist Sans and Geist Mono bundled through Fontsource, not linked from Google Fonts: a board that silently falls back to the system font measures differently, which is the one thing this repo's boards exist to be right about.
  • Five colours stay fixed literals on purpose: the checkerboards and the photo ground are grounds for the board's own artwork, and white on the accent fill sits on a blue that does not invert.

A board sits on the canvas, not on a white card

The dark ground showed what a white one had hidden — every mockup was a white card. Two different things were painting that white:

  • Six generators, and raycast's hand-written boards, declared background:#fff in the shared screen body{} rule — drift from templates/gen.py, which has never had it. Removed, and the boards regenerated. .phone was already painting its own opaque ground, so nothing inside a phone moved.
  • Every other board declared nothing and was white anyway: a frame paints an opaque base background under the document it loads, and nothing outside the frame reaches it. Measured, all five of background on the <iframe> element, allowtransparency="true", color-scheme on the element, an injected html{background:transparent} and an injected html,body{background:none!important} still paint #FFFFFF. A dark color-scheme on the board's own :root is the one lever that releases it, so CanvasFileShapeUtil injects that straight after the doctype — quirks mode before it, and </head> is not an anchor since 51 of the 351 boards close no head — with the root's color pinned back to the black a light scheme was giving the status bar's currentColor glyphs.

The injection names no colour, so a board shows whatever canvas is behind it and the light theme needs no second rule. The cost was measured at the real 478 × 980 over a white ground, so only the releasing shows: three of six boards pixel identical, the rest differing by at most 11 in one channel on glyph edges, where subpixel antialiasing gives way to grayscale on a layer that is no longer opaque. refkit shoot renders a board directly rather than through the canvas, so no measurement or screenshot in the repo changes.

Both prototype-canvas's references/layout.md and clone-prototype's hard constraints grew the rule, so the next generator does not put the ground back.

Checks

bun run lint, bun run test (129 tests, 17 files) and bun run build all pass after the rebase onto canvas-agent-chat. Reviewed in the browser on tiktok-ios, apple-app-store and apple-calendar: boards on the canvas ground, token and evidence sheets keeping the white page ground they declare.

Reasoning in docs/2026-09-17-canvas-geist.md.

🤖 Generated with Claude Code

Jing-yilin and others added 2 commits September 17, 2026 16:29
The panel already carried nineteen steps of Geist's scale under its own
`--ds-*` names, but as hex approximations of oklch values, light-only, and
with two dozen plain literals around them. This finishes the adoption: the
scale at the values Vercel publishes, the four steps Geist does not publish
interpolated along its own ramp rather than picked by eye, the two variable
families bundled so an offline board measures the same, and Geist's icons in
place of eighteen hand-drawn SVGs.

Each step is a `light-dark()` pair and `color-scheme: dark` on `:root` picks
the half, so one word is the theme. tldraw gets the same preference on mount,
because a dark rail against its near-white ground is two apps in one window —
and that retires `WelcomeGround`, which blacked out the canvas under the
welcome board to solve exactly the light ground it no longer has.

Five colours stay fixed literals on purpose: the checkerboards and the photo
ground are grounds for the board's own artwork, and white on the accent fill
sits on a blue that does not invert.

Reasoning in docs/2026-09-17-canvas-geist.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dark ground showed what a white one had hidden: every mockup was a white
card. Two different things were painting that white.

Six generators, and raycast's hand-written boards, declared `background:#fff`
in the shared screen `body{}` rule -- drift from `templates/gen.py`, which has
never had it. That is data, so it is gone and the boards are regenerated. The
`.phone` frame already paints its own opaque ground, so nothing inside a phone
moved.

Every other board declared nothing and was white anyway: a frame paints an
opaque base background under the document it loads, and nothing outside the
frame reaches it -- not `background` on the element, not `allowtransparency`,
not `color-scheme` on the element, not a transparent `html` injected inside.
Measured, all five still paint #FFFFFF. A dark `color-scheme` on the board's
own `:root` is the one lever that releases it, so CanvasFileShapeUtil injects
that straight after the doctype, with the root's `color` pinned back to the
black a light scheme was giving it. It names no colour, so a board shows
whatever canvas is behind it and a light theme needs no second rule.

Both skills grew the rule, so the next generator does not put the ground back.

Reasoning in docs/2026-09-17-canvas-geist.md.

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

cloudflare-workers-and-pages Bot commented Sep 17, 2026

Copy link
Copy Markdown

Deploying super-prototyping with  Cloudflare Pages  Cloudflare Pages

Latest commit: 036e94d
Status: ✅  Deploy successful!
Preview URL: https://b38b837b.super-prototyping.pages.dev
Branch Preview URL: https://canvas-geist-ui.super-prototyping.pages.dev

View logs

@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: b73ca2affb

ℹ️ 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/CanvasFileShapeUtil.tsx Outdated
() =>
loaded?.replace(
/(<!doctype html>)/i,
"$1<style>:root{color-scheme:dark;color:#000}</style>",

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 Avoid shadowing the board's token root

When any board is opened in the inspector, this injected rule becomes the first :root matched by inspectorAgent.ts's single-root extraction, while its later rule scan deliberately skips every :root rule. The board's actual custom properties are therefore omitted, leaving the Tokens tab and token usage counts empty for every inspected board. Inject the scheme without a :root selector, or update token extraction to combine all root rules.

Useful? React with 👍 / 👎.


*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--rc-font);background:#fff;-webkit-font-smoothing:antialiased;
body{font-family:var(--rc-font);-webkit-font-smoothing:antialiased;

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 Restore a generator before editing the Raycast boards

The eleven Raycast boards are modified directly, but mockups/canvases/raycast-ios contains no committed gen.py or other generation source, so this background change cannot be reproduced and a future regeneration cannot preserve it. Add the generator and regenerate these outputs rather than hand-editing the HTML.

AGENTS.md reference: AGENTS.md:L47-L48

Useful? React with 👍 / 👎.

The palette matched by substring, so a word typed out to the end still matched
the one command it names and the palette stayed open over it. Enter there
picks rather than sends, and picking what is already written adds only the
trailing space -- so typing `/ponytail:ponytail-help` and pressing Enter
looked like nothing happening, and it took a second Enter to send.

A word that is the only command it matches has nothing left to choose, so the
palette closes and the Enter goes to the composer. A prefix of one command
still opens, which is what Tab and Enter are there to complete.

Co-Authored-By: Claude Opus 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: 3da8523e14

ℹ️ 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/ChatPanel.tsx Outdated
// closes and Enter sends. Open, it would swallow that Enter to pick what is on screen — typing
// a command out in full and pressing Enter appeared to do nothing, because all the pick added
// was the trailing space, and it took a second Enter to send.
const matches = found.length === 1 && found[0] === typing ? [] : found;

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 Close the palette for any exact command match

When a command name is also contained in another available command (for example, review alongside security-review), typing the shorter command exactly still leaves multiple entries in found, so this condition keeps the palette open. The first Enter is then consumed by pickCommand and merely adds a space, preserving the two-Enter behavior this change is intended to fix; suppress the palette whenever any entry exactly matches typing, regardless of additional fuzzy matches.

Useful? React with 👍 / 👎.

Jing-yilin and others added 6 commits September 18, 2026 03:51
One UI system rather than two. The Geist scale moves out of index.css into
tokens.css, which every entry point imports — index.css for the canvas,
brand.css for the brand pages — so there is a single set of tokens behind all
of them instead of a palette per stylesheet. Tailwind reads it through an
`@theme inline` block that maps shadcn's names onto Geist's: `primary` is the
ground and the ink swapped, not the accent blue, and `accent` is a hovered row.

Tailwind's utilities land in `layer(utilities)` and its preflight in
`layer(base)`, and an unlayered rule beats a layered one whatever its
specificity — so every hand-written rule in this repo, and every rule tldraw
ships, still wins. Tailwind reaches only what nothing else claims. The `dark:`
variant is pointed at an attribute nothing sets, because the theme here is
`color-scheme` and no selector can match that; left pointed rather than
deleted so shadcn components keep the classes they ship with instead of firing
off the operating system's preference on a light brand page.

The alias goes in tsconfig.json as well as tsconfig.app.json: the shadcn CLI
reads it from the root file and nowhere else, and without it `shadcn add`
writes components into a folder literally named `@`.

`geist-icons` arrives in the same lockfile, for the entry that follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
geistIcons.tsx held sixteen path strings copied out of Geist by hand. It is
now twenty names bound to `geist-icons`, which publishes all 455 of them as
one dependency-free module — so nobody here maintains a `d` attribute, and a
glyph that has to change changes upstream. Fifteen of the sixteen matched the
package byte for byte, which is the evidence that it is the set and not a
lookalike; Vercel publishes no icon package of its own.

Two things are added to each and nothing else: the size, because Geist draws
on a 16 grid and the package defaults its components to 20, and `aria-hidden`,
because every glyph here sits inside a control that already carries a name.

What stays hand-drawn is what Geist has no mark for or draws wrong for this
use: Claude's and Codex's, and GitHub's, whose Geist version mixes
`currentColor` with a literal white that would vanish on a near-white button.
FigmaMark.tsx goes — `LogoFigma` is Geist's own transcription of it, still in
Figma's five colours.

`public/clone.svg` and `public/refresh.svg` go too, with the asset-url
override that named them. A tldraw icon passed as JSX skips the mask path
entirely, so a component needs no file behind it.

It costs 433 kB gzip, for 455 marks where twenty are drawn. The package is
marked `sideEffects: false` and Rollup would shake it; rolldown, which is what
Vite builds with here, does not — proven against three treeshake configs that
came out byte for byte identical. The alternative is going back to
transcribing path strings, which is the thing this entry is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The window still read as two applications joined at a hairline. tldraw's top
bar sat on `--tl-color-low`, a grey block in the corner of a black canvas; the
chat panel drew its header icons at gray-700 over a solid ground while
tldraw drew the ones four pixels away at gray-900 over a translucent one; and
seven scrolling surfaces said nothing about their scrollbars at all, so each
drew the platform's — a light thumb in a light trough, the brightest thing on
a black screen.

All three are the same fix: say it once, in the place that covers everything.
The bar takes `--ds-background-100` with a hairline on the two edges facing
the canvas, the way the panel's does. The icon button is Geist's, muted at
rest and full ink on a gray-100 ground, written over both sets of selectors at
once — half of it twice even so, because tldraw paints its hover on an inset
`::after` rather than on the button, and that inset is what sizes the ground.
The scrollbars are `scrollbar-width` and `scrollbar-color` on `:root`: both
inherit, so every surface is covered and the next one is covered before it
exists, and it is two standard properties rather than `::-webkit-scrollbar`
and its six pseudo-elements for a thumb that has to be one grey.

It stops at the document. A board is an iframe with its own scrollbars, and a
mockup of a phone should scroll like the phone it is a mockup of.

The last literals in the shape utils go with them — the banner's amber, the
inspector overlay's blue, and four font stacks that named system-ui by hand
where `--sp-sans` is now Geist. `index.html`'s theme colour follows the ground
it describes.

`brand.css` imports the scale for the CTA pair in its topbar, and puts back
the two underlines Tailwind's preflight removes, on the links where the
underline was the whole affordance.

Reasoning in docs/2026-09-17-canvas-geist.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment, clone and force refresh come off the bar. All three already pointed
at the right button: each acts on what is under the cursor or on the page it
is on, which is what a right-click has picked out before the menu opens. The
context menu had two of them already and gains the clone, so nothing lost a
way in, and `QuickActions` goes to `null` with the comment button that was the
last thing left in it.

What the bar keeps is where a board goes next — Export to Figma, Brand kit —
and the switch for the chat panel, which takes the slot tldraw's main menu
used to have. That slot is the leftmost thing in the window, against the left
edge, which is where the switch for the panel on that edge belongs: in the
panel's own header it disappeared along with the panel and needed a second
control to undo it. The state moves up to App with it, since the button and
the panel are siblings now rather than parent and child, and it is remembered
across reloads — it is a preference about this window, not about a board.

Collapsed is `display: none` and not a rail: the button that brings it back is
in the canvas's corner, so a rail would be a second one. Hidden rather than
unmounted, so the panel keeps following whatever is running and comes back to
it mid-stream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CTAs were the last hand-built controls in the app: two pills with their
own stylesheet, their own blue and dark gradients and a gold star. They are
the shadcn Button now, over the scale, which is what proves the system works
end to end — the same two elements come out white on the canvas and black on
a brand page with nothing here to keep in step.

Both take the primary variant. Geist's pairing would make the second one
secondary, but these are two asks rather than an ask and an aside, and a
hairline chip beside a solid one reads as the lesser of them.

The SnapAction mark becomes a mask rather than an `<img>`: the file is a fixed
near-white and the ink it sits in is black. Masked, it is whatever the
button's ink is.

canvasCta.css survives holding the shimmer and nothing else. It stays a file
rather than a block in index.css because the pair is also in the brand pages'
topbar, another document with another stylesheet, and an import beside the
component travels with it. The band is a `light-dark()` pair for the same
reason the buttons are one component, and its ends are `rgb(255 255 255 / 0)`
rather than `transparent`, which is transparent *black*: interpolated towards
it the band dims at its own edges, and a grey fringe sweeping across a white
button is the one thing a shimmer must not leave behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…user

The decision note gains the four entries the last commits are: the icon set
instead of a transcription of it and what it costs, the top bar down to a
switch and two destinations, the one icon button and the one scrollbar, and
the shimmer that stays a file of its own.

The release notes say the same things in the order a user meets them: where
the buttons went, where the switch is now, and that the scrollbars are the
app's rather than the platform's — everywhere but inside a board, which keeps
its own.

Co-Authored-By: Claude Opus 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: 04e8c38fe3

ℹ️ 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/App.tsx Outdated
* it sits in the canvas's top bar — the panel's sibling, not its child. Remembered across
* reloads: it is a preference about this window, not about any one board. */
const [chatCollapsed, setChatCollapsed] = useState(
() => localStorage.getItem(CHAT_COLLAPSED_KEY) === "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 Guard the chat preference storage access

When storage is unavailable—for example, cookies/site data are blocked or the canvas is embedded under an origin that cannot use storage—this initializer throws a SecurityError during render and blanks the entire canvas. This is now executed in production even though the chat itself is dev-only; the nearby applySnapDefault and comment-user storage code already catch this condition. Read this preference defensively (and likewise guard the write in toggleChat) so storage refusal only makes the preference non-persistent.

Useful? React with 👍 / 👎.

Jing-yilin and others added 3 commits September 18, 2026 04:20
Ran the unslop skill from cursor/plugins over the prose this branch adds:
docs/2026-09-17-canvas-geist.md, the entries this branch appends to
RELEASE-NOTES.md, the page-ground bullet in the two skill files, and the
comments in the twelve source files it touches.

Most of the work was three rules. Em dashes are gone, about seventy of them,
each replaced by a period, a comma, or the word the dash stood in for. Colons
used as sentence glue became sentences. Metaphor gave way to the literal
phrase, so "holds a black menu off a black canvas" now reads "separates a
black menu from a black canvas".

One number was wrong and is fixed. The geist-icons chunk costs 433 kB gzip,
measured from dist/assets/geistIcons-*.js, not the 167 kB an earlier isolated
rolldown test suggested.

Nothing outside a comment or a Markdown paragraph changed. Every token name,
path, measurement and stated trade-off in the design doc survives, and the one
em dash left in the branch is in JSX the app renders, so it is a UI string
rather than prose. The visual claims keep the hedging they were written with,
because none of this branch has been checked in a browser yet.

Green: oxlint clean, tsc -b 0, 17 files and 129 tests, vite build in 5.36s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A message can carry screenshots now, numbered so the sentence can refer to
them — the layout from #1, the copy from #4 — and the number the composer
draws on the tile is the number the agent is handed beside the picture. Paste,
drop, or the button in the row under the box, where Claude Code keeps it.

The other direction came almost free. Whatever a tool hands the agent as an
image arrives on the frame that carries the tool's result, and refkit already
tells the agent to read back what it drew, so a clone's working images — the
grid over the reference, the crops — are drawn under the call that produced
them. Nothing watches the project directory, and nothing had to be taught
which tools draw.

Neither direction puts bytes in the event buffer: a written board reloads the
page and the panel rebuilds every turn from event zero, so what the stream
carries is a number and the picture is a request away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer section still listed `+` among the controls deliberately left
out, on the grounds that the prompt reaches the agent as one string on stdin.
Both halves of that are now false.

Co-Authored-By: Claude Opus 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: 4f8e02e0c0

ℹ️ 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/vite.config.ts
Comment on lines +1234 to +1236
res.writeHead(200, {
"content-type": img.type,
"cache-control": "no-store",

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 Serve untrusted SVGs outside the application origin

When an attachment or tool result declares image/svg+xml, this endpoint reflects that type and the transcript opens the response as a top-level document. An SVG containing script therefore executes under the dev server's origin and can POST to privileged endpoints such as /__sp/agent/run, which launches agents with write permissions. Restrict previews to safe raster MIME types, or serve/download active formats with an opaque origin or sandboxed CSP.

Useful? React with 👍 / 👎.

Comment thread canvas/src/ChatPanel.tsx Outdated
Comment on lines +320 to +325
const addImages = (list: FileList | File[] | null) => {
const picked = [...(list ?? [])].filter((f) => f.type.startsWith("image/"));
if (picked.length === 0) return;
// The server refuses a body over about 48 MB, and base64 is a third larger than the file; a
// number said here is better than one found out after the whole thing has been read and sent.
if (picked.reduce((n, f) => n + f.size, 0) > 24_000_000)

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 Enforce attachment limits across the whole tray

This limit only totals the newly selected batch, so multiple paste/drop/file operations can each add less than 24 MB while the accumulated attached payload exceeds the server's 48 MB body cap; the same path also allows more than the server's 20-image limit. In those cases the composer accepts and reads every file, then sending always fails with 413 or bad images. Include the already attached files and count when validating each addition.

Useful? React with 👍 / 👎.

Comment thread canvas/src/claudeStream.ts Outdated
Comment on lines +113 to +115
function shotsIn(
content: string | ResultBlock[] | undefined,
): Shot[] | 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.

P1 Badge Inline the new single-use image helpers

Both shotsIn here and filed in vite.config.ts are introduced solely to be called once, adding indirection contrary to the repository's explicit convention. Inline each transformation at its call site instead of retaining one-use helper names.

AGENTS.md reference: AGENTS.md:L77-L78

Useful? React with 👍 / 👎.

Jing-yilin and others added 2 commits September 18, 2026 07:04
The palette was learned only as a side effect: Claude Code names every
command it can run on the init frame of a run, the server kept that list in
a Map, and the panel asked once. So the palette was empty before the first
message of a session, and empty again after every dev-server restart — which
an edit to vite.config.ts or anything it imports causes mid-session. The
panel then cached the empty answer and only asked again when the user typed
a fresh slash. That is the "sometimes it doesn't work" this fixes, and the
panel was doing exactly what it was written to do.

Claude Code now gets a probe of its own, the way codex already had one.
`claude -p /help` asks for that same init frame: /help is the command the
CLI answers by itself, so the result frame comes back num_turns 0,
duration_api_ms 0, total_cost_usd 0 — measured, not assumed. The answer is
thrown away; the frame printed above it is the point. --max-budget-usd is
the belt in case a release ever sends /help to the model instead.

Three things then have to hold for the palette to open on the slash itself.
The server asks the CLI when its map is empty, once per server. The panel
asks at mount, so a cold probe's few seconds are spent while the canvas is
being looked at rather than after a keystroke. And the browser remembers
what each agent last said, replaced only by a non-empty answer, so the first
paint has something even when the server is still asking.

No command name is written down anywhere. The list is whatever that install
of that CLI reports today — claude's off its own init frame, codex's off the
prompt it would have composed — so a new skill, a new plugin or a new
release shows up without a change here. The only literal is how to ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It argued that claude is never asked because asking costs a turn, and cited
$0.017. That was right about the price of the probe it had in mind and wrong
about the conclusion: the list lived only in the server's memory, so the
palette was empty before the first message and after every restart. The note
now says that, says what /help costs instead, and says how the mount ask and
the remembered list keep the palette open on the slash itself.

Co-Authored-By: Claude Opus 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: d2b413b336

ℹ️ 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/ChatPanel.tsx Outdated
Comment on lines +416 to +418
composer.current?.replaceChildren();
setDraft("");
setAttached([]);

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 edits made while the run request is pending

While this POST is awaiting its response, running is still false and the composer remains editable, so a user can start the next prompt or attach another image. A successful response then unconditionally clears the current DOM and attachment state here, discarding those newer edits even though they were not included in the request. Track an in-flight submission or clear only the snapshot that was actually sent.

Useful? React with 👍 / 👎.

Comment thread canvas/vite.config.ts Outdated
Comment on lines +1063 to +1066
const imagesDir = images.length
? path.join(os.tmpdir(), `sp-chat-${id}`)
: "";
if (imagesDir) fs.mkdirSync(imagesDir, { recursive: 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 Remove attachment directories when the server stops

Every image-bearing run creates a directory under the system temp folder, but the only removal path is the later runs.size > 20 eviction loop. If Vite stops or restarts before eviction—which is common during local development—the in-memory run map is lost and those directories can never be discovered or removed by this server, so repeated sessions can leave tens of megabytes per run accumulating indefinitely. Register shutdown cleanup or remove stale run directories on startup.

Useful? React with 👍 / 👎.

The board's frame says `color-scheme: light` on the element, and the
`:root{color-scheme:dark;color:#000}` injected after every board's doctype
is gone. The PR's measurement had tried the element-side value that matches
the canvas, not the one that matches the board; measured again in Chrome
153, `light` on the `<iframe>` composites transparent. Nothing is written
into a board's markup now, and an injected class-level rule no longer
outranks a board's own `html{}`. The design note says so.

Cmd+/ no longer flips tldraw to its light theme under a black rail: the
`toggle-dark-mode` action is deleted from the overrides, since the ground
remap, the welcome board and the panel tokens are all dark only.

Attachments are the four image types the CLIs read, on both sides of the
wire. `image/svg+xml` was accepted and served back on the dev server's
origin, where every /__sp endpoint writes, and the strip opens each tile as
a top-level document.

The /run body is collected as bytes and decoded once: a character split
across two chunks decoded to U+FFFD, and a CJK message or file name sits
after megabytes of base64. The cap is bytes too, and its message says the
same 24 MB the panel does. Per-run image folders live under one folder per
server named by its pid: removed with the server, and a folder left by a
server that was killed is swept at the next start.

In the panel: a send is held until the server answers, so a repeated Enter
no longer posts the message and its images twice; a fetch that rejects
outright surfaces as the send error instead of an unhandled rejection; the
tray checks the server's 20-image and size limits over the whole tray and
reports a file that could not be read; a box emptied by typing drops the
`<br>` that kept the placeholder away; Enter inside an IME composition is
the editor's; and the data URL the reader produced is kept whole rather
than rebuilt from megabytes of base64 on every keystroke.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Jing-yilin and others added 8 commits September 18, 2026 11:22
Hover a mockup or a piece of brand material and two buttons appear at its
top-right corner: + names the file in the composer, and the picture button
draws the board and drops it in the attachment tray. Brand material gets
only +, because it is already a picture.

+ writes `<slug>/<file>.html`, the name the server, the layout and the agent
all know a board by, not the module path its shape carries — canvasBoardRef
converts between them, with a test that the two really do differ. The
picture button goes through a new /__sp/shoot, which runs `refkit shoot` and
caches per board and artboard size.

Locked shapes get no hover from tldraw, so the overlay hit-tests the pointer
itself, sharing shapeUnderPointer with the inspector. Two rules that are not
obvious from the code: the corner is in viewport pixels, because the chat
panel takes the window's left edge and screen pixels would count that offset
twice; and reaching for the buttons is not a move to another shape, or the
bar follows the pointer onto the board underneath and steals the press.

Also here: refkit draws into its own directory and the finished file is
moved into place, so a second request during the seconds a render takes
cannot be served half a picture. The formatter reflowed canvasLibrary.test.ts
while it was open, which is most of that file's diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing + on a picture, or the picture button on a board, put the tile in
the tray and left the sentence to be written with a second click on it. A
picture pointed at on the canvas is one image and pointing at it is the
saying, so its #N chip is now written as it lands.

A pick, a paste or a drop still only fills the tray: those are a handful of
images at once, and which of them the message is about is still to be said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attach a picture, think better of it, remove it, attach another: it came
back as #2, with no #1 anywhere to tell it apart from. The counter ran for
the life of the composer, so a cleared-away false start still cost a number,
and so did every message sent.

Numbers still cannot be reused while something points at one — renumbering
under a sentence already typed would silently repoint it — so the reset asks
whether anything does: an empty tray and a box with no chip left in it,
struck through or not. That is checked wherever both can empty, which is
removing a tile, deleting the last chip by hand, sending, and starting over.

namesAPicture lives in chatDraft.ts beside readDraft, which is what makes a
struck-through chip count: it is drawn dead but still reads as "#1".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two halves of the same rule — the tray and the message are one thing, not a
list beside a box.

A number belongs to a picture, not to the act of attaching one. Pressing +
on the same icon four times made four tiles of the same image and wrote #1
#2 #3 #4, which reads as four pictures that happen to look alike. The tray
is now searched for those exact bytes before a number is handed out, and the
chip is only written if the box does not already say it, so four presses
leave one tile and one #1. Bytes and not the file's name: a paste is called
image.png every time and is a different screenshot every time. That means
the reads finish before the numbers do; they still go out in the order the
files were picked, so three chosen at once are #1, #2, #3 down the dialog.

And deleting a chip now takes its picture out of the tray. Removing a tile
still only strikes its chips through — a sentence is not rewritten under
whoever typed it — but the other direction has no such excuse: a message
that no longer mentions a picture is not a message with that picture
attached. A picture the box has never named is left alone, since most are
attached before a word is typed; the panel compares each reading of the box
with the last to tell those apart.

The numbering restart now hangs off the tray itself rather than off each
caller, so a removal made from a stale render still counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resting the pointer on a picture's + and then scrolling with two fingers
froze the canvas until the pointer moved off again. Nothing was catching the
wheel: the attach bar renders in InFrontOfTheCanvas, which is a sibling of
.tl-canvas rather than a child of it, and tldraw listens for the wheel on the
canvas element alone — so a gesture that begins over the bar reaches no
handler at all.

usePassThroughWheelEvents hands it back, which is what every control tldraw
draws over its own canvas does: the toolbar, the minimap, a comment pin. It
steps aside for anything inside that really scrolls, so it costs nothing if
the bar ever grows a list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hovering the clear part of a mockup drawn on nothing offered no + button,
and clicking it opened no inspector. tldraw hit-tests an image that can
carry transparency against its own alpha channel, so a phone mockup with
rounded corners answers for its screen and refuses its margins — which is
most of the tile for anything not rectangular.

It answers for all of it now, by way of a shape util that gives an image the
plain box its width and height describe. A circle crop is still a circle;
it is only the alpha channel that stops counting.

The store has to be handed the same set, and it refuses the same shape type
twice, so the default an override stands in for is dropped rather than
listed beside it.

Checked on x-ios: paidads-trend-takeover-mockup.png has four fully clear
corners, and before this all four went unanswered while the centre did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing + on a picture put a numbered tile above the box and a chip in the
sentence; pressing + on a mockup wrote a grey path badge and nothing else,
so the panel said which file had been named but never showed the thing that
had been pointed at.

A board now comes over the same way a picture does — drawn by the server, as
both buttons already had it drawn — and the name it arrives under is its own
`<slug>/<file>.html`. That is what the tile is captioned with, what the chip
says on hover, and what the agent is handed beside the picture's number, so
nothing is lost by dropping the path out of the sentence: `[Image #1]
x-ios/13-set-a-reminder.html` still names the file to go and open.

The two buttons keep their difference in that name alone: + hands over the
board, the picture frame hands over a drawing of it. The same board through
both is the same bytes, so it stays one tile with one number, which is the
rule the tray already had.

The tile is 76px, so its caption is the last segment; the whole path is the
tooltip and the alt text. The spinner follows whichever button was pressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The picture frame drew the board and handed it over as `<file>.png`; + now
draws the same board and hands over the same bytes under `<file>.html`. Two
buttons for one action, and the tray deduped them into one tile anyway.

+ alone, then, and the spinner is back to a boolean: there is only one
button for it to be in.

Co-Authored-By: Claude Opus 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: 0075875e38

ℹ️ 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/vite.config.ts
env: process.env,
},
),
images: held,

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 Drop base64 data after launching the run

For every image-bearing turn, run.images retains held, including each image's full base64 data, even after the bytes have been written to disk and stdin has been closed. At the permitted payload size this is roughly 32–48 MB per run, and the newest 20 runs remain in the map, so a normal sequence of large screenshot turns can retain hundreds of megabytes and eventually destabilize the dev server. Keep only the metadata and file path on the run once the agent input has been constructed.

Useful? React with 👍 / 👎.

Comment thread canvas/src/ChatPanel.tsx Outdated
// already has: pressing + on one twice is one picture said twice, not two. Still numbered in
// the order they were picked rather than the order the reads came back in, so three files
// chosen at once are #1, #2, #3 as they appear in the dialog.
const tray = [...attached];

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 Merge attachments against the current tray after reads

When two additions overlap—for example, a user double-clicks the canvas attachment button or drops another large image before the first FileReader finishes—both invocations capture the same old attached value before awaiting their reads. Each then builds a tray from that stale snapshot, so the later completion overwrites the earlier attachment, defeats byte deduplication, and can leave skipped reference numbers. Serialize additions or merge through current state/a ref after the reads complete.

Useful? React with 👍 / 👎.

Comment on lines 284 to 288
<TldrawUiMenuItem
id="relayout"
label="Force refresh"
icon="refresh-icon"
icon={<RefreshCounterClockwise />}
onSelect={chrome.relayoutLibrary}

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 Point relayout instructions to the context menu

Moving Force refresh exclusively into the context menu leaves the shipped canonical guide at skills/prototype-canvas/references/layout.md:121-124 telling users and agents to press a refresh button in the top bar beside an actions menu, both of which this change removes. Because that guide also explains that relayout is required to reposition persisted shapes after editing layout.json, following it now leaves no discoverable way to complete the documented workflow; update it to name the right-click menu.

AGENTS.md reference: AGENTS.md:L39-L41

Useful? React with 👍 / 👎.

Jing-yilin and others added 11 commits September 18, 2026 13:09
The tray keeps the reader's data URL, and upstream's dedupe of a picture
pressed twice compares that instead of the sliced base64. Two things Codex
found on #98 are folded in: the limits count what is still being read, so
a second paste during the first one's read sees the whole tray, and the
tmp sweep removes a folder only when its pid is gone (ESRCH), not when it
belongs to another user (EPERM). And the tray is merged in a functional
update, so a pick that finishes reading second no longer overwrites the one
that finished first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Attaching runs one add at a time: two picks of one picture in flight together each numbered it,
and the second put a chip in the sentence for a tile the tray had dropped. The tray an add reads
is a ref written as it is set, so the add queued behind it sees it. The pending counters and the
updater's second dedup go with that, and the tray weighs its files by size rather than by
data-URL length. A drop or paste of a type the agents cannot read now says so instead of doing
nothing, and the four types and the two limits are said once, in agents.ts, for both sides.

Safari reports the Enter that confirms an IME candidate after composition has ended, with only
keyCode 229 to mark it; the guard reads that too. The placeholder keys off the draft state
through a data attribute rather than the box being wiped in onInput, which was throwing away
native undo and eating a leading Shift+Enter.

A run's image files go when its child closes or fails to spawn, and the image route serves the
bytes the run already holds. The server's close hook went: Vite restarts by building the new
server, same pid, before closing the old, so the hook was deleting the running server's folder;
Ctrl+C never fired it at all. The dead-pid sweep is the cleanup, and skips a folder it cannot
remove rather than failing the server on a shared tmp.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A path made of the pid alone is one anyone on a shared tmp could have put a folder or a link
at first, and the run's files would then have gone under theirs. mkdtemp makes it private and
atomically, and the sweep reads the pid off the name it makes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…server counts the bytes

The limits are over what a pick adds: the same picture pointed at again on a full tray kept its
number rather than being refused as one more, so the reads come first and the count and the
weight are of what is new. The server's body cap was the panel's limit in base64; a client that
is not the panel now meets the same limit in the bytes the files decode to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A read is the whole file in memory, a third larger, and a drop of a few hundred megabytes was
read in full to be refused after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four findings from the review on #97, all in the composer.

Two additions in the air at once each merged into the tray of the render that
made them — a drop landing while a paste was still being read — so the later
one won and the earlier picture vanished while its number had already been
handed out. Every change to the tray now goes through one setter that writes
a ref beside the state, and every decision reads the ref, so the second merge
sees what the first left. Not a state updater: the merge is what hands out
the numbers, and StrictMode runs an updater twice.

A command that is also the start of a longer one — `review` beside
`security-review` — still took two Enters, because the palette only closed
when its exact match was the only match. Any exact match closes it now.

The box stayed open while the message was away and was then emptied whole, so
a word typed in those milliseconds was swept out with a message it was never
part of. The box takes nothing until the server answers; the tray still does,
and a picture that lands meanwhile keeps its tile and number for the next
message. Tab still leaves the box, so a keyboard is never held in it.

And the size guard counted only the batch being added, so several drops could
each pass and the send fail with 413. It counts the tray too, in the server's
own numbers: twenty pictures, about 32 MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ets its bytes

Four findings from the review on #97, all in the dev server.

A picture in the transcript is linked to open in a tab, and its type is
whatever the browser said when it was attached — so an SVG opened as a
document of this origin, where its script could POST /run and start an agent
in the project asked by no one. The canvas's attach button made that a click
away, since a board can carry any `.svg` dropped on it. Both picture routes
now answer with `content-security-policy: sandbox`, which opens them in an
opaque origin with no script, and `nosniff`. A PNG in a tab never notices,
and the same bytes drawn as an `<img>` are untouched.

A run kept every attachment's base64 after writing it to disk, and the newest
twenty runs are kept: it holds the numbers, types and paths now. The request
body is dropped as soon as it is parsed for the same reason — the listeners
the run leaves on its child close over that scope.

Attachment folders went under one folder per server process, swept at start
for the folders of processes that are gone. A close hook would not have done
it: `sp-canvas stop` is `tmux kill-session`, a SIGHUP Vite does not handle.
A folder whose pid is alive, or one this cannot signal, is left alone.

And `filed`, which had one call site, is inlined where CLAUDE.md says it goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three smaller findings from the review on #97.

Reading whether the chat panel was shut threw where site data is blocked, and
it was read during render, so a refused `localStorage` blanked the whole
canvas — in production, though the chat itself is dev-only. Both the read and
the write are guarded now, the way the snap default and the comment user
already were: storage refusing only means the choice lasts until a reload.

`shotsIn` had one call site and is inlined where CLAUDE.md says it goes.

And the shipped layout guide still told users to press a refresh button in
the top bar next to the actions menu. This branch moved force-relayout into
the canvas's right-click menu, so the guide named a button that is not there
for the one step that makes an edited `layout.json` take effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dark scheme this branch injects into every board was written `:root`,
and the inspector's agent takes the first `:root {…}` in the document's
stylesheets to be the board's token block. Ours goes in right after the
doctype, so it was always first: the Tokens tab and the token usage counts
came back empty for every board, since two declarations of ours is what the
inspector was reading instead of the board's fifty.

`html` names the same element at a lower specificity, so a board's own rules
still win — its `:root` by specificity, its `html` by order — and the first
`:root` in the sheet is the board's again. Nothing else here depends on the
spelling, and `canvasLibrary.ts` was already injecting with `html{…}`.

Checked with the real agent against x-ios/00-design-tokens.html: `:root`
reports no tokens in no groups, `html` reports 71 in seven. No committed
board sets `color` or `color-scheme` on an `html` rule of its own, so the
specificity drop repaints nothing.

Found by the review on #97.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The layout guide was the one the review on #97 caught, but the same sentence
had been written four more times: three in the skill itself and one in the
repo README, each still telling a reader to press a button in the top bar for
the one step that makes an edited `layout.json` take effect.

The README also described a bottom toolbar with a styles-panel toggle. There
is no bottom toolbar — `canvasChrome.tsx` sets `Toolbar: null` — and the
styles panel is the inspector, which opens on the board you click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#98 forked at 0075875 and fixed the review of #97 in parallel with the five
commits on this branch. Neither knew about the other, so the three files at
the centre of the review conflicted. Resolved file by file, on which fix is
the better one rather than on which came first.

#98's, and this branch's dropped:

- The board backdrop. #98 deletes the injected `<style>` outright and sets
  `color-scheme: light` on the `<iframe>` element instead. This branch had
  renamed the injected selector `:root` → `html` to stop it shadowing the
  board's token block. Removing the injection dissolves that bug rather than
  working around it, and fixes the backdrop measurement besides, so the rename
  goes.
- The request body. Collected as `Buffer`s and decoded once: a character split
  across a chunk boundary decoded to U+FFFD, which this branch had not found.
- The attachment folder. `mkdtempSync` over this branch's `sp-chat-<pid>`,
  which was a predictable path on a shared tmp. Same dead-pid sweep, and #98's
  tells ESRCH from EPERM.
- Accepted types. png, jpeg, gif, webp, said once in `agents.ts` and read by
  both sides, so an SVG is refused before it is read rather than sandboxed on
  the way out. The sandbox headers stay on both image routes as the second
  layer, and the shot route now names its type from `IMAGE_TYPES` too.
- The composer. #98 serializes adds through a promise chain, which this
  branch's ref-and-setter did another way, and carries the IME, native undo
  and `data-empty` fixes besides.

Kept from this branch, which #98 does not have:

- The palette closes on any exact match, not only a sole one. #98 still had
  `found.length === 1 && found[0] === typing`, so `review` beside
  `security-review` took two Enters.
- The guarded `localStorage` read of the chat-panel state, `shotsIn` inlined,
  and the refresh-button instruction corrected in the README, the skill and
  the layout guide.

`tsc --noEmit`, `oxlint` and `vitest` (18 files, 141 tests) pass on the result.

Co-Authored-By: Claude Opus 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: 2b3a5115f9

ℹ️ 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/vite.config.ts
Comment on lines +678 to +679
const out = path.join(os.tmpdir(), "sp-shot", parts[0], size.join("x"));
const png = path.join(out, parts[1].replace(/\.html$/, ".png"));

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 Namespace rendered-board cache by canvas directory

When two projects contain the same slug, filename, and artboard size, this global temp path aliases their screenshots; because a newly rendered PNG is typically newer than the second project's existing HTML, the freshness check serves the first project's image without rendering the second. Include canvasesDir (or its existing namespace/hash) in the cache key so switching projects cannot attach the wrong mockup to an agent prompt.

AGENTS.md reference: AGENTS.md:L15-L17

Useful? React with 👍 / 👎.

Comment thread canvas/src/ChatPanel.tsx
};

/** The file as the tray holds it: base64, without the `data:` prefix a data URL opens with. */
const readFile = (file: 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 Inline the remaining single-use file reader

readFile is introduced solely for picked.map(readFile) below, leaving a one-call helper contrary to the repository's explicit convention. Inline the FileReader promise at that mapping site instead of retaining this extra name.

AGENTS.md reference: AGENTS.md:L77-L78

Useful? React with 👍 / 👎.

@Jing-yilin
Jing-yilin merged commit 7b05506 into canvas-agent-chat Sep 18, 2026
4 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: 036e94da68

ℹ️ 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/vite.config.ts
Comment on lines +1292 to +1297
run.shots.push({
type: IMAGE_TYPES.includes(s.type)
? s.type
: "image/png",
data: Buffer.from(s.data, "base64"),
});

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 Bound retained tool-result screenshots

When Claude or an MCP tool returns images repeatedly during a clone workflow, every full-resolution result is decoded and appended to run.shots without any byte or count limit. These buffers remain reachable for each of the newest 20 runs, so screenshot-heavy turns can steadily grow the Vite server's RSS until it becomes unstable; persist these results outside memory or enforce per-run/global eviction limits.

Useful? React with 👍 / 👎.

Jing-yilin added a commit that referenced this pull request Sep 19, 2026
Step one of the release flow is reading `## Unreleased` and reconstructing
anything a PR left out, "the one part of a release that cannot be done well
late". Grepping the section for image, attach, paste, svg, drag or drop found
nothing: the whole picture feature area — fourteen commits across #97, #98 and
#99 — reached `main` with no line for the person deciding whether to update.

Five paragraphs, written from the log and checked against the code rather than
from memory: attaching and numbering, tool images arriving in the transcript,
the + on a canvas shape, vectors drawn into a PNG on the way in, and the limits.
Placed with the rest of the chat panel material, before the Geist paragraphs,
since that is the order the section already reads in.

Two numbers are the code's, not the commit messages': MAX_IMAGE_BYTES is 24 MB
(agents.ts:52), which an early commit called 32, and a mockup has one hover
button now rather than the two it shipped with.

No version is cut here. `scripts/bump-version.sh --check` still says all 7
files agree on 1.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Jing-yilin Jing-yilin mentioned this pull request Sep 19, 2026
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