cmux NEXT: a recovered draft's toast shows once, not at every launch
Leo's ask: "HOW THE FUCK IS THIS STILL NOT FUCKING CHANGED IT'S LITERALLY "STOP SHOWING FUCKING BULLSHIT ON LOAD" + "JUST FUCKING REMOVE THE FUCKING OLD DAMEMONS WHAT THE…""
This PR covers the first part, for one case: the recovered-draft toast showed up again on every launch.
What was wrong
RecoveryNotice.show (called from AppDelegate's first window) showed one "Recovered unsaved changes in …" toast for every draft in RecoveryDraftStore.drafts(). It did this at every launch. A draft that the person never opened or discarded therefore came back as a toast at every launch, forever.
What changes
- A recovered draft's toast now shows once. Later launches with the same draft show nothing.
- A newer draft of the same document toasts again. "Newer" means a different
savedAtfor the same id. - A launch with no new recovered draft shows nothing.
- The record lives in the store's own directory, next to the draft. It is
<stem>.announced, using the same FNV stem as<stem>.draft, holding{id, savedAt}. It is written 0600 by temp + fsync + rename, the same way as the draft (the write is factored intoRecoveryDraftFiles.writeAtomically). It does not use UserDefaults or a settings key. - The record goes away when its draft does:
remove(id:)(a save or Don't Save) deletes it.- Size-cap eviction deletes it.
- A launch deletes any record whose draft is gone.
- The drafts themselves are untouched. They stay on disk and restorable, the toast text is unchanged, and the toast is not removed.
- New API on
RecoveryDraftStore:unannouncedDrafts()only reads. It returns the drafts with no record for their currentsavedAt.markAnnounced(_:)writes the record.RecoveryNoticecalls it only aftertoasts.showreturns a live toast, so a launch that crashes or loses its window before the toast shows does not hide that draft. This was the Codex review's finding at the first head.drafts()is unchanged and still used by the debug restore socket.
Files: CmuxNextDesignTokens/Quit/RecoveryDraftStore.swift, CmuxNextApp/Quit/RecoveryNotice.swift, and the new test CmuxNextAppTests/RecoveryNoticeOnceTests.swift.
Tests
Focused test: RecoveryNoticeOnceTests in CmuxNextAppTests. Each launch builds a fresh store over the same directory, as the app does.
aDraftToastsOnTheFirstLaunchOnly: launch 1 toasts, launches 2 and 3 do not, and the draft is still on disk.aNewerDraftOfTheSameDocumentToastsAgainnoDraftShowsNothingremovingTheDraftDropsItsRecordaDraftWhoseToastNeverShowedStillToasts: reading the drafts without showing a toast records nothing.
The failing test is committed first (d84470a7dc0) and the fix after it. On the base, aDraftToastsOnTheFirstLaunchOnly and aNewerDraftOfTheSameDocumentToastsAgain should fail because the second launch toasts again. The red and the green both come from this PR's CI: Swift is not compiled on the authoring host.
Observed / Not run
- Observed:
python3 scripts/cmux-next/prepush-gate.pyreports no new violations. - Not run: no local Swift compile or test. The PR's CI compiles it and runs the suite.
Screenshots
Screenshots: requested from a fleet Mac capture. Before: the toast appears at every launch. After: the toast appears only on the first launch after a draft is saved.
Capture recipe (Debug-only, added in this PR):
- Fixture:
debug.filepagesgains two params, both under#if DEBUG || CMUX_BENCH_HOOKS.{plant_draft: {path, text}}writes a draft at once through the app's store.{launch_notice: true}runs the launch notice again with the app's store, which is what the next launch's first window does. The notice keeps no state outside that directory.{clear_draft: path}removes the draft and its record. The tour runs it first and last, so a capture leaves nothing in the shared recovery directory.- A step that plants nothing or finds no window answers
ok: false, which fails the tour step.
- Tour:
scripts/cmux-next/media/tours/recovered-draft-once.jsonplants a draft, runs the notice (first launch), waits 13 s for that toast to end, then runs the notice again (second launch). - Runs: two separate runs, after and before. Each run is one app launch, and inside it the notice runs twice to stand in for the first and second launch;
tour.pyhas no relaunch step. The runs share the runner's Application Support, which is why the tour clears its draft first and last. - Proving frames: the first-launch shot shows the toast at base and head. The second-launch shot shows it again at base and nothing at head.
- Base build: a base build has no fixture, so the before capture uses branch
lane/recovery-toast-once-capture-baseatc3ac2a66d41. That is this PR's merge-base plus only the two fixture commits. - Commands:
cmux-ci capture fb2c18d98015fdd29fcad4b3a9e7016469eacd2e --state tour:recovered-draft-once --pr 28305(after)cmux-ci capture c3ac2a66d412e446c2d1b6a12f9ee68cd94cf71e --state tour:recovered-draft-once --pr 28305(before)
Before / after
First launch with a recovered draft (base and head both show the toast once):
| Before, light | Before, dark |
|---|---|
![]() | ![]() |
| After, light | After, dark |
|---|---|
![]() | ![]() |
Second launch, same draft (base shows the toast again, head shows nothing):
| Before, light | Before, dark |
|---|---|
![]() | ![]() |
| After, light | After, dark |
|---|---|
![]() | ![]() |
Viewed after image: yes
Visual verdict: the toast appears once on the first launch and the second launch is clean, with the pill fully on screen and nothing clipped or repeated.
Notes
- Risk: risk:normal (UI notice logic plus a small state file in the recovery directory; no user data is deleted).
- Before this PR, the only in-app ways to open a recovered draft were the toast's Open and the debug socket's
restore. No palette, Home, menu or Settings entry exists. #28323 adds Open Recovered Draft… to the command palette, so a draft stays reachable after its single toast. It lands right after this PR.
Changelog
- Fixed: the "Recovered unsaved changes" toast shows once per recovered draft instead of at every launch.
Agent session: agent session
🤖 Generated with Claude Code







