From 4959afa60b7d580b704a72f6ed5c527983a5ac19 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:04:34 -0700 Subject: [PATCH 1/3] fix(native-chat): no "unconfirmed" line while Orca resends a send on its own A send whose answer was lost (for example after reopening the chat mid-send) showed "Message delivery is unconfirmed." with a Retry for the moment before the automatic resend under the same message id confirmed it. One rule now decides both: the resend runs, and the notice stays quiet, only while the send's answer is lost, the user never retried it, it was not still going out when the user pressed Stop, and the host has no record of it yet. Once the host has any record of it, the line and Retry are exactly as before. --- .../NativeChatStructuredSession.tsx | 10 +- ...tiveChatStructuredSessionDelivery.test.tsx | 142 +++++++++++++++--- ...red-agent-session-delivery-notices.test.ts | 71 ++++++++- ...ructured-agent-session-delivery-notices.ts | 8 +- ...-agent-session-outbox-unconfirmed-probe.ts | 11 +- ...agent-session-outbox-unconfirmed-resend.ts | 18 +++ 6 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 src/shared/structured-agent-session-outbox-unconfirmed-resend.ts diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 6b79c8a7fc8..51d67a49e4d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -122,9 +122,11 @@ export function NativeChatStructuredSession( retryRef.current(clientMessageId) }, []) const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') - // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. + // Only a rejected message or one in doubt reads the journal's rows, so a new batch of them + // re-renders no row else. const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') - const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS + const readsRows = hasRejected || controller.outbox.some((entry) => entry.state === 'unconfirmed') + const journalRows = readsRows ? controller.submissions : NO_SUBMISSIONS const startFailures = useStructuredAgentSessionStartFailureFacts( controller.journalItems, hasRejected @@ -135,7 +137,7 @@ export function NativeChatStructuredSession( controller.outbox, agentLabel, retryDelivery, - rejectionRows, + journalRows, startFailures, controller.failedHere ), @@ -143,7 +145,7 @@ export function NativeChatStructuredSession( controller.outbox, agentLabel, retryDelivery, - rejectionRows, + journalRows, startFailures, controller.failedHere ] diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 460df23ef8b..d3e149a23cc 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -183,7 +183,10 @@ vi.mock('./NativeChatQuestionCard', () => ({ })) import { NativeChatStructuredSession } from './NativeChatStructuredSession' -import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage' +import { + appendStructuredAgentSessionOutboxMessage, + getStructuredAgentSessionOutbox +} from './structured-agent-session-outbox-storage' describe('NativeChatStructuredSession delivery', () => { afterEach(() => { @@ -232,7 +235,8 @@ describe('NativeChatStructuredSession delivery', () => { ) } - it('retries an unconfirmed transport send and clears the delivery notice', async () => { + // Resent under its own id until the host answers, as a send still on its way: nothing to say. + it('confirms a transport-unconfirmed send on its own, with no notice or Retry', async () => { mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValueOnce({ ok: true, @@ -260,13 +264,114 @@ describe('NativeChatStructuredSession delivery', () => { | undefined expect(send?.('hello', [])).toBe(true) await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) + expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 5000 }) + expect(mocks.call.mock.calls[1]?.[2]).toEqual(mocks.call.mock.calls[0]?.[2]) + expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() + }, 10000) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) - }) + // Reopened mid-send, the send is read back in doubt; the probe resends it under its own id. + function seedMidSend(sessionId: string, patch: Record = {}): void { + seedOutbox(sessionId, [ + { + ...seededEntry(sessionId, 'op-sent', 'first', 'queued'), + state: 'dispatching', + lastAttemptAt: 1, + retryAfterUnknownSubmittedAt: null, + ...patch + } + ]) + } + + function renderSession(sessionId: string): void { + render( + + ) + } + + it('says nothing on a send reopened mid-send while it is resent, and settles it', async () => { + mocks.mode = 'outbox' + mocks.submissions = [] + mocks.call.mockResolvedValue({ + ok: true, + value: { submission: { clientMessageId: 'op-sent', dispatchState: 'accepted' } } + }) + seedMidSend('session-reopened') + + renderSession('session-reopened') + + expect(getStructuredAgentSessionOutbox('session-reopened')).toMatchObject([ + { clientMessageId: 'op-sent', state: 'unconfirmed' } + ]) + expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull() + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce(), { timeout: 3000 }) + expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({ + envelope: { clientOperationId: 'op-sent' } + }) + await waitFor(() => expect(getStructuredAgentSessionOutbox('session-reopened')).toEqual([])) + expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() + }, 10000) + + it.each([ + ['a live unknown', {}], + ['a recovered unknown', { recovered: true }], + ["an older host's recovered unknown", { reason: 'host_restarted_before_acknowledgement' }] + ])( + 'says a send reopened mid-send is unconfirmed, with its Retry, once the journal holds %s', + async (label, patch) => { + mocks.mode = 'outbox' + const sessionId = `session-reopened-${label.replace(/\W+/g, '-')}` + mocks.submissions = [ + { + clientMessageId: 'op-sent', + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'unknown', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + ...patch + } + ] + seedMidSend(sessionId) + + renderSession(sessionId) + + await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) + expect(screen.getByRole('button', { name: /Retry/ })).toBeTruthy() + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1500)) + }) + expect(mocks.call).not.toHaveBeenCalled() + }, + 10000 + ) + + it('says a send a Stop outlived is unconfirmed when reopened, as nothing resends it', async () => { + mocks.mode = 'outbox' + mocks.submissions = [] + seedMidSend('session-reopened-stopped', { outlivedStop: true }) + + renderSession('session-reopened-stopped') + + expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() + expect(screen.getByRole('button', { name: /Retry/ })).toBeTruthy() + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1500)) + }) + expect(mocks.call).not.toHaveBeenCalled() + }, 10000) it('retries the head, not a later stuck message', async () => { mocks.mode = 'outbox' @@ -484,7 +589,6 @@ describe('NativeChatStructuredSession delivery', () => { | undefined expect(send?.('first', [])).toBe(true) await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) expect(send?.('second', [])).toBe(true) // The head is probed automatically, clears, and the queue drains. @@ -569,6 +673,8 @@ describe('NativeChatStructuredSession delivery', () => { await act(async () => { send?.('second', []) }) + // From the row on, only the user's Retry moves it, so it says so. + await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3000)) }) @@ -653,7 +759,6 @@ describe('NativeChatStructuredSession delivery', () => { | undefined expect(send?.('first', [])).toBe(true) await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)) @@ -669,8 +774,8 @@ describe('NativeChatStructuredSession delivery', () => { it('never auto-probes an entry the user already force-retried', async () => { mocks.mode = 'outbox' mocks.submissions = [] - // Both the original send and the user's explicit Retry fail at the transport. mocks.call.mockRejectedValue(new Error('socket closed')) + seedOutbox('session-forced', [seededEntry('session-forced', 'op-head', 'first', 'unconfirmed')]) render( { /> ) - const send = mocks.composerProps?.structuredTransport?.send as - | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) - | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + // Only the user's Retry moves it, so it says so. await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - - // User retries with the same envelope and no legacy redelivery signal. - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - const forcedRequest = mocks.call.mock.calls[1]?.[2] as Record | undefined - expect(forcedRequest?.retryUnknown).toBeUndefined() - - // That retry also failed at the transport. The probe must not repeat an - // explicit retry automatically. await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3000)) }) - expect(mocks.call).toHaveBeenCalledTimes(2) + expect(mocks.call).not.toHaveBeenCalled() }, 20000) it('does not hot-loop when the host answers pending', async () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 5dde72621ad..05df4fa87a5 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -133,7 +133,9 @@ describe('the notice on each message that did not go through', () => { }) it('says a message is unconfirmed, and only that it was not sent when nothing more is known', () => { - expect(texts([entry('doubt', { state: 'unconfirmed' })])).toEqual({ + expect( + texts([entry('doubt', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 })]) + ).toEqual({ [agentJournalSubmissionKey('doubt')]: 'Message delivery is unconfirmed.' }) expect(texts([entry('bare', { state: 'rejected' })])).toEqual({ @@ -160,7 +162,7 @@ describe('the notice on each message that did not go through', () => { entry('sent', { state: 'dispatching' }), entry('rejected', { state: 'rejected' }), entry('failed', { lastFailure: { kind: 'failed' } }), - entry('stuck', { state: 'unconfirmed' }), + entry('stuck', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 }), entry('behind', { state: 'unconfirmed' }), entry('queued') ]) @@ -175,7 +177,10 @@ describe('the notice on each message that did not go through', () => { it('keeps a rejected message behind the stopped one its words but not its Retry', () => { const retry = vi.fn() for (const outbox of [ - [entry('stuck', { state: 'unconfirmed' }), entry('rejected', { state: 'rejected' })], + [ + entry('stuck', { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 }), + entry('rejected', { state: 'rejected' }) + ], [entry('held', { outlivedStop: true }), entry('rejected', { state: 'rejected' })] ]) { const notices = structuredAgentSessionDeliveryNotices( @@ -194,7 +199,7 @@ describe('the notice on each message that did not go through', () => { // Ahead of the stopped message, its Retry sends it at once, so the row offers it. it.each([ - ['in doubt', { state: 'unconfirmed' as const }], + ['in doubt', { state: 'unconfirmed' as const, retryAfterUnknownSubmittedAt: -1 }], ['outlived by a Stop', { outlivedStop: true as const }] ])('gives a failed message ahead of one %s its Retry', (_label, patch) => { const retry = vi.fn() @@ -460,6 +465,64 @@ describe('the notice on each message that did not go through', () => { expect(texts([entry('queued'), entry('sending', { state: 'dispatching' })])).toEqual({}) }) + // The unconfirmed probe resends it under its own id until the journal answers, as a send still + // on its way: nothing to say until a row lands. + describe('a message in doubt that Orca resends on its own', () => { + const doubt = (patch: Partial = {}) => + entry('doubt', { state: 'unconfirmed', lastAttemptAt: 1, ...patch }) + const row = (patch: Partial): AgentJournalSubmission => ({ + clientMessageId: 'doubt', + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'unknown', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + ...patch + }) + const UNCONFIRMED = { [agentJournalSubmissionKey('doubt')]: 'Message delivery is unconfirmed.' } + + it('says nothing while the journal holds no row for it', () => { + expect(texts([doubt(), entry('behind')])).toEqual({}) + // Another message's row is not its answer. + expect( + texts([doubt()], [row({ clientMessageId: 'other', dispatchState: 'accepted' })]) + ).toEqual({}) + }) + + it.each([ + ['a live unknown', row({})], + ['a recovered unknown', row({ recovered: true })], + [ + "an older host's recovered unknown", + row({ reason: 'host_restarted_before_acknowledgement' }) + ] + ])('says it is unconfirmed, with its Retry, once the journal holds %s', (_label, answer) => { + const retry = vi.fn() + const notices = structuredAgentSessionDeliveryNotices( + [doubt()], + 'Claude', + retry, + [answer], + [], + NOT_FAILED_HERE + ) + expect(Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text]))).toEqual( + UNCONFIRMED + ) + notices.get(agentJournalSubmissionKey('doubt'))?.onRetry?.() + expect(retry).toHaveBeenCalledExactlyOnceWith('doubt') + }) + + it.each([ + ['the user already retried', { retryAfterUnknownSubmittedAt: 1 }], + ['a Stop outlived', { outlivedStop: true as const }] + ])('says it is unconfirmed when %s it, as nothing resends it', (_label, patch) => { + expect(texts([doubt(patch)])).toEqual(UNCONFIRMED) + }) + }) + // Matched on the typed fact of a row found by its identity, never on either sentence. describe('a message rejected by a start whose row already says why', () => { const startFailed: AgentSessionFailureFact = { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 6ad655e8f80..f9de41e5cdc 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -32,6 +32,7 @@ import { admitStructuredAgentSessionOutboxEntry, structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission' +import { structuredAgentSessionEntryResendsUnconfirmed } from '../../../../shared/structured-agent-session-outbox-unconfirmed-resend' import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' import { translate } from '@/i18n/i18n' @@ -135,7 +136,8 @@ export function structuredAgentSessionDeliveryNotices( outbox: readonly StructuredAgentSessionOutboxEntry[], agentName: string, retry: (clientMessageId: string) => void, - /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ + /** The journal's rows: rejected ones carry more of a rejection than the message keeps, and a + * message in doubt with none yet is still being resent on its own. */ submissions: readonly AgentJournalSubmission[], /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ startFailures: readonly AgentSessionFailureFact[], @@ -152,6 +154,10 @@ export function structuredAgentSessionDeliveryNotices( ) const notices = new Map() for (const [index, entry] of outbox.entries()) { + // Resent under its own id until the journal answers, as a send still on its way: nothing to say. + if (structuredAgentSessionEntryResendsUnconfirmed(entry, submissions)) { + continue + } if ( entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) || diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts index 1d70ad86b88..b8a3b46e140 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts @@ -1,6 +1,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionEntryResendsUnconfirmed } from '../../../../shared/structured-agent-session-outbox-unconfirmed-resend' import { commitStructuredAgentSessionOutbox, getStructuredAgentSessionOutbox @@ -40,17 +41,15 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { // A non-null `retryAfterUnknownSubmittedAt` means the user already retried, so // another request would repeat that explicit action. Only entries that have // never been retried, and that no Stop outlived, are safe to probe automatically. + // The delivery notices read the same rule, so a send says nothing only while it is resent here. const probeId = blocker && blocker.sessionId === sessionId && - blocker.retryAfterUnknownSubmittedAt === null && - blocker.outlivedStop !== true + structuredAgentSessionEntryResendsUnconfirmed(blocker, submissions) ? blocker.clientMessageId : null - const probeSettled = - probeId !== null && submissions.some((submission) => submission.clientMessageId === probeId) useEffect(() => { - if (probeId === null || probeSettled || !owner.attached) { + if (probeId === null || !owner.attached) { return } const attempts = probeAttemptsRef.current.id === probeId ? probeAttemptsRef.current.attempts : 0 @@ -70,5 +69,5 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { Math.min(UNCONFIRMED_PROBE_BASE_DELAY_MS * 2 ** attempts, UNCONFIRMED_PROBE_MAX_DELAY_MS) ) return () => clearTimeout(timer) - }, [owner.attached, owner.ownerChange, owner.targetKey, probeId, probeSettled, sessionId]) + }, [owner.attached, owner.ownerChange, owner.targetKey, probeId, sessionId]) } diff --git a/src/shared/structured-agent-session-outbox-unconfirmed-resend.ts b/src/shared/structured-agent-session-outbox-unconfirmed-resend.ts new file mode 100644 index 00000000000..ee65fed7287 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-unconfirmed-resend.ts @@ -0,0 +1,18 @@ +// Which send in doubt Orca resends on its own under the same id, without the user's Retry. + +import type { AgentJournalSubmission } from './agent-session-journal-types' +import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' + +/** Whether the unconfirmed probe resends this entry: in doubt, never retried by the user, not + * outlived by a Stop, and with no journal row yet. Any row ends it: the journal answers from there. */ +export function structuredAgentSessionEntryResendsUnconfirmed( + entry: StructuredAgentSessionOutboxEntry, + submissions: readonly AgentJournalSubmission[] +): boolean { + return ( + entry.state === 'unconfirmed' && + entry.retryAfterUnknownSubmittedAt === null && + entry.outlivedStop !== true && + !submissions.some((submission) => submission.clientMessageId === entry.clientMessageId) + ) +} From b5cc3c836958369f81e9dd40d381387be9479c4a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:16:01 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(native-chat):=20say=20"Sending=E2=80=A6?= =?UTF-8?q?"=20on=20a=20message=20until=20the=20host=20confirms=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A send whose answer was lost was resent quietly under the same id but looked like any delivered message. Every message still in the outbox with no failure to show now says "Sending…", muted, in place of its time, until the journal holds a row for it. Rows that say a message did not go through keep only that line and its Retry. The notices read the outbox through the same reconcile as the transcript, so a row that lands clears the marker in one step. --- .../native-chat/NativeChatMessageRow.test.tsx | 19 ++- .../native-chat/NativeChatMessageRow.tsx | 56 ++++++--- ...tiveChatStructuredSession.test-harness.tsx | 5 +- .../NativeChatStructuredSession.tsx | 7 +- ...tiveChatStructuredSessionDelivery.test.tsx | 26 ++-- ...red-agent-session-delivery-notices.test.ts | 111 ++++++++++++++++-- ...ructured-agent-session-delivery-notices.ts | 42 ++++--- ...-agent-session-outbox-unconfirmed-probe.ts | 2 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + 14 files changed, 214 insertions(+), 60 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index bcecab05bc6..e0b353df2f0 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -3,7 +3,7 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { MessageRow } from './NativeChatMessageRow' +import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow' afterEach(cleanup) @@ -163,8 +163,8 @@ describe('MessageRow send mode', () => { }) }) -describe('a user message that did not go through', () => { - function renderUser(deliveryNotice?: { text: string; onRetry?: () => void }) { +describe('what a user message says about its delivery', () => { + function renderUser(deliveryNotice?: NativeChatDeliveryNotice) { return render( { renderUser() expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() }) + + // Muted, in the time's place, and shown without hover: a message nothing confirmed yet never + // looks like one that went through. + 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') + expect(screen.queryByRole('time')).toBeNull() + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 234934664f4..6b1512e4057 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -28,12 +28,41 @@ import type { } from './native-chat-subagent-sections' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -/** What a user message says under it when it did not go through, with its own Retry when the - * surface can send it again. */ -export type NativeChatDeliveryNotice = { - text: string - onRetry?: () => void - onDismiss?: () => void +/** What a user message says about its delivery: that nothing has confirmed it yet, quietly in + * place of its time, or that it did not go through, with its own Retry when the surface can send + * it again. */ +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. */ +function UserMessageMeta({ + markdown, + timestamp, + sending +}: { + markdown: string + timestamp: number | null + sending: boolean +}): React.JSX.Element | null { + if (sending) { + return ( + + {translate('components.native-chat.messageSending', 'Sending…')} + + ) + } + if (!markdown && timestamp === null) { + return null + } + return ( +
+ {markdown ? : null} + +
+ ) } /** One message: its prose first, then a collapsible run folding all of the @@ -178,15 +207,12 @@ export const MessageRow = memo(function MessageRow({ {translate('components.native-chat.goal.sentAsGoal', 'Sent as goal')} ) : null} - {/* Copy + timestamp reveal together, mirroring the agent controls row. - Image-only prompts have no text to copy, so the button is omitted. */} - {markdown || message.timestamp !== null ? ( -
- {markdown ? : null} - -
- ) : null} - {deliveryNotice ? ( + + {deliveryNotice?.text !== undefined ? (
{deliveryNotice.text} {deliveryNotice.onDismiss ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 6e39ec0129d..9ce0a000634 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -27,7 +27,8 @@ function absent(): T | undefined { return undefined } -/** Stands in for the transcript: renders only each message's delivery notice and its Retry. */ +/** Stands in for the transcript: renders only each message's delivery notice and its Retry, or the + * row's quiet "Sending…" while nothing has confirmed it. */ export function DeliveryNoticesMock({ notices }: { @@ -37,7 +38,7 @@ export function DeliveryNoticesMock({
{[...(notices ?? [])].map(([id, notice]) => (
- {notice.text} + {notice.sending ? 'Sending…' : notice.text} {notice.onRetry ? (