Skip to content

Restore model and effort picks when a background send is recovered - #887

Open
matt2e wants to merge 3 commits into
mainfrom
remember-chosen-but-failed-model
Open

matt2e wants to merge 3 commits into
mainfrom
remember-chosen-but-failed-model

Conversation

@matt2e

@matt2e matt2e commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to the #856 review thread: #856 (comment) (Codex P2, "Restore run picks with failed attachment sends").

Problem

In MessageComposer.tsx, send() captures the model/effort picks, hands attachments to sendInBackground(...), and synchronously clears setPicks({}). When that job later fails (upload or publish failure, session abort) or is cancelled, the recover callback restores the draft and the attachment store re-adopts the files, but the picks are never restored. The gauge button shows unpicked and a resend goes out at the agents' default model and effort — the one place where the composer promises restoration and under-delivers.

Fix

  • RecoveredSend carries picks?: MentionPicks; the recover callback passes capturedPicks (not the recipient-filtered sentPicks — the restored draft has its original recipients and the filter re-applies on the next send).
  • reconcileDraft(restored?, picks?) applies setPicks(picks ?? {}) only when the composer ends up holding exactly the recovered revision, unedited: the accepted load, the explicit-recovery load, and — the usual case — when the subscribeView listener already loaded that revision synchronously during replaceView, so the explicit notification arrives at the current === revision.current early return. Conflict paths never take them, so an old pick cannot attach to a later local edit.
  • receiveRecovery and the deferred-edit effect forward the record's picks.

The last bullet is why the production diff is a little larger than the ~10 lines estimated in the review thread: the recovered revision is normally loaded by the storage listener's argument-less reconcileDraft() before notifyRecovered runs, so the picks have to be applied at that early return as well.

Deliberately not done

  • No draft persistence: picks are not added to MentionDraft or draft storage. They remain useState only and still do not survive a reload or a remount without an in-session recovery, by design.
  • No change to the follow-up draft semantics: an unpicked turn still returns to defaults while an upload runs.

Validation

  • Regression test restores the model and effort picks of a failed background upload (remounted: false|true) in MessageComposer.test.tsx: mention the viewer's agent on the real-session upload harness, pick effort High, attach, send, reject the upload, assert the draft and file are restored and the picker has data-selected with tooltip Next run: High, then Retry + Send and assert the published kind-9 event carries ["effort", <agent>, "high"]. The remounted variant covers picks riding in the module-level record across a composer remount. The harness gained agents2 and members options (mentioned pubkeys must be channel members on the real session).
  • Fail-then-pass: with the source change reverted, both variants fail at toHaveAttribute("data-selected"); with it, they pass.
  • bin/pnpm vitest run src/features/messages/MessageComposer.test.tsx — 270/270 passed.
  • bin/pnpm typecheck (tsc --noEmit) — clean.
  • biome check --error-on-warnings on both changed files — clean.
  • Not exercised in the running app; opened as a draft pending the human check below.

To try it in the app

  1. In a channel with one of your agents, mention it, open the gauge button and move effort to High.
  2. Attach a file, Send, then press Cancel on the upload (or let it fail offline).
  3. Working looks like: the draft and file come back, the gauge button stays highlighted with tooltip "Next run: High", and after Retry + Send the agent runs at High.

Before / after

