Skip to content

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

Closed
teamleaderleo wants to merge 1 commit into
feat-cmux-nextfrom
fix/draft-never-lost
Closed

teamleaderleo wants to merge 1 commit into
feat-cmux-nextfrom
fix/draft-never-lost

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

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.

Written for commit 18ea006. Summary will update on new commits.

View guided diff Turn on auto-fix

Text typed before the chat finished loading was cleared, and a saved
draft could be overwritten by it or by an empty editor:

- the load-time swap between the New Tab page and the chat view (a host
  error before the handshake) dropped the draft both ways;
- the session id arriving reset a sessionless composer's prompt;
- an unanswered acpmux draft read counted as "no draft", so typing then
  replaced acpmux's saved draft;
- a new chat's kept draft arriving after the first key was ignored.

Merge saved and typed text (saved first, typed after) instead, keep an
unanswered read unhydrated, ignore load-time empty writes in
AgentUnsentDrafts until the page has its draft, and set aside unreadable
draft and prompt-journal files instead of overwriting them.

Agent-Session: https://cmux.com/deeplink/agent?id=aid-58ff9cb24c374c00f104
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teamleaderleo

teamleaderleo commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Owned by an agent session · took over from an earlier session · session

@github-actions github-actions Bot added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 29, 2026
@blacksmith-sh

blacksmith-sh Bot commented Oct 29, 2026

Copy link
Copy Markdown

Found 6 test failures on Blacksmith runners:

Failures

Test View Logs
/tmp/tmp34qahwaw/summary-missing.json (live):/summary-missing.json (live): View Logs
/tmp/tmph5gbqijy/summary-fail.json (live):/summary-fail.json (live): View Logs
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows/
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows
View Logs
[new_workspaces] the window draws no frames/[new_workspaces] the window draws no frames View Logs
[tabs] debug.tab_switch timed 0 of 40 switches/
[tabs] debug.tab_switch timed 0 of 40 switches
View Logs
[tabs] the window draws no frames/[tabs] the window draws no frames View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Unclear: web / react-apps-base-check (2) failed: no known signature; failed step: Run base reds and compare.

CI failed on 18ea00656e (run 40946062626 attempt 1): 1 code, 2 unknown.

Job Verdict Why
web / react-apps-base-check (2) unknown no known signature; failed step: Run base reds and compare
web / react-apps-base-check (1) code a web test failed
web / react-apps-check unknown no known signature; failed step: Check webview test shards

Failures:

  • seen on other PRs MarkdownMath.test.tsx keeps shell command dollar signs as plain text without dropping content: failed (job)
  • seen on other PRs queuedMessages.test.ts each agent message is a received-card row, in the queue's order, with its promptId: failed (job)
  • seen on other PRs rowSummary.test.tsx no description line on any Settings page is over 60 characters, in any language: failed (job)
  • seen on other PRs rowSummary.test.tsx every schema summary fits 60 characters in every language: failed (job)
  • seen on other PRs themePane.test.tsx the Theme page draws each theme key once, in one card, with no second App Theme group: failed (job)
  • seen on other PRs agent-pane-pending-steer.test.tsx Send now on a waiting steer asks queue_edit send_now for its promptId, and goes once it is read: failed (job)
  • seen on other PRs bare-ref-repo.test.tsx an agent's message: its own address names the repository of its bare #N: failed (job)
  • seen on other PRs glyph-alignment.test.ts glyphs use shared row geometry in light and dark: failed (job)
Matched log lines
web / react-apps-base-check (1): (fail) keeps shell command dollar signs as plain text without dropping content [4.42ms]

Not re-run automatically: web / react-apps-base-check (2), web / react-apps-base-check (1), web / react-apps-check are not machine failures.

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's; yours means the failing file is one this PR changes, also red on main that main's latest full suite fails the same way, seen on other PRs that it failed on another pull request's run lately.

red: cmux-next checks (god files, concurrency, crash safety, l10n); no finished base run on 920ece1 to compare; do not merge until green; see lane-rules
red: web / Web status; base red: the same failure on the base 920ece1; does not block
red: web / react-apps-base-check (1); no finished base run on 920ece1 to compare; do not merge until green; see lane-rules
red: web / react-apps-base-check (2); no finished base run on 920ece1 to compare; do not merge until green; see lane-rules
red: web / react-apps-check; base red: the same failure on the base 920ece1; does not block

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Bead cx-1csc2z created for this PR by the cmux coordinator.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Owned by an agent session · took over from an earlier session · session

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Awareness note from an agent lane that does not own this PR; it is not a review or a request to take the PR over.


Superseded by #28204 (merged as a240548). That carry includes this PR’s draft-loading and reconnect preservation work. As this PR’s owner, I authorize closing this original draft PR; the lane broker currently does not expose PR closure. The later changes in #27963 are being audited separately.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #28204 (merged a240548), with its owner lane's (orphan-composer) authorization.

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 18ea0065 Deployed Oct 29, 2026 by teamleaderleo via cmux-tui tree preflight #78027
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant