From 059e79e4188e70cfe611aee239b2785267c8d45c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:56:23 -0700 Subject: [PATCH] =?UTF-8?q?fix(native-chat):=20keep=20"Sending=E2=80=A6"?= =?UTF-8?q?=20until=20the=20host=20has=20the=20message,=20in=20the=20time'?= =?UTF-8?q?s=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../native-chat/NativeChatMessageRow.test.tsx | 32 ++++++++-- .../native-chat/NativeChatMessageRow.tsx | 32 +++++----- ...red-agent-session-delivery-notices.test.ts | 59 +++++++++++++++++++ ...ructured-agent-session-delivery-notices.ts | 11 +++- 4 files changed, 113 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index e0b353df2f0..f9e247d1354 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -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( + + ) + expect(screen.queryByText('Sending…')).toBeNull() + expect(screen.getByRole('time').parentElement).toBe(meta) + expect(meta).toHaveClass('can-hover:opacity-0') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 6b1512e4057..10c8a68de07 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -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 ( - - {translate('components.native-chat.messageSending', 'Sending…')} - - ) - } - if (!markdown && timestamp === null) { + if (!markdown && timestamp === null && !sending) { return null } return ( -
- {markdown ? : null} - +
+ {markdown ? ( + + ) : null} + {sending ? ( + + {translate('components.native-chat.messageSending', 'Sending…')} + + ) : ( + + )}
) } 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 d5b3a81ba29..f0d99960070 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 @@ -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 => ({ + 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' }), []], 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 65deb1f61ff..8871f01695c 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 @@ -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),