fix(composer): never lose a draft typed before the chat loads

Draft
TE
5d ago
+618-43

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

  1. The page and the chat view swap places on a load error, and the swap dropped the draft (App.tsx showNewTab). 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's onLeaveDraft kept 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.
  2. The session arriving reset the prompt (Composer.tsx session effect). A composer that had no session yet (still loading or reconnecting, or a new chat whose session had just started) set its prompt to restored || carried || draft || "" when the session id arrived. carried applies only to a switch, so text typed before the chat loaded was replaced by the saved draft or by an empty prompt.
  3. 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 returned undefined, 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.
  4. A new chat's kept draft that arrived after the first keystroke was ignored (draft effect: if (field.value()) return). The typed text then went out through chat.unsentDraft and replaced the tab's kept draft on disk (AgentUnsentDrafts.set).
  5. agent-unsent-drafts.json was 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) and AgentUnsentDrafts.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 order ComposerHandle.restore already 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's onLeaveDraft hands its draft to the chat composer (through restore, or heldBack until 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: readDurableDraftState returns { 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 (hydratedDraft with firstRead), 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 empty chat.unsentDraft is 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 as agent-unsent-drafts.unreadable-<time>.json before 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. RestoredNewTabPages asks hasDraft instead of calling seed, 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 mergedDraft and hydratedDraft.
  • 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 Autofix with [code]smith
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.unsentDraft writes until the page has been handed its kept draft; after that, the person's own clear still clears.
  • Moves unreadable agent-unsent-drafts.json and prompt-journal files aside instead of overwriting them on the next write.