fix(mac): optimistic send — the message appears when you press Enter - #451
Merged
Conversation
The composer cleared the draft and then waited: nothing reached the timeline until the server echoed `thread.message-sent` back. On a thread's first turn that echo sits behind worktree provisioning and the project setup script, so the app shows the user nothing at all for ten-plus seconds and the send reads as dead (inventory finding 12). `dispatchSend` is now the one send path, and it does three things the old one did not. It appends the message to the timeline the moment the user commits it, as an ordinary `.userMessage` keyed by the client-minted message id. The server's echo carries the same id, so it upserts that row in place — a content update, not a remove and reinsert — and the attachments and server timestamp fill in without the row moving or a renderer case that only exists for pending rows. Attachments are deliberately left off the optimistic copy: their thumbnails resolve through `assets.createUrl` against an attachment id that does not exist until the message is persisted, so an optimistic copy would render as a failed image and report an error. A terminal failure takes the row back off again — unless the id was already echoed, which means the message landed and only the acknowledgement was lost. It carries one client message id per submission instead of minting a fresh one inside `T3Client.startTurn` on every attempt (finding 22). A retry after an ambiguous failure now repeats the id, so the optimistic row and LiveBackend's `seenMessageIDs` both collapse the second echo onto the first row rather than showing two copies of what the user typed once. Queued messages reuse their own queue id, which is exactly what the retry loop resends. It holds a per-thread send gate for the whole dispatch (findings 86/88). Nothing gated send before, and a second send during a first turn's bootstrap provisions a second worktree and orphans one of them. The gate is a refcount rather than a flag because the composer also has to hold it across an attachment encode it must await before it has a draft to dispatch at all. A reconnect snapshot that predates the message no longer deletes the pending row: `timelineReset` and the cold timeline load both reinstate rows whose send is still in flight, and stop reinstating them the moment it settles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…omposer Three defects on the same submission path, all of them ways the composer silently discarded something the user had already committed. `send()` captured `replacingID` and then `resetTransientState()` nil'd the edit identity. On failure only the draft text came back, so the retry appended a duplicate instead of rewinding the thread to the message being edited (finding 13). The identity is now restored alongside the text, and only when the draft actually came back and the composer is still on that thread — an edit identity that outlives its thread truncates the wrong conversation. `send()` and `queue()` cancelled `attachmentEncodeTask`, which quietly dispatched the message without a pasted screenshot that was still encoding (finding 15). Both now await the in-flight encode before capturing the draft. The wait is covered by the thread's send gate, so the button reads as busy for it and a second Return cannot start a parallel submission behind it. `canSend` gates on that same send gate (findings 86/88), so a double send during a first turn's ten-second bootstrap can no longer open two turns and provision two worktrees. Stop is unaffected: the draft is empty by then, so a running thread still shows the stop control, and Cmd+. was never routed through `canSend` at all. Return during an in-flight send keeps the draft and says nothing rather than blaming the connection. The submission's message id is minted once and travels back on the restored draft, so a manual retry repeats it instead of duplicating the user message; emptying the draft drops it, because a draft the user wiped is no longer that message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The "send feels dead" cluster (inventory findings 12, 86, 88, 13, 15, 22): pressing Enter cleared the draft and nothing appeared until the server round-tripped — on first sends, after the entire worktree bootstrap.
AppModel.dispatchSendappends the user's message as a normal timeline row the instant they commit, keyed by a client-minted messageId; the server echo upserts the same row in place (no flicker, attachments arrive with the echo). Terminal failure removes the row and restores the draft; an ambiguous failure after the echo keeps it. Reconnect snapshots taken before the server persisted the message re-instate the pending row instead of blanking it.canSendgates on it with a "Sending…" state; Stop unaffected.Pairs with the setup-progress PR: its server-side provisional echo carries the same messageId, so client echo + server echo + final persist all collapse onto one row by design.
Area
apps/mac— native macOS appapps/windowsapps/mobileapps/serverRelease size
size:XSsize:Ssize:Msize:Lsize:XLVerification
pnpm run verify(full Swift suite): 1106 + 291 + 36 tests pass🤖 Generated with Claude Code