Files
orca/src/shared/structured-agent-session-rejection-words.ts
T
Brennan Benson 6440d3c520 fix(mobile): keep a not-sent message in place on the phone, using the desktop's logic (#27936)
* fix(native-chat): list not-sent rows at their journal places

The shared projection appended rows the host recorded but never delivered after every other row,
leaving the reader to sort them. A reader that draws the list as given showed an old not-sent
message under newer turns. Merge them in at their journal places, after the stop rows are built,
so the list is already in order. The desktop sorts by the same order, so what it draws is unchanged.

* refactor(native-chat): move the recorded-rejection line into shared words

The desktop's words for a message the host recorded and then rejected now live beside the other
rejection words, returning notice parts each client renders its own way, so the phone can say the
same thing. The desktop calls it with the same inputs; its words are unchanged.

* refactor(native-chat): one projection behaviour for rejected rows

Every reader now draws a message the host recorded and then rejected where it was sent, marked
not sent, so the rejectedInPlace option goes. The phone was its last off caller apart from the
host's conversation outline, which now leaves not-sent rows out itself; a test proves the outline
is the same as if the rejected message had never been sent.

* fix(mobile): show a not-sent message in place with the reason under it

The phone now draws a message the host recorded and then rejected where it was sent, as the desktop
does, with one small line under the bubble in the desktop's words. The line is built from the
journal the view already receives; when a start-failure or sign-in row already gives the reason,
it says only that the message was not sent. The load-earlier header moves to its own file to keep
the view under its line limit.

* fix(mobile): a send the host recorded is never handed back

When the send's own reply said the host recorded the message and then rejected it, the phone put
the text back in the composer with a banner, though the host and the desktop keep it in the chat.
A recorded send now settles like any other: its row draws it in place as not sent (or its kept
card holds it), and the composer stays empty. The banner's failure-fact plumbing, fed only by that
branch, goes with it; the transcript states the sign-in guidance once.

* fix(mobile): keep each structured send on the shared optimistic list

Once a not-sent row stays in the chat, matching a pending bubble by its text breaks: resending the
same words after a not-sent message never reached the count the bubble waited for, so a stray copy
stayed, and an acknowledgement lost on that resend showed a false "Delivery unconfirmed".

A structured send is now one record, kept by the structured session under the id it is sent with.
Three parts match the desktop's sender: the id, the drawing (the shared projection draws it at the
tail until the host's row under that id lands, in the same pass) and the settle rule (the host's own
record of it, a submission, a card, or a card's hand-off, drops it by the one rule the desktop now
shares). The rest stays the phone's own: the bubble shows once the host accepts the send, not from
the tap; a send whose answer was lost is held undrawn and says "Delivery unconfirmed" only if its
deadline passes first, instead of going back to the composer; records are kept whether or not a
chat view is open; and one send in flight is the composer's rule, not the sender's. Its photo is
kept under the row id it shares with the host's row, so it shows on the bubble, the landed row and
a not-sent row. The drafts' text matching, with its older queued-card matching, now serves only
terminal-backed chats, whose transcripts carry no ids.

* fix(native-chat): merge not-sent rows by the transcript order alone

The merge kept a rule for rows with no journal place that no caller reaches and that the desktop's
sort does not have; drop it, and say what merging after the stop rows actually keeps. A test pins
that the list the phone draws as given is already in the desktop's sorted order, with a moved stop
row, a retry run, a cut-turn notice, a held card and two not-sent rows.

* fix(mobile): draw the not-sent line as the desktop does

Red and capped to the bubble's width, like the desktop's line and the phone's own returned-card
caption, and only when there is a notice: the row had a second, unreachable source of words. A
send a Stop took back is no failure, so it no longer rebuilds the notices on every streaming frame.
2026-10-12 15:16:23 -07:00

106 lines
4.4 KiB
TypeScript

