SIGN IN SIGN UP

fix(chat): make the run-failure card's retry tell the truth about the send gate (#7927)

* fix(chat): make the run-failure card's retry tell the truth about the send gate

Three reports that all land on the same gate in ProjectView:
`currentConversationActionDisabled`, which every recovery handler consults
and no button was ever wired to.

OPEND-2821 — the retry button had no `disabled` at all, so it always looked
live. Clicking it reported a `manual_retry` recovery click and then
`handleRetry` returned. Split that boolean into a four-way reason
(read-only / messages-unavailable / billing-unresolved / conversation-busy),
disable the card's recovery actions when it is set, and say which one it is.
The split is a partition of the same predicate, proven exhaustively.

OPEND-2758 — `handleSend` paints the new turn before the preflight and before
POST /api/runs (OPEND-2614, deliberate), and that paint makes
`retryableAssistantMessage` return null, so the card vanished 1-2s before
anything was confirmed. Announce the pending retry, pin the card on the
message being retried, lock the button and label it "Retrying", and drop the
announcement only when the replacement message gets its run id -- or when the
send never started, which restores the original card and its actions.

OPEND-2719 — an exhausted wallet parked the send in the pending-send queue.
The queue means "this runs once capacity frees up"; an empty wallet never
frees up, so the message sat there forever. Stop queueing that one branch
(`signed_out` keeps its queue-and-resume path) and hand the draft back to the
composer through the existing `restore-draft` outcome. The report's other
half did not reproduce: the dialog and balance card were already re-firing on
every blocked send.

Note on telemetry: a blocked click no longer counts as a recovery click. It
never produced a run to join against, so those were unjoinable orphans; the
existing surface_view still records that the action was offered.

* fix(chat): claim the composer draft positively instead of by exclusion

Review on #7927: the no-queue path for an exhausted wallet used an
exclusion predicate — "not a retry and not a queue drain, therefore the
composer owns it". ProjectView has eleven other direct `handleSend`
callers (share to community, design-system feedback and review decisions,
continue remaining tasks, resume run, question-form answers, Home
auto-send, resend-failed-user-message, brand extraction and enrichment,
the auto-audit repair effect, Antigravity OAuth repair). None of them
holds its payload in the composer, and all of them were being swept onto
that path — which silently took away their queue. The question-form
answer is the worst case: it keeps the only copy of the answer and its
uploads, and releases the form only on a durable-queue acknowledgement.

Replace it with an explicit `composerOwnedDraft` marker that only
`handleComposerSend` sets. Everything else falls through to the queueing
path it had before this branch, unchanged. The marker is transport-only
and is stripped alongside `acceptDurableQueue` before queue persistence:
once a send is parked, the queue item owns the payload, not the composer.
L
lefarcen committed
9f73dcebf3906bc7eae571e65fd2ecfd7024cdcc
Parent: 92bf0b7
Committed by GitHub <noreply@github.com> on 9/9/2026, 7:53:02 AM