Skip to content

🤖 fix: an edit keeps its text in memory, so the unsent draft survives a reload and stays out of other windows - #5801

Draft
ThomasK33 wants to merge 15 commits into
mainfrom
fix/reload-during-edit-draft
Draft

ThomasK33 wants to merge 15 commits into
mainfrom
fix/reload-during-edit-draft

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Editing a sent message no longer writes the edit text into the workspace's shared, persisted draft. The edit text lives only in the editing composer's memory. A reload during an edit keeps the unsent draft (#5672). An edit in one window never reaches another window's composer (#5571).

A workspace switch ends the open edit, as it did on main before this PR. Unlike main, nothing is lost: the edit's text, files and review notes move once into that workspace's normal draft, after the unsent draft. No edit state survives a composer remount.

Fixes #5672
Fixes #5571
Part of #5808 (item 1, in a different form: a switch ends the edit and keeps its contents. Items 2-6 exist on main and stay open.)

Background

On main, entering edit mode saved a snapshot of the unsent draft and replaced the shared draft with the message text. The shared draft is persisted and synced across windows. So a reload during an edit lost the unsent draft, and other windows showed the edit text.

An earlier version of this PR kept an open edit alive across a workspace switch, in module-level memory maps. Review round 5 found 3 defects in that mechanism (duplicate edit send after a remount, notes restored into an unmounted composer, a failed refresh stuck after a switch). This version removes that mechanism.

Implementation

One ownership rule. An edit's contents (text, files, notes) have exactly one owner at a time:

  1. the composer that opened the edit (memory only),
  2. an edit send in flight, or
  3. the workspace's normal draft: text and files in the DraftStore after the unsent draft, notes in the review store as attached notes.

Each move settles the edit session first and takes the buffer, so it runs once. Cancel and Escape discard the edit and restore the pre-edit notes, as before. An accepted send ends ownership.

  • useComposerDraft: a component-local edit buffer (useState plus editDraftRef). While an edit is open, setInput and setAttachments write only that buffer. They make no DraftStore write and no backend draft save. setDraftReviews writes its ref and its state together, so a send that completes after unmount still reads the notes it put back. It never runs on an edit keystroke.
  • ChatInputInner:
    • moveEditToDraft performs the move to the normal draft.
    • releaseEndedEdit calls it on unmount (switch, transcript-only), after a commit that cleared the target (row deleted, refresh found no target), and from the send's finally. It skips sessions that are in flight or still open.
    • The enter-edit effect moves a previous unsettled edit (second Edit) and reads the pre-edit notes from the ref after that move.
  • A refused edit send goes back by session identity. If it is still the composer's open session, the contents go back into that edit, and its notes merge in without duplicates. Otherwise they go to the normal draft after the unsent draft, and the notes go to the review store.
  • An edit cancelled while its send still prepares sends nothing: the send checks its session right before it takes the text.
  • An edit send that completes in an unmounted composer writes only workspace-bound stores. It does not call onCancelEdit, so it cannot close an edit open in another workspace. It does not patch the edit target or start a transcript refresh. isMountedRef is set in the mount effect, so StrictMode's simulated unmount does not leave it false.
  • ChatPane.tsx: main's edit slot and main's reset on a workspace switch. The only change vs main is the 2 replay guards in the "row replaced or deleted" effect (+6 lines): wait for a caught-up transcript, and keep the edit when its row is outside the replayed window (hasOlderHistory). A replay of the active workspace can empty its rows while ChatPane stays mounted.
  • The effect with no dependency list in ChatInputInner runs on every commit, stream updates included. It is O(1) with no I/O, and a code comment says so.
  • Not touched: src/browser/utils/chatEditing.ts (the canEditDisplayedUserMessage guard is unchanged), edit target row selection, and assistant and streaming paths.

Validation

New tests in tests/ui/chat/editKeepsUnsentDraft.test.ts. "Fails before" means the test failed on its own expect with the previous head e8acc69cd8. Each mutant was applied to the fix and restored afterwards, and the tree was clean after each one.

ID Test Fails before Mutant that makes it fail
T1 an edit sent before a workspace switch is closed on return and is sent only once yes —
T2 an edit accepted after a workspace switch brings back only the pre-edit notes yes M14 (the unmounted accepted restore sends only the pre-edit notes)
T3 an edit whose history-changed refresh failed ends on a switch, with its text after the unsent draft yes —
T4 a workspace switch ends an open edit and keeps its text and files after the unsent draft, once yes M1 (the unmount cleanup does not release the edit)
T5 an edit refused after a workspace switch keeps its text, files and notes in that workspace's draft, once yes M5 (notes copied by a layout effect), M7 (no unmounted check before a refresh; spy on requestTranscriptRefresh), M15 (the refusal replaces notes)
T6 a workspace switch moves an idle edit's notes to the attached notes, once yes M1, M4 (the move keeps notes in the composer instead of the review store)
T7 an edit accepted after a switch does not close an edit opened in the other workspace no M3 (the accepted path calls onCancelEdit while unmounted)
T8 an editing /compact accepted after a switch never shows its command text in the draft yes — (M2 is caught by the kept test "notes restored while a follow-up waits survive its send")
T9 under StrictMode an accepted edit closes, and a switch moves an idle edit once yes M6 (isMountedRef not set in the mount effect)
T10 an edit refused after its row was deleted keeps its text after the unsent draft, once yes M9 (old put-back writes in front of the draft)
T11 an edit whose row is deleted keeps its notes as attached notes after a switch yes M4
T12 a second Edit with notes, then Cancel, shows the first edit's notes once no M8 (pre-edit notes read from the render instead of the ref)
T13 an edit cancelled while its send is still preparing sends nothing yes M10 (no session check before the take)
T14 an open edit stays open while its workspace replays and has not caught up no M11 (no caught-up guard)
T15 an open edit stays open when a replay leaves its row outside the window no M12 (no hasOlderHistory guard)
T17 a workspace switch ends an open edit whose row a replay leaves outside the window yes M16 (the switch does not clear ChatPane's edit slot)
  • The 3 review-round-5 findings: T1 proves that a returning composer cannot send the same edit twice. T2 proves that no edit notes stay in the returning composer and that the pre-edit notes come back. T3 proves that a failed refresh does not leave the composer stuck after a switch.
  • T17 is the perf owner's requested test: a switch during a windowed replay (hasOlderHistory true) ends the edit and keeps its text as the unsent draft, after the existing draft, once.
  • T16 (a refusal after a Cancel during the send) is not in the suite. Mutant M13 (the refusal always writes notes into the composer) survives, so that branch is an untested guard. I checked every path that could cancel an edit after its send took the text:
    • Escape in the composer (vim and non-vim) and the Cancel button: both need editingMessageForUi, which is undefined once the send takes the text.
    • The cancel-edit command action: it runs only after acceptance.
    • The command palette: no action cancels a message edit.
    • A second Edit: the 🤖 feat: composer-draft follow-ups that need new protocol or mechanism #5226 guard blocks it while the send is pending.
  • Removed tests: "an open edit keeps its typed text and attachments across a workspace switch", "a kept edit whose row is deleted while another workspace is shown ends on return" and "a kept edit whose row is outside the replayed window stays open on return". They asserted the edit survives a switch, which this version reverses. T4, T14 and T15 replace them.
  • Per-keystroke writes: "a reload during an edit keeps the unsent draft (🤖 Reloading during a message edit replaces the unsent draft with the edit text #5672)" types 5 times in edit mode and asserts 0 draftStore.setText and setAttachments calls.
  • Kept tests: all 18 earlier tests in the file and the 7 tests in composerDraftsFormalRepro.test.ts pass unchanged. That covers the reload, the other window, transcript-only, the second Edit, the 🤖 feat: composer-draft follow-ups that need new protocol or mechanism #5226 races and memory-only attachments.
  • Results: editKeepsUnsentDraft.test.ts 34/34. tests/ui/chat: 128 passed, 8 skipped. ChatInput unit tests: 162/162. BUGBASH_AI=mock make test-bugbash-repros: 44 selected, all passed, including reloadDuringEditKeepsDraft.e2e.ts on web and phone.
  • make static-check passes. make check-react-compiler: "React Compiler coverage OK: 23/24 hot components compile (1 known skipped)."

Size

Production vs main is +228/−67 in 3 files: ChatInput/index.tsx +145/−57, useComposerDraft.ts +77/−10, ChatPane.tsx +6/0. The perf owner accepted this size. Tests are +1169/−99. The repair in this version removes the earlier module-level edit stores (about 145 added lines) and adds the ownership rule's move, put-back and notes routing.

Risks

Medium, limited to the composer's edit mode.

  • A reload or a workspace switch ends an open edit. The switch keeps its contents in the normal draft, and a reload drops them. Both were the chosen tradeoff.
  • Notes have no identity, and the review store does not deduplicate them. A moved note whose data is already in the store gets a second, attached entry.

Hazards that exist on main and stay open, tracked in #5808:

Dogfood

Bug-bash app with mock AI, two workspaces, composer values read from accessibility snapshots:

  1. Idle switch (1200 px and 390 px). With "unsent draft" typed and an edit open, I switched to B and back. The edit was closed, and the composer read "unsent draft", then the edit text, once. Two more switches and a reload kept it the same.
  2. Edit send in flight. I paused the backend process, sent the edit, and switched away and back. No edit was open, and the draft read "unsent draft". After resuming, the transcript showed the edited message once, and the draft did not change.
  3. Failed refresh, then a switch. I applied a local fault patch so the refresh fails, and produced a history-changed refusal. The Retry button showed. After a switch and a return, no "refreshing" indicator showed, Send was enabled, and the composer read "unsent draft", then the edit text.
  4. StrictMode dev server. An accepted edit closed, and scenario 1 behaved the same.
Idle switch, after return Failed refresh, after return
after return failed refresh after return

The in-flight scenario recording is attached below.

Review record

  • Previous head e8acc69cd8: 11 assessments used. Round 5 found the 3 defects listed in Background.
  • This push is the one repair push the maintainer approved. The planned budget is assessment 12 (Codex review), 13 (security review) and 14 (an independent final check). The cap is 14.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $92.65

s2-in-flight.webm

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T07:56:56.552105Z c7e7fdb New commits
🔒 Security Review ✅ Completed 2026-10-08T07:55:24.456273Z c7e7fdb New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76c52aa2bb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/browser/features/ChatInput/useComposerDraft.ts
Comment thread src/browser/features/ChatInput/index.tsx
@ThomasK33

Copy link
Copy Markdown
Member Author

Parked at a0ca2001af. Do not merge.

  • Reviews: 7 used. That is 6 Codex reviews (rounds 1-3, code and security), plus slot 7, the final independent check. Round 2 found 2 P2s, which a0ca200 fixed. Round 3 is clean, and Codex gave a 👍 on this head. No review thread is open.
  • Checks: every required check passes on this head. GitHub reports the PR as mergeable with no conflicts.
  • Final check: READY, with no blocker in what it was asked to cover.
  • Why it is held: the final check found a new loss of typed input that this PR introduces. An open edit's typed changes (text and attachments) are lost when you switch to another workspace and come back. The edit reopens with the original message text. The accepted tradeoff covers reload only. Tracked as the first item of 🤖 fix: an open edit loses its typed changes on a workspace switch (#5801), plus two older edit races #5808. A repair needs explicit approval first.
  • tests/ui/chat/sendModeDropdown.test.ts failed once on 76c52aa, cause not established. It passed in CI on this head and 10 of 10 local runs. Tracked in 🤖 tests: sendModeDropdown 'send after step' waits only 1 s for the stream to start #5807.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $35.15

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 72a15a919c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/browser/components/ChatPane/ChatPane.tsx Outdated
Comment thread src/browser/components/ChatPane/ChatPane.tsx
Comment thread src/browser/features/ChatInput/useComposerDraft.ts Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

Parked at 72a15a919c. Do not merge.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $49.02

@ThomasK33
ThomasK33 force-pushed the fix/reload-during-edit-draft branch from 72a15a9 to e8acc69 Compare October 7, 2026 08:02

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8acc69cd8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/browser/features/ChatInput/index.tsx Outdated
Comment thread src/browser/features/ChatInput/index.tsx
Comment thread src/browser/components/ChatPane/ChatPane.tsx Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

Parked at e8acc69cd8 (hard stop). Do not merge.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $54.92

@ThomasK33

Copy link
Copy Markdown
Member Author

Parked at c7e7fdbe76 (hard stop). Do not merge.

  • This head is the one approved R2 repair push. A workspace switch ends the open edit and moves its text, files and notes once into the normal draft.
  • Reviews 12 and 13 on this head (Codex normal and security) finished with no findings.
  • Review 14, the independent final check, found a new blocker in edit preservation. It reproduced it, and main does not have it:
    1. Leave "unsent draft" in the composer, keep the workspace busy, and queue a follow-up "queued Q".
    2. Edit an older message and send the edit. The backend restores the queue into the composer during the edit send (agentSession.ts:5000, restoreQueueToInput).
    3. The composer's restore branch (ChatInput/index.tsx:1676-1693) checks editingMessageForUi, which the send has already dismissed. It merges the restored text with the DraftStore draft and writes the result through setInput. While the edit send is in flight, setInput still goes to the edit buffer (useComposerDraft.ts:86-90).
    4. On acceptance, the buffer is appended after the draft (index.tsx:1290).
    • Result on c7e7fdbe76: the draft reads "unsent draft", then "queued Q", then "unsent draft" again. On main (1e03c96d7e) it reads "unsent draft", then "queued Q". The final checker's repro test failed on this head (count 2, expected 1) and passed on main.
  • Checks: Test / Unit (6/6) failed because GitHub canceled the step on the runner before any test ran ("Step canceled by GitHub"). That makes Required fail too. I did not rerun it.
  • Under the approved budget this is a hard stop. A fix needs a second push, which the budget does not allow. A maintainer must decide the next step. The likely fix is small: while an edit send is in flight, the restore branch writes its merge to the DraftStore, or it merges from the live text.
  • Assessments used: 14, which is the cap.

@ThomasK33
ThomasK33 marked this pull request as draft October 8, 2026 12:56
@ThomasK33

Copy link
Copy Markdown
Member Author

Marked as draft. The user chose Option C for #5672 and #5571: a new PR from main, built on the rule that normal draft writes always go to the normal draft. That PR replaces this one. Only the replacement is meant to land. This PR closes as superseded after the replacement merges, with a link to it.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant