Skip to content

fix(mac): optimistic send — the message appears when you press Enter - #451

Merged
SergeSerb2 merged 2 commits into
mainfrom
surgecode/composer-optimistic-send
Aug 2, 2026
Merged

fix(mac): optimistic send — the message appears when you press Enter#451
SergeSerb2 merged 2 commits into
mainfrom
surgecode/composer-optimistic-send

Conversation

@SergeSerb2

Copy link
Copy Markdown
Owner

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.

  • Optimistic echo: AppModel.dispatchSend appends 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.
  • Message-id reuse (idempotency): one id per draft submission, reused across retries end to end (T3Client → BackendService → LiveBackend; queued messages reuse their queue identity; a restored failed draft carries its id). A retry now collapses onto one row. (Server-side turn dedup by messageId doesn't exist — stated honestly; the send gate below is what prevents double turns.)
  • Send-in-flight gate (double-send made two worktrees and orphaned one): per-thread refcount; composer canSend gates on it with a "Sending…" state; Stop unaffected.
  • Edit-resend identity restored with the draft on failure — a retry rewinds instead of appending a duplicate.
  • Attachment-encode race: send/queue await a still-encoding paste instead of silently dispatching without it.

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 app
  • apps/windows
  • apps/mobile
  • apps/server
  • Shared packages or relay
  • Build, CI, or release tooling
  • Docs

Release size

  • size:XS
  • size:S
  • size:M
  • size:L
  • size:XL

Verification

  • New AppModelOptimisticSendTests: 12 tests over a gated backend double (in-flight window observable without sleeps) — echo/upsert/failure/reconnect/id-reuse/gate semantics
  • pnpm run verify (full Swift suite): 1106 + 291 + 36 tests pass

🤖 Generated with Claude Code

SergeSerb2 and others added 2 commits August 2, 2026 15:51
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>
@SergeSerb2 SergeSerb2 added the size:M Normal feature or meaningful behavior change label Aug 2, 2026
@SergeSerb2
SergeSerb2 merged commit 0a8ce2e into main Aug 2, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M Normal feature or meaningful behavior change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant