From 3b01ba6d0d8635efc8b80cb19c9bb3882d84f16c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:08:40 -0700 Subject: [PATCH] =?UTF-8?q?fix(native-chat):=20show=20"Sending=E2=80=A6"?= =?UTF-8?q?=20on=20a=20message=20until=20the=20host=20confirms=20it=20(#24?= =?UTF-8?q?606)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * fix(native-chat): say "Sending…" on a message until the host confirms it 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. * 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. * test(native-chat): move the delivery probe tests into their own file NativeChatStructuredSessionDelivery.test.tsx went over the 800-line lint limit after main was merged. The eight tests for the automatic probe of an unconfirmed message move unchanged to NativeChatStructuredSessionDelivery.probe.test.tsx, which uses the shared structured-session test harness for its mocks. The outbox seeding and probe clock helpers move into that harness so both files share them. The at-most-once reliability gate now lists the new file, with a fresh evidence run. --- config/reliability-gates.jsonc | 11 +- .../native-chat/NativeChatMessageRow.test.tsx | 43 +- .../native-chat/NativeChatMessageRow.tsx | 60 +- ...tiveChatStructuredSession.test-harness.tsx | 43 +- ...atStructuredSessionDelivery.probe.test.tsx | 362 ++++++++++++ ...tiveChatStructuredSessionDelivery.test.tsx | 516 +++++------------- ...red-agent-session-delivery-notices.test.ts | 253 ++++++++- ...ructured-agent-session-delivery-notices.ts | 50 +- ...ructured-agent-session-delivery-notices.ts | 20 +- ...-agent-session-outbox-unconfirmed-probe.ts | 11 +- 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 + ...agent-session-outbox-unconfirmed-resend.ts | 18 + 17 files changed, 945 insertions(+), 448 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx create mode 100644 src/shared/structured-agent-session-outbox-unconfirmed-resend.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index e5d8b5b2c57..642918a992c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -948,7 +948,7 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.", "oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts" ], "testFiles": [ @@ -963,6 +963,7 @@ "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx", "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx", "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx", + "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx", "src/renderer/src/lib/launch-structured-agent-session.test.ts", "mobile/src/session/mobile-native-chat-image-attachment.test.ts", "mobile/src/session/use-mobile-native-chat-image-attachments.test.ts", @@ -1033,13 +1034,13 @@ ], "evidenceRuns": [ { - "date": "2026-09-12", + "date": "2026-10-04", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", - "durationSeconds": 22.1, - "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests." + "durationSeconds": 42.3, + "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 197 tests after the delivery probe tests moved to their own file." }, { "date": "2026-09-12", diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index bcecab05bc6..f9e247d1354 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. 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…') + 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 234934664f4..10c8a68de07 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -28,12 +28,45 @@ 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 } + +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, + sending +}: { + markdown: string + timestamp: number | null + sending: boolean +}): React.JSX.Element | null { + if (!markdown && timestamp === null && !sending) { + return null + } + return ( +
+ {markdown ? ( + + ) : null} + {sending ? ( + + {translate('components.native-chat.messageSending', 'Sending…')} + + ) : ( + + )} +
+ ) } /** One message: its prose first, then a collapsible run folding all of the @@ -178,15 +211,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 ee876868c35..89fda3f7f31 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -1,3 +1,4 @@ +import { act } from '@testing-library/react' import { forwardRef, useImperativeHandle, useRef } from 'react' import { vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' @@ -27,7 +28,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 +39,7 @@ export function DeliveryNoticesMock({
{[...(notices ?? [])].map(([id, notice]) => (
- {notice.text} + {notice.sending ? 'Sending…' : notice.text} {notice.onRetry ? (