Skip to content

feat(app): opt into experimental-media-uri and keep draft media bytes local - #59104

Merged
kitlangton merged 8 commits into
devfrom
media-app
Oct 28, 2026
Merged

kitlangton merged 8 commits into
devfrom
media-app

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

What it does

Desktop and web ask for media:<hash> refs, so a long session with screenshots stops re-sending every image as base64 each time it loads. Images load on demand through a cache instead. It's experimental: nothing changes until a session's location turns on experimental.media_blob, which is off by default. Until then the server keeps sending inline data.

Replaces #58743, which GitHub marked merged into its stack base while the stack was being reordered. Nothing from it reached dev.

Stack, built on the merged #58656: write (#58741, merged) → wire (#59103) → app (this PR) → backfill (#58742, optional, can land later).

Why

Inline media is a third larger than its bytes and arrives again on every message read, for every image in the session. With refs, each image is fetched once per server and cached. Before → after bytes per load have not been measured yet.

How it works

  1. Only the three reads that can return refs (message.list, session.message.get, session.inbox.list) send X-OpenCode-Capabilities: experimental-media-uri, as a per-call header. Every other request stays exactly as before, so no new CORS preflights. Export passes { mediaRefs: false }, so exported sessions always carry inline bytes.
  2. A per-server cache fetches bytes through experimental.media.get with the server's Basic auth, turns them into object URLs, and keeps each URL until the server switches or the app closes, so images on screen, in a preview or in a draft never break. Message attachments render from it.
  3. Drafts own their bytes. A media: image restored into the composer is kept as a draft reference with an empty URL (the same shape as a draft-store id, so older builds still read the draft) and is copied into the local draft store on the first save, so server-side cleanup can't break a draft. Submitting always sends bytes.
  4. Undoing a queued prompt, reverting one that hasn't been delivered yet, and editing a queued prompt all end in an inbox cancel, and the cancel sweeps blobs nothing else references. Each one first copies the prompt's media: bytes through the cache into local draft blobs. If undo or revert can't load them, they show an error and leave the prompt queued. Edit swaps in the local copies as they load.

Flag off (the default) and older servers

The server ignores the capability unless experimental.media_blob is on, so these three reads return the same inline attachments as today. The only request difference is that header on those three GETs. Restoring attachments into the composer follows the old rule exactly (source.type === "inline" || data non-empty), so URI-sourced attachments with inline bytes still come back on revert, fork and recall.

Risk

App UI and draft persistence. If a media load fails, the image shows as unavailable and the draft stays intact. Tool-result images are not shown from refs yet. Legacy /api/media/ URLs are not supported; only media:<hash> refs are. Not verified end-to-end in the desktop app against a live server with the flag on.

Proof

  • bun run test:unit (packages/app): 923 pass. New cases: the capability header goes only on the three ref reads (and is dropped with mediaRefs: false), export requests inline media, a URI-sourced attachment with inline bytes restores, and a restored media: image encodes and decodes through the draft schema.
  • bun test src (packages/session-ui): 318 pass.
  • bun typecheck (app, session-ui): clean.

The server sends media:<hash> refs, never route paths, and user files keep
their source, so drop the legacy route parsing and the origin fallback.
Reverting an undelivered prompt, undoing a queued prompt and editing one all end in an inbox cancel that sweeps blobs nothing else references. Each now copies its media: bytes through the media cache into local draft blobs first; revert and undo leave the prompt queued when the bytes cannot load.
…ds, keep exports inline, and persist media refs without a URL
@kitlangton
kitlangton marked this pull request as ready for review October 28, 2026 16:50

@opencodejr opencodejr 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.

The approach looks sound. Only the three ref-capable reads (message.list, session.message.get, session.inbox.list) get the capability header, export opts out, and the undo and undelivered-revert paths copy media: bytes locally before cancelling the inbox item, so a failed copy leaves the prompt queued. I couldn't test it end to end because there's no web/desktop app harness here with experimental.media_blob on. Unit tests (app 923, session-ui 318) and tsc -b for both packages pass.

Public API: unchanged. The server API, SDK, plugin API, config and CLI are untouched. The app sends the existing X-OpenCode-Capabilities: experimental-media-uri header on three reads, and session-ui's internal DataProvider gains optional readMedia/peekMedia props.

One concern is worth fixing, noted inline: the cache revokes object URLs that rendered attachments and drafts still use.

const touch = (hash: string, url: string) => {
if (urls.has(hash)) urls.delete(hash)
urls.set(hash, url)
while (urls.size > maxEntries) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Eviction calls URL.revokeObjectURL on URLs that other code may still be holding:

  • CurrentUserMessageDisplay keeps the loaded URL in resolvedUrl, and clicking the image opens ImagePreview with that same URL.
  • resolveBlobUrl returns it to the composer for media: draft images.

The cache is shared per server, so once more than 128 distinct images have loaded (exactly the long screenshot sessions this targets), older images have dead srcs. You'd see a broken preview, a broken image after a re-mount, or a broken composer thumbnail, with no reload, because peek misses but the component never calls load again.

Two ways to fix it: only revoke entries nothing currently holds (a refcount or release from the component's onCleanup), or have consumers re-load when a URL might have been evicted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 7709085. The cache no longer evicts. Every object URL it hands out stays valid until the server cache is disposed (server switch or teardown), so rendered attachments, ImagePreview and composer drafts can never hold a revoked URL. Refcounting would have needed a release from every consumer, and the composer and submit paths have no clear lifetime to release from. Images are still fetched only when they are viewed, so memory stays below the old inline data: payloads. Added a test that loads 200 images and checks the first URL still resolves until dispose.

…osed

Clean the anti-slop lint findings in the files this branch touches.

@opencodejr opencodejr 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.

This commit fixes the earlier object-URL revocation point: the media cache now keeps object URLs until the server's cache is disposed, so images that are still on screen, open in a preview or in a draft stay valid. That holds a server's images in memory while it's connected, which is about what the inline base64 data cost before. The rest of the commit is formatting, lint suppressions and type tightening. I found nothing new worth blocking on. The description still says the cache "revokes the oldest beyond 128", which is no longer true.

I couldn't run it in the app against a live server with the flag on. Unit tests (app 924, session-ui 318) and tsc -b for both packages pass.

Public API: unchanged.

@kitlangton
kitlangton merged commit f822b10 into dev Oct 28, 2026
22 checks passed
@kitlangton
kitlangton deleted the media-app branch October 28, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant