fix(composer): never lose a draft typed before the chat loads
Leo (2026-10-29):
HOW THE F*** IS THIS STILL NOT FING CHANGED IT'S LITERALLY "STOP SHOWING FING NONSENSE ON LOAD" + "JUST FING REMOVE THE FING OLD DAMEMONS WHAT THE F*** ARE YOU GUYS FU CKING DOING" AND WHY THE F*** ARE YOU STILL CLEARING MY FING COMPOSER DRAFT WHEN I TYPE BEFORE FING LOADING IM ASSUMING MY PAST DRAFTS MAY ALSO GET FED TOO GOD THESE FING RECOVERY STEPS ARE SO F***ING RETARDED
And earlier the same day: "it seems that new updates overwrite the text portion of the draft and it becomes empty".
This PR covers the draft part: text typed before the chat finishes loading survives the load, and saved drafts are never lost to it.
Cause: four load paths dropped text, and two files were unsafe
- The page and the chat view swap places on a load error, and the swap dropped the draft (
App.tsxshowNewTab). When the host fails before its handshake ("acpmux did not answer in time"), the chat view with its error card takes the New Tab page's place. The page'sonLeaveDraftkept the draft only for a Sandboxed run, so text typed on the page was dropped. When a retry answered, the page came back and the chat composer's text was dropped too. This is the clear Leo saw: he typed, the error card appeared, and the prompt was empty. - The session arriving reset the prompt (
Composer.tsxsession effect). A composer that had no session yet (still loading or reconnecting, or a new chat whose session had just started) set its prompt torestored || carried || draft || ""when the session id arrived.carriedapplies only to a switch, so text typed before the chat loaded was replaced by the saved draft or by an empty prompt. - An unanswered draft read counted as "no draft" (
readDurableDraft). The read could fail in three ways: the native action map was not installed yet (the usual case on first load and after an update relaunch), the read threw, or the connection dropped. Each returnedundefined, which also means "acpmux has no draft". The composer then marked the chat as hydrated, and the first keystroke queued a write that replaced acpmux's saved draft once the connection came up. This is the "past drafts get f***ed too" case. The edited-before-read path did the same: what the person typed won over the saved draft, and an editor emptied before the read wrote""over it. - A new chat's kept draft that arrived after the first keystroke was ignored (
drafteffect:if (field.value()) return). The typed text then went out throughchat.unsentDraftand replaced the tab's kept draft on disk (AgentUnsentDrafts.set). agent-unsent-drafts.jsonwas treated as empty when it could not be read, and the next save replaced it. If the in-memory map was empty, the save deleted the file instead.agent-pane-unsent-prompts.json(the sent-prompt journal) also started empty on an unreadable file, so its next write overwrote it.
Fix
- Merge rule, the same on the page and in Swift:
mergedDraft(composerDraft.ts) andAgentUnsentDrafts.merged. If one side is empty, the other is kept. If the typed text already contains the saved draft, the typed text is kept as is, and a text merged once before is not doubled. Two different texts are both kept, the saved draft first and the typed text after it. This is the orderComposerHandle.restorealready uses ("what comes back goes first"), and it leaves the caret at the end of what the person is typing, so their next key lands where they expect. - Load-time swap (App.tsx, a few lines): while the host has failed before its handshake (
hostFailedEarly), the page'sonLeaveDrafthands its draft to the chat composer (throughrestore, orheldBackuntil the composer mounts). When the page comes back, it takes the composer's text and chips (takeDraft, read while the page renders, before the composer unmounts). The Sandboxed handover is unchanged. - Session effect: a composer with no previous session keeps what it holds and merges it with the chat's saved draft. It marks the text as edited before the read, so the read writes the merged text to acpmux.
- Durable read:
readDurableDraftStatereturns{ draft }only when acpmux answered. An unanswered read leaves the chat unhydrated. Text typed meanwhile goes only to the remount cache (cachePersistedDraft), so a remount brings it back, and the read retries on the action map's next version. The first answered read for a chat in this page merges (hydratedDraftwithfirstRead), so an empty or load-time editor never writes over a saved draft. Later reads keep the existing rules, so the person's own clear still clears. A quit before the first read answers reads once and merges before it writes. - Seed effect: a kept draft that arrives after typing is merged in, not dropped.
- Native
AgentUnsentDrafts: until a page has been given the tab's kept draft (seed), an emptychat.unsentDraftis ignored as a page that has not loaded yet, and a non-empty one is merged with the kept draft. After that, the page's empty text clears the draft, and a session start or a tab close still clears it as before. A file that cannot be read is moved aside asagent-unsent-drafts.unreadable-<time>.jsonbefore the first write, and nothing is saved if it cannot be moved. Writes stay atomic (.atomic: temporary file, then rename) and now log their failures.RestoredNewTabPagesaskshasDraftinstead of callingseed, so its check does not count as giving the draft out. AgentPanePromptJournal: an unreadable journal is moved aside instead of being overwritten.
Tests
- Webview,
webviews/test/composer-draft-survives-load.test.tsx(real Composer in jsdom):- (a) Text typed before the session attached is still there after load, and acpmux keeps it.
- (b) A saved acpmux draft and text typed before the connection: the prompt shows
saved\n\ntyped, acpmux keeps both, and the typed text alone is never written. A new chat's kept draft that arrives after typing is merged too. - (c) An editor typed into and then emptied before the read never writes
""over the saved draft. A read that throws, as during the outage, is not treated as "no draft". - (d) The person's clear and a send still clear acpmux's draft.
- Plus unit checks of
mergedDraftandhydratedDraft.
- Swift,
UnsentDraftTests: an empty load-time write is ignored, typed-before-seed text is merged, a clear after the seed clears, a session start clears, an unreadable file is moved aside and kept byte for byte, and the merge keeps both texts once.
Results: pending (CI and a fleet run at this head).
Related PRs
#26642, #26753 and #26792 (one stacked chain whose owner is not alive) cover other cases: adopting legacy state atomically, New Tab text and attachments through quit, and Quick Composer drafts. They do not touch the load-time clears above. #26753's "an existing non-empty record is never replaced by an empty seed" is the same idea as the native guard here, which this PR applies at chat.unsentDraft. None of them becomes fully redundant; #26753 and #26792 would need a rebase over the merge rule here.
No UI change: the composer shows what it held, so there are no before/after captures.
Agent session: agent session
š¤ Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Fixes the composer clearing draft text typed before the chat finishes loading, and prevents saved drafts from being overwritten by empty editors or unanswered reads.
Text typed while the chat loaded was dropped by four separate paths: the load-time swap between the New Tab page and the chat view, the session ID arriving, an unanswered acpmux draft read counting as "no draft," and a kept draft arriving after the first keystroke. Saved drafts could also be replaced by "" writes or overwritten files.
- Merges saved and typed text (saved first, typed after) instead of replacing one with the other, on both the webview and native sides.
- Leaves a chat unhydrated when its draft read does not answer, so typing never overwrites acpmux's saved draft.
- Ignores empty
chat.unsentDraftwrites until the page has been handed its kept draft; after that, the person's own clear still clears. - Moves unreadable
agent-unsent-drafts.jsonand prompt-journal files aside instead of overwriting them on the next write.