// The words for a message the host rejected, shared by the desktop, the phone and the queue's
// returned cards.
import {
agentSessionWriteNoticeEnglish,
agentSessionWriteNotDoneParts
} from './agent-session-refusal-notice'
import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy'
import {
readWholeAgentSessionFailureFact,
type AgentSessionFailureFact
} from './agent-session-failure'
import type { AgentSessionFailureWordsContext } from './agent-session-failure-words'
import type { AgentJournalSubmission } from './agent-session-journal-types'
import { agentSessionFailureStatedByRow } from './agent-session-visible-failures'
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
/**
* What to put on screen for a rejection.
*
* A content rejection's reason is the provider explaining itself, so it is shown
* verbatim — "Claude does not support the image type .bmp" is the whole answer and
* a generic string would throw it away. A transport rejection's reason is an
* internal marker; printing it put `provider_write_failed: broken pipe` in front of
* users, which names nothing they can act on. That case gets copy that says what
* happened, and on the phone that the message can be sent again — which it can,
* because the frame provably never left, so a resend cannot duplicate.
*
* The null default claims no cause and no next step, because at that point we know
* neither: all it asserts is the one thing every rejection shares.
*
* Exported because the phone needs the same copy: the rule about
* which reasons a person may read is a property of the reason, not of the queue.
*/
export function structuredAgentSessionRejectionNotice(
reason: string | null,
write: 'send' | 'composer-send'
): string {
return agentSessionWriteNoticeEnglish(structuredAgentSessionRejectionParts(reason, write))
}
export function structuredAgentSessionRejectionParts(
reason: string | null,
write: 'send' | 'composer-send',
/** The host's typed fact, which decides when the row carried one. */
fact?: AgentSessionFailureFact,
context: AgentSessionFailureWordsContext = {}
): AgentSessionWriteNoticePart[] {
if (fact) {
return rejectionFactParts(write, fact, context)
}
if (reason === null) {
return ['notDoneSend']
}
const rejection = classifyDispatchRejection({ reason })
if (rejection.kind === 'writeFailed') {
return ['unreachable', ...agentSessionWriteNotDoneParts(write)]
}
// A legacy marker is an internal cause with no user-facing meaning; any other reason is a
// sentence written to be read — the provider's, or the host's own.
return rejection.kind ? agentSessionWriteNotDoneParts(write) : [{ text: reason }]
}
/** The line under a message the host recorded and then rejected. `startFailures`: what the loaded
* rows already state (`agentSessionVisibleFailureFacts`), so the reason is said once. */
export function structuredAgentSessionRecordedRejectionParts(
submission: AgentJournalSubmission,
context: AgentSessionFailureWordsContext,
startFailures: readonly AgentSessionFailureFact[]
): AgentSessionWriteNoticePart[] {
if (agentSessionFailureStatedByRow(submission.rejection, startFailures)) {
return agentSessionWriteNotDoneParts('send')
}
return structuredAgentSessionRejectionParts(
submission.reason,
'send',
readWholeAgentSessionFailureFact(submission.rejection),
context
)
}
function rejectionFactParts(
write: 'send' | 'composer-send',
fact: AgentSessionFailureFact,
context: AgentSessionFailureWordsContext
): AgentSessionWriteNoticePart[] {
const { kind } = classifyDispatchRejection({ reason: null, rejection: fact })
if (kind === 'writeFailed') {
return ['unreachable', ...agentSessionWriteNotDoneParts(write)]
}
// A fact this build cannot place proves only that the message did not happen.
return kind
? [{ failure: { ...fact, kind }, surface: 'rejection', context }]
: agentSessionWriteNotDoneParts(write)
}
/** What a returned queue card says about why the host rejected its message. */
export function structuredAgentSessionAttemptFailureParts(
failure: { kind: 'rejected'; reason: string | null },
context: AgentSessionFailureWordsContext = {},
/** The journal's whole fact for the rejection, when the host sent one this build can read. */
recorded?: AgentSessionFailureFact
): AgentSessionWriteNoticePart[] {
return structuredAgentSessionRejectionParts(failure.reason, 'send', recorded, context)
}