fix(native-chat): keep "Sending…" until the host has the message, in the time's slot

A message the user retried, a second message in doubt, or one requeued over a
rejected row had a journal row that did not hold it, so it showed nothing and
looked sent. "Sending…" now stays until the row is pending or accepted.

The marker took the place of the whole meta row, so the copy button went away
while sending and the row jumped when it cleared. The row now stays mounted:
copy keeps its hover reveal and "Sending…" sits where the time goes.
This commit is contained in:
Brennan Benson
2026-10-03 23:59:31 -07:00
parent b5cc3c8369
commit 059e79e418
4 changed files with 113 additions and 21 deletions
@@ -205,15 +205,39 @@ describe('what a user message says about its delivery', () => {
})
// Muted, in the time's place, and shown without hover: a message nothing confirmed yet never
// looks like one that went through.
// looks like one that went through. Copy keeps its hover reveal, and the row its height.
it('says quietly that it is still sending in place of its time, with no Retry', () => {
renderUser({ sending: true })
const sending = screen.getByText('Sending…')
expect(sending).toHaveClass('text-muted-foreground')
expect(sending).not.toHaveClass('can-hover:opacity-0')
expect(sending.parentElement).toHaveClass('group')
const copy = screen.getByRole('button', { name: 'Copy message' })
expect(sending).toHaveClass('text-xs', 'text-muted-foreground')
expect(Array.from(sending.parentElement!.children)).toEqual([copy, sending])
expect(sending.parentElement).not.toHaveClass('can-hover:opacity-0')
expect(sending.parentElement!.parentElement).toHaveClass('group')
expect(copy).toHaveClass('can-hover:opacity-0', 'group-hover:opacity-100')
expect(screen.queryByRole('time')).toBeNull()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})
it('keeps the same row when the message is confirmed, with the time back in its place', () => {
const { rerender } = renderUser({ sending: true })
const meta = screen.getByText('Sending…').parentElement
rerender(
<MessageRow
message={{
id: 'message',
role: 'user',
timestamp: 0,
source: 'transcript',
blocks: [{ type: 'text', text: 'Message text' }]
}}
expandSignal={false}
onScrollMessageToTop={vi.fn()}
/>
)
expect(screen.queryByText('Sending…')).toBeNull()
expect(screen.getByRole('time').parentElement).toBe(meta)
expect(meta).toHaveClass('can-hover:opacity-0')
})
})
@@ -35,9 +35,12 @@ export type NativeChatDeliveryNotice =
| { sending: true; text?: never; onRetry?: never; onDismiss?: never }
| { sending?: never; text: string; onRetry?: () => void; onDismiss?: () => void }
/** Under a user message: until confirmed, a quiet "Sending…" in its time's place, always shown;
* then copy + timestamp, revealed together like the agent controls row. Image-only prompts have
* no text to copy, so the button is omitted. */
const USER_META_REVEAL =
'transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100'
/** Under a user message: copy + timestamp, revealed together like the agent controls row. Until
* confirmed, a quiet "Sending…" stays visible in the time's place and copy keeps its own reveal,
* so the row keeps its height when it clears. Image-only prompts have no text to copy. */
function UserMessageMeta({
markdown,
timestamp,
@@ -47,20 +50,21 @@ function UserMessageMeta({
timestamp: number | null
sending: boolean
}): React.JSX.Element | null {
if (sending) {
return (
<span className="select-none text-xs text-muted-foreground">
{translate('components.native-chat.messageSending', 'Sending…')}
</span>
)
}
if (!markdown && timestamp === null) {
if (!markdown && timestamp === null && !sending) {
return null
}
return (
<div className="flex select-none items-center gap-1 transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [.group:has(:focus-visible)_&]:pointer-events-auto [.group:has(:focus-visible)_&]:opacity-100">
{markdown ? <NativeChatCopyButton text={markdown} /> : null}
<NativeChatMessageTimestamp timestamp={timestamp} focusable />
<div className={cn('flex select-none items-center gap-1', !sending && USER_META_REVEAL)}>
{markdown ? (
<NativeChatCopyButton text={markdown} className={sending ? USER_META_REVEAL : undefined} />
) : null}
{sending ? (
<span className="text-xs whitespace-nowrap text-muted-foreground">
{translate('components.native-chat.messageSending', 'Sending…')}
</span>
) : (
<NativeChatMessageTimestamp timestamp={timestamp} focusable />
)}
</div>
)
}
@@ -507,6 +507,65 @@ describe('the notice on each message that did not go through', () => {
}
)
// Only a row that has the message ends it; one in doubt or rejected leaves it looking sent.
describe('a message whose journal row does not have it yet', () => {
const row = (
clientMessageId: string,
dispatchState: AgentJournalSubmission['dispatchState'],
patch: Partial<AgentJournalSubmission> = {}
): AgentJournalSubmission => ({
clientMessageId,
fence: 1,
payloadFingerprint: 'fp',
dispatchState,
providerItemId: null,
reason: null,
submittedAt: 7,
resolvedAt: null,
...patch
})
const stuck = entry('stuck', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 })
it.each([
['queued', { state: 'queued' as const }, {}],
['in flight', { state: 'dispatching' as const, lastAttemptAt: 2 }, {}],
[
'in flight, after a host restart',
{ state: 'dispatching' as const, lastAttemptAt: 2 },
{ recovered: true as const }
]
])('says it is sending while your Retry is %s', (_label, patch, rowPatch) => {
const doubt = row('m', 'unknown', rowPatch)
expect(texts([entry('m', { state: 'unconfirmed', lastAttemptAt: 1 })], [doubt])).toEqual({
[agentJournalSubmissionKey('m')]: 'Message delivery is unconfirmed.'
})
const retried = entry('m', { lastAttemptAt: 1, retryAfterUnknownSubmittedAt: 7, ...patch })
expect(texts([retried], [doubt])).toEqual({ [agentJournalSubmissionKey('m')]: SENDING })
})
it.each([
['a live unknown', {}],
['a recovered unknown', { recovered: true as const }]
])('says the second of two in doubt is sending when each row holds %s', (_label, patch) => {
const a = entry('a', { state: 'unconfirmed' })
const b = entry('b', { state: 'unconfirmed' })
expect(texts([a, b], [row('a', 'unknown', patch), row('b', 'unknown', patch)])).toEqual({
[agentJournalSubmissionKey('a')]: 'Message delivery is unconfirmed.',
[agentJournalSubmissionKey('b')]: SENDING
})
})
it('says a requeued message whose row was rejected is sending until its replay answers', () => {
const requeued = entry('q', { state: 'queued', lastAttemptAt: 1 })
const rejected = row('q', 'rejected', { reason: 'provider said no', resolvedAt: 8 })
expect(texts([requeued], [rejected])).toEqual({ [agentJournalSubmissionKey('q')]: SENDING })
expect(texts([stuck, requeued], [rejected])).toEqual({
[agentJournalSubmissionKey('stuck')]: 'Message delivery is unconfirmed.',
[agentJournalSubmissionKey('q')]: SENDING
})
})
})
// One state, one surface: a row that says it did not go through never also says it is sending.
it.each([
['rejected', entry('m', { state: 'rejected' }), []],
@@ -1,5 +1,6 @@
// Which of the structured chat's own messages say, on their row, that they did not go through, and
// which say quietly that they are still sending: every other one, until the host holds a row for it.
// which say quietly that they are still sending: every other one, until the host holds a row that
// has it (pending or accepted). A row in doubt or rejected does not, so it still reads as sending.
//
// Derived from the outbox on every render and never stored: each failed or held message carries
// its own typed failure, so each row words its own reason. Read through the drain's own rule: while
@@ -142,7 +143,7 @@ export function structuredAgentSessionDeliveryNotices(
agentName: string,
retry: (clientMessageId: string) => void,
/** The journal's rows: rejected ones carry more of a rejection than the message keeps, and a
* message with none yet is still sending. */
* message with no pending or accepted one is still sending. */
submissions: readonly AgentJournalSubmission[],
/** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */
startFailures: readonly AgentSessionFailureFact[],
@@ -182,7 +183,11 @@ export function structuredAgentSessionDeliveryNotices(
retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text }
)
} else if (
!submissions.some((submission) => submission.clientMessageId === entry.clientMessageId)
!submissions.some(
(submission) =>
submission.clientMessageId === entry.clientMessageId &&
(submission.dispatchState === 'pending' || submission.dispatchState === 'accepted')
)
) {
notices.set(
agentJournalSubmissionKey(entry.clientMessageId),