perf(clients): fix composer draft persistence boundaries - #9049
perf(clients): fix composer draft persistence boundaries#9049StiensWout wants to merge 4 commits into
Conversation
Web: the zustand persist pipeline ran the full draft walk plus JSON.stringify on every store write, so each keystroke serialized every persisted draft, including base64 image attachments; only the final localStorage write was debounced. partialize now captures the live state cheaply and the walk + stringify run once per idle flush inside the debounced storage. 200 keystrokes next to two 1 MB images: 200 serializations / 534.6 MB / 1693.5 ms of main-thread time down to 1 / 2.7 MB / 6.8 ms. Mobile: picked and pasted images persisted full base64 data URLs into drafts.json, thread-outbox/*.json, and incoming-shares/*.json (an 8 MB image cost 21.3 MB of JSON per copy, rewritten on every 200 ms draft debounce). Images are now file-backed like file attachments already were: bytes are copied once into the app-owned attachment directory and drafts persist metadata plus fileUri (322 bytes). Uploads stream from the owned file; base64 is materialized lazily only for the old-server inline fallback. Legacy dataUrl drafts still hydrate, preview, upload, and send. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| name: input.name, | ||
| mimeType: input.mimeType, | ||
| sizeBytes, | ||
| fileUri, |
There was a problem hiding this comment.
🟡 Medium lib/composerImages.ts:290
Review-comment images leave their copied files in t3-composer-attachments permanently when removed or when the sheet is dismissed, steadily consuming device storage. createComposerImageAttachment returns an app-owned fileUri, but the review-comment composer only drops the attachment from state instead of calling removePersistedComposerAttachmentFile; invoke that cleanup path for both removal and dismissal.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/lib/composerImages.ts around line 290:
Review-comment images leave their copied files in `t3-composer-attachments` permanently when removed or when the sheet is dismissed, steadily consuming device storage. `createComposerImageAttachment` returns an app-owned `fileUri`, but the review-comment composer only drops the attachment from state instead of calling `removePersistedComposerAttachmentFile`; invoke that cleanup path for both removal and dismissal.
There was a problem hiding this comment.
[claude-fable-5] RESPONDING ON BEHALF OF WOUT:
Confirmed and fixed in afe1617. The sheet's local attachments now go through the reference-checked cleanup: removing an attachment releases its copied file immediately, and any attachments still held when the sheet unmounts (dismissal by cancel, back, or gesture) are released there. Submitted attachments are appended to the thread draft before the sheet closes, so the reference check keeps their files.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
The review-comment sheet holds picked images in local state until submit copies them into the thread draft. With file-backed images, removing an attachment or dismissing the sheet dropped the state while the copied file stayed in the attachment directory. Removal and unmount now route through the reference-checked cleanup, so submitted files stay owned by the draft and abandoned ones are deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This is an XL cross-platform persistence and attachment-lifecycle migration, not a small performance-only change: it adds app-owned image files, asynchronous upload fallbacks, outbox v4 handling, HEIC conversion, and deferred web serialization. The unresolved medium finding concerns cleanup of abandoned review-comment files, while downgrade behavior also changes, so the storage lifecycle and compatibility boundaries need human verification. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit afe1617. Configure here.
Dropping the picker's base64 export also dropped the only source of provider-supported bytes for HEIC-family originals: the picker's file copy stays HEIC on both its fast and slow paths, so those photos were rejected as unsupported. The picker exports JPEG base64 again, used only as the fallback for unsupported originals and landed once in the owned attachment directory; supported formats still copy their original file and nothing base64 is persisted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iOS always transcodes the base64 export to JPEG, but Android's quality-1 export is the raw original, so an Android HEIC pick would have shipped HEIC bytes labeled image/jpeg. The fallback now requires the JPEG magic number in the export and otherwise rejects the photo as unsupported, matching the pre-change Android behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Defer the draft walk and JSON serialization until the storage write flushes. Preserve hydration, migrations, attachment verification, and final flushes. Continues the web portion of [#9049](#9049). The mobile storage migration remains separate. Created with GPT-6 Astra (preview) in Codex. Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(antigravity): keep subagent batches active after launch (#9579) (cherry picked from commit 2675e3c70327719a99af4ae6e53e7b74fb8a9be0) * chore(upstream): record aligned changes through 2675e3c70 * fix(mobile): render workspace images in markdown file previews (#8769) (cherry picked from commit 09b81a34954c990f70257ae05efbb602c90aac97) * chore(upstream): record the markdown image module move * fix(usage): deduplicate CLI proxy subscription accounts (#9584) (cherry picked from commit b34ff8f56469afa8f3f85d89894e1b4cf49b5213) * fix(web): bound disconnected send toasts (#9592) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 07891e9569c88457516b44c08c820471762969e8) * chore(upstream): record aligned changes through 07891e956 * fix(desktop): restore panel titlebar interactions (#9591) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 57832803eed4c87c462de92892777a0934019721) * chore(upstream): record the desktop titlebar interaction fix * fix(connect): refresh authorization without disconnecting (#9582) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 39abb9d1d6ae6501c573b9dc0cb9c28e2f75659c) * fix(web): show context meter in compact composer (#9430) (cherry picked from commit f559fe0ba6fb5950bd14a2404f10b9c94b33f696) * chore(upstream): record aligned changes through f559fe0ba * fix(pull-requests): refresh data after thread turns (#9496) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 5cc369b7eb882dbea8d5ad21ba688c73a0058748) * chore(upstream): record the pull request refresh adaptation * fix(web): render draft PRs in gray (#9537) (cherry picked from commit caab2fdbac041ac2e851ad4fa3ac4a40a1d4a8f6) * chore(upstream): record the draft pull request colour * docs(upstream): rebase onto a squash-merged parent instead of merging main Squashing the parent sync branch moves the merge base behind its commits, so main and the stacked branch both look like they added the same code and git duplicates it without reporting a conflict. * refactor(web): move usage provider controls to settings (#9599) (cherry picked from commit f1e90e388b86fe4b007a55c0e685a1fa878115e6) * chore(upstream): record the usage provider settings move * docs(upstream): take upstream's version when it duplicates something we built Carrying two implementations of one feature costs the maintenance and still conflicts on every future upstream edit to their copy. * refactor(web): drop the unused advanced-section locals The provider Advanced section renders unconditionally, so advancedVisible, setAdvancedOpen and searchTargetId had no readers left. * fix: show idle subagent batches without completion marks (#9616) (cherry picked from commit 00f8b7c28056188e3c5630160806a0afe51c9010) * chore(upstream): record aligned changes through 00f8b7c28 * fix(web): group image views like other tool calls (#9597) Co-authored-by: Claude Code <noreply@anthropic.com> (cherry picked from commit 61a91b6ef1bd45424169c6650362b358d49bbe34) * chore(upstream): record the shared runtime instructions convergence * fix: preserve tool icons on failed calls (#9606) (cherry picked from commit c3b8825bf476cbce5e061c0f99570cf1f6723b89) * chore(upstream): record the failed tool icon tint * fix(connect): diagnose incomplete headless server setup (#9602) (cherry picked from commit 99e3b721c5255ded20b00ba1798f848bfc0f1f65) * chore(upstream): record the relay diagnostic adaptation * fix(web): keep command palette above composer menus (#9613) (cherry picked from commit 4cc800c7593db13726171918572afe3502c43ba6) * fix(web): snooze menu no longer overlaps thread details (#9601) (cherry picked from commit 93c3ab4ffe408a3e06228a33efc8a0745da91178) * chore(upstream): record aligned changes through 93c3ab4ff * fix(web): match composer pull request state icons (#9375) (cherry picked from commit 706231535ceac8618712913dfdc4a058c2ffb0d8) * chore(upstream): record the pull request icon states * fix(server): load OpenCode workspace skills via SDK to avoid 64KB CLI pipe truncation (#9585) (cherry picked from commit 2152d44de2db30a6bae965b0afd30be080e5c872) * fix(web): mute sidebar branch name to match worktree icon (#9622) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit ec3ec6f0b4e005c47aff07d4d9e31506241bce3a) * fix(web,mobile): fold context compaction under settled turn folds (#9623) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 5f878d2a85807618a4c8571cdef5daa3124672d6) * feat(mobile): make chat text selectable on Android (#8779) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> (cherry picked from commit 09d13de4381925fa2a6dea74eff8185fa301e905) * fix(web): toggle a single stashed prompt with Cmd+S (#9644) Cmd+S opened the stash menu even when the composer was empty and only one prompt was stashed. It now restores that prompt directly, so repeated presses toggle between the draft and stash. Multiple entries and images that are still saving open the menu. The stash badge still opens the menu. Validation: 94 focused stash, shortcut, and attachment tests pass. Web typecheck and formatting pass. Targeted lint has no new warnings or errors. Browser checks were skipped at Theo's request. Original implementation by Theo Browne. No code changes were needed during the takeover audit. Audited with GPT-6 Astra (preview) in Codex. (cherry picked from commit 14bf3f6d1644a37029be58429e8f0138e1ceb743) * fix(server): prevent duplicate desktop clients after restart Replace stale local desktop sessions in one transaction. Preserve paired clients and browser sessions, and keep the previous credential valid if replacement fails. Closes pingdotgg/t3code#6283. Original implementation by seeb1337. Reviewed and verified with GPT-6 Astra (preview) in Codex. Co-authored-by: seeb1337 <63622047+seeb1337@users.noreply.github.com> Co-authored-by: Theo Browne <me@t3.gg> (cherry picked from commit eb77683e5544e071db74831bae052bbd8a7d5f88) * fix(web): resume Antigravity threads without repeated sign-in (#9647) Allow Antigravity threads to resume while saved Google sign-in is unchecked after a server restart. Keep confirmed authentication failures and installation errors visible. Validated with 136 focused tests, web typecheck, targeted lint, and CI. Browser verification was omitted at the maintainer's request. Created with GPT-6 Astra (preview) in Codex. (cherry picked from commit d487dfbf46be344e818725be70ee04be2436bfb4) * feat(mobile): paste the phone clipboard into the terminal (#9199) Co-authored-by: Jake Leventhal <jakeleventhal@me.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> (cherry picked from commit d5b94100863057fb4629f9ad4a35753d16917924) * feat(web): show which sidebar threads hold an unsent draft (#9658) Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit f0347322441f3b8e473a8d13ea7006cbcb4fb761) * chore(upstream): record aligned changes through f03473224 * fix(server): unblock OpenCode approvals and stop (#9653) OpenCode could show an Approval badge with no controls, appear stuck on TodoWrite, and keep showing a running turn after Stop. - Show every permission, including old saved requests. Keep failed replies retryable and close completed requests even when reply events are lost. - Keep OpenCode output pipes drained and automatic replies out of the event loop. Handle disconnects, reconnects, and confirmed stops without stale requests or running states. - Show native task progress and command results. Do not treat TodoWrite or approval history as file edits or executed commands. - Ignore late aborts and task updates after a turn finishes. Fixes #4795 Fixes #7113 Fixes #5760 Created with GPT-6 Astra (preview) in Codex. Reviewed and merged with Claude Fable 5.1 in Claude Code. (cherry picked from commit 01f3e50eca5102ccd881de6f942a98fe6a518ad4) * test(server): adapt the OpenCode abort tests to buffered delivery The legacy token-streaming setting these parameterised over is upstream's Legacy features flag, which this fork does not carry. * chore(upstream): record the OpenCode approval unblock * fix(desktop): quit immediately on a second shortcut press (#9657) (cherry picked from commit caa8a0db98f9d32e98a1645caa7f7dd37b14f187) * chore(upstream): record the desktop quit shortcut * fix(server): update Claude Agent SDK to 0.3.260 (#9135) Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 560afffdea82000d757c98ea79678aee75f8648c) * chore(upstream): record aligned changes through 560afffde * perf(server): stop loading message bodies for thread summaries (#9662) (cherry picked from commit 8ac5462920c45cdee63af15b2598909736f2ec84) * chore(upstream): record the thread summary query change * docs(upstream): reinstall before a mid-round typecheck after a manifest change * perf(web): speed up terminal snapshots (#9663) (cherry picked from commit cccd7e3c885065e925f559c5708378cdb3b51eb3) * fix(web): show machine icons in the environment picker (#9668) (cherry picked from commit 082cab224624eb3a6cd494df3719c59014fb0c99) * chore(upstream): record aligned changes through 082cab224 * perf(mobile): bound diff syntax highlighting work (#9673) (cherry picked from commit 3b6be3ef4daa848e10c095d8a088064ac836be7f) * chore(upstream): record the bounded diff highlighting * perf(web): keep Markdown mounted during streaming (#9677) (cherry picked from commit 887ece307131bdc853cc10f3b82067dee77c4ecf) * chore(upstream): record the markdown streaming change * chore(upstream): register the legacy mobile list paths as removed #23 deleted the legacy home list and its presentation helpers, so upstream edits to them have nothing to land on here. * chore(upstream): record the marketing image skip * perf(server): cache and stream static web assets (#9669) (cherry picked from commit 27e6cc27fe0f3cff53a44905e40615b7db99c80c) * perf(server): batch projector cursor writes (#9671) (cherry picked from commit 2263e13fda8c9a4f1b6f4dee32e3c9020195e2aa) * perf(server): stop retaining unused OpenCode tool history (#9684) (cherry picked from commit f2e3764c257a7e27c8171d7dd1e38d4383074206) * chore(upstream): record aligned changes through f2e3764c2 * perf(mobile): reuse chat feed rows during streaming (#9688) (cherry picked from commit 44dc8ae259f5c3349f7ab8045e39bd62408b52a9) * chore(upstream): record the chat feed row reuse * perf(server): omit repeated OpenCode progress logs (#9689) (cherry picked from commit ec8b2119c377f5c1dbe6235b221ef98eca31a96e) * perf(clients): avoid waiting to read cached relay tokens (#9691) (cherry picked from commit 246064993535e5d90107d3d7784ceef3cc883435) * chore(upstream): record aligned changes through 246064993 * perf(mobile): reuse diff rows during comment edits (#9693) (cherry picked from commit 777f5bb2e11fe30e7fdcb5741b0b1d9bb20924d6) * chore(upstream): record the diff row reuse * perf(web): defer composer draft serialization (#9695) Defer the draft walk and JSON serialization until the storage write flushes. Preserve hydration, migrations, attachment verification, and final flushes. Continues the web portion of [#9049](pingdotgg/t3code#9049). The mobile storage migration remains separate. Created with GPT-6 Astra (preview) in Codex. Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit dab5f6e6e02e78675655e69503aa89654e5b8050) * chore(upstream): record aligned changes through dab5f6e6e * fix(upstream): rebrand relay client ids without the product stem RelayPublicClientId spells them ras-mobile and ras-web, so the catch-all kebab rule was producing ids the schema rejects. * test(client-runtime): use a managed endpoint kind the schema accepts #54 replaced managed Cloudflare tunnels with the RAS relay, so cloudflare_tunnel is no longer in RelayManagedEndpointProviderKind. * fix(web): move the chat pane store onto the deferred storage createDebouncedStorage is gone; the pane layout now serializes at flush time like composer drafts, so a no-op set no longer stringifies the layout. --------- Co-authored-by: Theo Browne <me@t3.gg> Co-authored-by: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: maria <maria@kuuro.net> Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Co-authored-by: Guilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Co-authored-by: Rakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> Co-authored-by: oliver <97427849+flamboh@users.noreply.github.com> Co-authored-by: Barry <43803274+BarryHenryJr@users.noreply.github.com> Co-authored-by: seeb1337 <63622047+seeb1337@users.noreply.github.com> Co-authored-by: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com>

Typing next to a heavy composer draft was paying for the whole draft store on every keystroke, and mobile image drafts were storing megabytes of base64 in JSON. This is item 03 of the performance opportunity audit.
Web: the persist pipeline ran the full draft walk plus
JSON.stringifyon every store write, so each keystroke serialized every persisted draft, including base64 image attachments; only the finallocalStorage.setItemwas debounced.partializenow captures the live state reference cheaply, and the real walk + stringify run once per idle flush inside the debounced storage (createDeferredStorage). Flush-on-unload, flush-before-attachment-verify, migration writeback, and hydration behavior are unchanged.Mobile: picked and pasted images persisted full base64 data URLs into
drafts.json,thread-outbox/*.json, andincoming-shares/*.json, re-serialized on every 200 ms draft debounce and re-parsed at cold start. Images are now file-backed the way file attachments already were: bytes are copied once into the app-owned attachment directory and drafts persist metadata plusfileUri. Uploads stream from the owned file (no temp base64 staging), and base64 is materialized lazily only for the old-server inline fallback. LegacydataUrldrafts still hydrate, preview, upload, and send; the outbox schema version moves 3 → 4 following the existing pattern.Measurements
Web numbers are from a store seeded with 20 drafts, one holding two 1 MB persisted images, driving 200
setPromptcalls through the real persist pipeline. Mobile numbers encode one attachment through the actual draft schema (the legacy shape stored the data URL twice:dataUrl+previewUri).Draft persistence still works (type → reload → restored)
Recorded against a live dev environment running this branch: the draft is typed, flushed after idle, and restored into the composer and sidebar after a full page reload.
Verification
vp test runacross all 12 touched suites: 333 tests pass (web draft store 105, mobile attachment/draft/outbox/share suites 228).tsgo --noEmitfor web,tsc --noEmitfor mobile) andvp linton changed files: clean.Known limitation: downgrading the mobile app to a pre-change build will not decode fileUri-only drafts or v4 outbox entries, the same class of impact as the earlier file-attachment rollout.
Change made by Claude Fable 5 running in Claude Code.
Note
Medium Risk
Broad changes to mobile attachment lifecycle, outbox schema v4, and async image upload wiring; legacy paths are preserved but downgrading the mobile app will not read file-only drafts.
Overview
Web: Composer draft persistence no longer runs the full draft walk and
JSON.stringifyon every keystroke.partializeonly captures a live state reference;createDeferredStorageruns normalization and serialization once per debounced flush (flush-on-unload unchanged).Mobile: New image attachments (picker, clipboard, paste, incoming share) are stored as app-owned files with
fileUrimetadata instead of megabyte-scaledataUrlin drafts, thread outbox (schema v3 → v4), and share drafts. Uploads stream from the owned file; base64 is read lazily only for servers without image uploads. Legacy inlinedataUrldrafts still decode and send. Image previews rebase paths when the iOS document container moves; review composer cleans up unreferenced files on dismiss/remove.Send path:
buildProjectThreadStartTurnInputnow always uses prepareduploadedAttachments(raw draft attachments removed from project start/outbox drain).Reviewed by Cursor Bugbot for commit 0c1f781. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Switch composer image attachments to app-owned files
dataUrlimage storage with app-owned file paths (fileUri) across composer drafts, outbox, and incoming shares.FileBackedComposerAttachmenttype andisFileBackedComposerAttachmentguard to narrow attachments with a definedfileUri.toUploadChatImageAttachmentsis now async and lazily reads base64 from disk only when the server lacks image uploads.uploadFileBytesuploads directly fromfileUriand only stages temporary files for legacy inline images.createDeferredStoragewith deferred serialization;partializeComposerDraftStoreStateis now exported.dataUrlorfileUri; old drafts withoutfileUristill work via legacy inline path. Drafts/outbox now persist image paths instead of bytes.Macroscope summarized 0c1f781.