Left: main at f9a5e6f (the #856 merge). Right: this PR. Same script on both: mention the agent, set effort to High, attach a file, Send, press Cancel on the upload. Watch the gauge button after the draft and file come back: on the left its tooltip reverts to the default and the popover's slider is back at "Default", and the resent message has no effort tag; on the right the tooltip reads "Next run: High", the slider stays at High, and the published event carries ["effort", <agent>, "high"].

Recorded in headless WebKit (Playwright) on the relay-composer browser fixture (real ChannelsPage/MessageComposer, real upload fetch, buzz.mentions completions) with a fixture Agents2 provider for the viewer's agent "Honey" offering models/efforts, since agent types with models only register under Tauri — not the desktop app. Fixture data only.

before-after.mp4

🤖 Generated with Claude Code

@matt2e
matt2e marked this pull request as ready for review October 12, 2026 07:13
@matt2e
matt2e requested a review from a team as a code owner October 12, 2026 07:13
// recipient filter above applies again on the next send.
notifyRecovered(session, recoveryKey, {
draft: captured,
picks: capturedPicks,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Retain recovered picks until an unmounted composer returns

If a user selects a model/effort, starts an attachment send, leaves the conversation, and the upload fails before they return, this picks snapshot reaches no mounted listener. notifyRecovered retains missed recovery only when preparationError is present, but ordinary upload failures and cancellation recover without it. On remount, the caption and attachment return while picks initialize empty, so retry falls back to defaults.

Moving the added test’s remount until after upload rejection reproduces the missing selection; its current remount-before-rejection ordering passes. Retain the pick snapshot with the unconsumed recovery record, replay only against the matching saved revision, and retire it on consumption/replacement. Add the unmount → reject → remount regression case.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes needed: retain recovered model/effort picks when the upload fails while its composer is unmounted. I independently traced and corroborated the existing P2 finding; the new regression remounts before failure, so it does not cover failure before remount. Please address that finding and add the missing ordering case; no additional findings.

Star Lord’s automated source review via Wes’s account. Head 95fea73970c541c0ce63fe2510b0f519547386dd; base 63f7e9032e531191b119241b256f660f8d10e66e. No tests or app execution; author-reported results were not independently run. Inspected the PR description and sampled frames from its fixture video; this does not establish live-app behavior.

…is recovered

A failed or cancelled attachment send restores its draft and files but
not the model/effort picks captured for it, so a resend went out at the
agents' defaults while the picker showed unpicked. Carry the captured
picks in the recovery record and apply them wherever the composer ends
up holding the recovered revision unedited: the accepted-draft load, the
explicit recovery load, and the usual case where the storage listener has
already loaded it before its notification arrives. Conflict paths never
take them.

Picks are still not persisted with the draft and do not survive a reload.

Follow-up to #856:
#856 (comment)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the remember-chosen-but-failed-model branch from 95fea73 to 098745f Compare October 12, 2026 08:46
The reconcileDraft parameter shadowed the picks state declared earlier
in the component; capturedPicks reads the state, so one identifier meant
two things. Rename the parameter to recoveredPicks. No behavior change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes still needed: the naming-only follow-up does not resolve the existing P2 recovery finding. Current source still drops model/effort picks when an upload fails or is cancelled while the composer is unmounted. Retain unconsumed recovery metadata for the matching draft revision and cover unmount → failure → remount; the added test still remounts before failure. No additional findings.

Star Lord’s automated source review via Wes’s account. Head ad32b5a3f4888c8c3f00df6ec52cc46940c787d8; base ecfbb8a5835dd0fb04e48111ef406a975f2c7715. Source-only: no tests or app execution, and no independent CI/runtime verification. PR description and sampled fixture-video frames inspected; not a full-frame video audit.

…urns

Recovery records were retained for replay only when they carried a
preparation error, so an ordinary upload failure or cancel that landed
while the composer was unmounted reached no listener and its pick
snapshot was dropped; on return the caption and files came back from
storage but the picker showed unpicked and a retry used the defaults.

Retain every recovery record that no mounted composer has consumed. The
existing remount replay still applies it only while the saved draft is
the recovered revision and retires it otherwise, and consumption clears
it as before. Add the unmount -> fail -> remount case, which fails
without this change.

#887 (comment)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No changes requested: the prior P2 is addressed by retaining unconsumed recovery picks until a matching-draft remount, with the missing failure-before-remount test case now included. I found no new actionable defect in this fix.

Star Lord’s automated source review via Wes’s account; head 66605f9d5f41b846db2e3793b662bfc2c030c289, base ecfbb8a5835dd0fb04e48111ef406a975f2c7715. Source-only: no tests/app execution or independent CI verification; PR description and sampled fixture-video frames inspected, not a full-frame audit or desktop validation.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants