From 0a0caf0ee7550f1b27bdd7290370a319b77b968c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:20:57 -0700 Subject: [PATCH 01/13] fix(chat): decode Claude pastes and track terminal delivery uncertainty Keep queued prompts pending while the existing agent status reports work, and check fresh history after a later idle fact. Preserve draft text and distinguish write rejection from unconfirmed delivery. Co-authored-by: Harshul Rathod --- .../src/session/MobileNativeChatMessage.tsx | 13 +- mobile/src/session/MobileNativeChatView.tsx | 3 +- .../mobile-native-chat-delivery-read.ts | 53 ++++++ .../mobile-native-chat-pending-echo.ts | 6 + .../session/mobile-native-chat-render-data.ts | 2 + .../use-mobile-native-chat-controller.ts | 11 +- .../session/use-mobile-native-chat-drafts.ts | 114 ++++------- ...bile-native-chat-pending-delivery.test.tsx | 170 +++++++++++++++++ ...use-mobile-native-chat-pending-delivery.ts | 140 ++++++++++++++ ...use-mobile-native-chat-unconfirmed-send.ts | 99 ++++++++++ ...ine-decoders-claude-pasted-content.test.ts | 66 +++++++ .../transcript-line-decoders-claude.ts | 3 +- .../native-chat/NativeChatComposer.tsx | 2 + .../native-chat/NativeChatMessageRow.tsx | 11 +- .../native-chat/NativeChatResolvedView.tsx | 69 ++----- .../native-chat/native-chat-composer-types.ts | 1 + .../native-chat-observed-send.test.ts | 60 ++++++ .../native-chat/native-chat-observed-send.ts | 61 ++++++ .../native-chat/native-chat-pending.ts | 3 + .../native-chat-runtime-image-send.ts | 16 ++ .../native-chat/native-chat-runtime-send.ts | 13 ++ .../use-native-chat-pending-delivery.test.tsx | 179 ++++++++++++++++++ .../use-native-chat-pending-delivery.ts | 178 +++++++++++++++++ .../use-native-chat-pty-composer-send.ts | 17 +- src/shared/claude-pasted-content.ts | 31 +++ .../native-chat-image-transcript-markers.ts | 8 +- .../native-chat-pending-delivery.test.ts | 60 ++++++ src/shared/native-chat-pending-delivery.ts | 76 ++++++++ 28 files changed, 1323 insertions(+), 142 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-delivery-read.ts create mode 100644 mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx create mode 100644 mobile/src/session/use-mobile-native-chat-pending-delivery.ts create mode 100644 mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts create mode 100644 src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-observed-send.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-observed-send.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts create mode 100644 src/shared/claude-pasted-content.ts create mode 100644 src/shared/native-chat-pending-delivery.test.ts create mode 100644 src/shared/native-chat-pending-delivery.ts diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 08feb9bc7ceb..fd55da733786 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -1,6 +1,7 @@ +import { NATIVE_CHAT_UNCONFIRMED_COPY } from '../../../src/shared/native-chat-pending-delivery' import { MobileSelectableText as Text } from '../components/MobileSelectableText' import { memo } from 'react' -import { Image, Text as NativeText, View } from 'react-native' +import { Image, Pressable, Text as NativeText, View } from 'react-native' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' @@ -69,6 +70,7 @@ function Prose({ function MobileNativeChatMessageImpl({ message, + deliveryNotice, toolsExpanded = false, fontScale = 1, onOpenFile, @@ -81,6 +83,7 @@ function MobileNativeChatMessageImpl({ subagentLabel }: { message: NativeChatMessage + deliveryNotice?: { onDismiss?: () => void } toolsExpanded?: boolean /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number @@ -130,6 +133,14 @@ function MobileNativeChatMessageImpl({ return ( <> + {isUser && deliveryNotice ? ( + + {NATIVE_CHAT_UNCONFIRMED_COPY} + + Dismiss + + + ) : null} ( entry.id === item.id && entry.delivery)} toolsExpanded={toolsExpanded} fontScale={fontScale} onOpenFile={onOpenFile} @@ -290,7 +291,7 @@ export function MobileNativeChatView({ {...turns.resolveRow(index, item)} /> ), - [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, subagentLabels, turns] + [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, subagentLabels, turns, pending] ) const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) diff --git a/mobile/src/session/mobile-native-chat-delivery-read.ts b/mobile/src/session/mobile-native-chat-delivery-read.ts new file mode 100644 index 000000000000..7097b6d85e74 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-delivery-read.ts @@ -0,0 +1,53 @@ +import { useCallback, useMemo } from 'react' +import type { NativeChatDeliveryStatus } from '../../../src/shared/native-chat-pending-delivery' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { nativeChatSessionPageRead } from './mobile-session-read-operations' + +export async function readMobileNativeChatDeliveryTranscript( + client: RpcClient | null, + sessionId: string | null, + transcriptPath: string | null +): Promise { + if (!client || !sessionId) { + return null + } + const response = await nativeChatSessionPageRead.request(client, { + agent: 'claude', + sessionId, + limit: 500, + ...(transcriptPath ? { transcriptPath } : {}) + }) + const result = nativeChatSessionPageRead.interpret(response) + if (!result.accepted) { + return null + } + const value = result.value + if ( + !value || + typeof value !== 'object' || + !('messages' in value) || + !Array.isArray(value.messages) + ) { + return null + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the nativeChat reader returns the host's message array, as on the existing history-page path. + return value.messages as NativeChatMessage[] +} + +export function useMobileNativeChatDeliveryTracking( + client: RpcClient | null, + sessionId: string | null, + transcriptPath: string | null, + enabled: boolean, + status: NativeChatDeliveryStatus | null | undefined +) { + const readTranscript = useCallback( + () => readMobileNativeChatDeliveryTranscript(client, sessionId, transcriptPath), + [client, sessionId, transcriptPath] + ) + return useMemo( + () => (enabled ? { status, readTranscript } : undefined), + [enabled, status, readTranscript] + ) +} diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 91bf778ba7c3..7874dfa97e12 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -1,7 +1,11 @@ +import type { NativeChatDeliveryOrigin } from '../../../src/shared/native-chat-pending-delivery' import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile' export type MobileNativeChatPendingMessage = { id: string + deliveryOrigin?: NativeChatDeliveryOrigin + delivery?: 'unconfirmed' | 'confirmed' + onDismiss?: () => void text: string expectedOccurrence: number /** Local preview URIs carried by the send for its optimistic echo. */ @@ -15,6 +19,7 @@ export type MobileNativeChatPendingMessage = { } export type MobileNativeChatSendOrigin = { + deliveryOrigin?: NativeChatDeliveryOrigin draftKey: string draftEditGeneration: number pendingKey: string | null @@ -64,6 +69,7 @@ export function appendMobileNativeChatPending( { id, text, + ...(origin.deliveryOrigin ? { deliveryOrigin: origin.deliveryOrigin } : {}), expectedOccurrence: origin.normalizedText === '' ? expectedImageEchoOrdinal diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index da93efadbb87..46bb8032c575 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -42,6 +42,8 @@ export function mobileNativeChatEmptyState( /** An optimistic user echo: the text and/or the local preview URIs of any images * ridden along on the send, shown until the transcript catches up. */ export type MobileNativeChatPendingItem = { + delivery?: 'unconfirmed' | 'confirmed' + onDismiss?: () => void id: string text: string images?: string[] diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index dfefcf476279..c68b804816cd 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,3 +1,4 @@ +import { useMobileNativeChatDeliveryTracking } from './mobile-native-chat-delivery-read' import { useLayoutEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -97,6 +98,13 @@ export function useMobileNativeChatController(args: { hostSupport: agentSessionHostSupport, onSendError }) + const deliveryTracking = useMobileNativeChatDeliveryTracking( + client, + activeChatSessionId, + activeChatResolution?.transcriptPath ?? null, + !activeChatStructured && activeChatAgent === 'claude', + nativeChatStatus + ) const { composerText: chatComposerText, setComposerText: setChatComposerText, @@ -123,7 +131,8 @@ export function useMobileNativeChatController(args: { // terminal view would permanently decline the prefill. chatActive: showNativeChat, transcriptLoading: nativeChatSession.transcriptLoading, - transcriptSettled: nativeChatSession.status === 'ready' + transcriptSettled: nativeChatSession.status === 'ready', + deliveryTracking }) // Deliberately not gated on the chat view being visible: the streaming gate diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a50ccf4c9235..63b84965af78 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -1,13 +1,17 @@ +import { useMobileNativeChatUnconfirmedSend } from './use-mobile-native-chat-unconfirmed-send' +import { captureNativeChatDeliveryOrigin } from '../../../src/shared/native-chat-pending-delivery' +import { + useMobileNativeChatPendingDeliveryState, + type MobileNativeChatDeliveryTracking +} from './use-mobile-native-chat-pending-delivery' import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countUserTextOccurrences, findLandedImagePreviewEchoes, - findLandedUnconfirmedSends, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, - normalizeReconcileText, - type UnconfirmedSend + normalizeReconcileText } from './mobile-native-chat-draft-reconcile' import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' @@ -29,9 +33,6 @@ export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] const NO_IMAGE_PREVIEWS: Record = {} -// Ack-lost sends wait for a transcript echo before surfacing as unconfirmed. -const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 - export function useMobileNativeChatDrafts(args: { hostId: string worktreeId: string @@ -52,6 +53,7 @@ export function useMobileNativeChatDrafts(args: { * is an empty conversation, not a read that failed or never ran. Only then * does a send's captured tail describe a real boundary. */ transcriptSettled: boolean + deliveryTracking?: MobileNativeChatDeliveryTracking }): { composerText: string setComposerText: Dispatch> @@ -105,12 +107,6 @@ export function useMobileNativeChatDrafts(args: { const draftEditGenerationsRef = useRef(new MobileNativeChatDraftEditGenerations()) const messagesRef = useRef(messages) messagesRef.current = messages - const activeDraftKeyRef = useRef(draftKey) - activeDraftKeyRef.current = draftKey - const activePendingKeyRef = useRef(pendingKey) - activePendingKeyRef.current = pendingKey - const mountedRef = useRef(false) - const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({ draftKey, messages, @@ -143,6 +139,9 @@ export function useMobileNativeChatDrafts(args: { const normalizedText = normalizeReconcileText(text) return { draftKey, + ...(args.deliveryTracking + ? { deliveryOrigin: captureNativeChatDeliveryOrigin(args.deliveryTracking.status) } + : {}), draftEditGeneration: draftEditGenerationsRef.current.readDraft(draftKey), pendingKey, normalizedText, @@ -154,7 +153,7 @@ export function useMobileNativeChatDrafts(args: { baselineResolved: transcriptSettled } }, - [draftKey, pendingKey, transcriptSettled] + [draftKey, pendingKey, transcriptSettled, args.deliveryTracking] ) // Why: over relay the send RPC can take seconds (or lose only its ack), and a @@ -181,7 +180,7 @@ export function useMobileNativeChatDrafts(args: { const acceptSend = useCallback( (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { - if (!origin.pendingKey && !images?.length) { + if (!origin.pendingKey && !images?.length && !origin.deliveryOrigin) { return } pendingCounterRef.current += 1 @@ -200,75 +199,13 @@ export function useMobileNativeChatDrafts(args: { [] ) - // Why: a relay drop mid-send loses only the ack in the common case — the - // desktop already delivered the message. Hold the send instead of claiming - // failure (which baits a duplicate): stay quiet when the transcript echo - // lands, and surface the uncertainty if the deadline passes without one. - // The composer was already cleared at send time, so this never touches drafts. - const unconfirmedRef = useRef([]) - const holdUnconfirmedSend = useCallback( - (origin: MobileNativeChatSendOrigin, text: string, onUnconfirmed: () => void) => { - if (!mountedRef.current) { - return - } - const isActiveTranscript = - activeDraftKeyRef.current === origin.draftKey && - (origin.pendingKey === null || activePendingKeyRef.current === origin.pendingKey) - const entry: UnconfirmedSend = { - draftKey: origin.draftKey, - pendingKey: origin.pendingKey, - text, - normalizedText: origin.normalizedText, - baselineTailMessageId: origin.baselineTailMessageId, - deadline: null - } - // Why: the transcript event can beat the lost RPC acknowledgement. - if ( - isActiveTranscript && - findLandedUnconfirmedSends(messagesRef.current, [entry]).length > 0 - ) { - return - } - entry.deadline = setTimeout(() => { - unconfirmedRef.current = unconfirmedRef.current.filter((held) => held !== entry) - onUnconfirmed() - }, UNCONFIRMED_SEND_DEADLINE_MS) - unconfirmedRef.current = [...unconfirmedRef.current, entry] - }, - [] + const holdUnconfirmedSend = useMobileNativeChatUnconfirmedSend( + draftKey, + pendingKey, + messages, + acceptSend ) - useEffect(() => { - if (!draftKey || unconfirmedRef.current.length === 0) { - return - } - const relevant = unconfirmedRef.current.filter( - (entry) => - entry.draftKey === draftKey && - (entry.pendingKey === null || entry.pendingKey === pendingKey) - ) - const landed = findLandedUnconfirmedSends(messages, relevant) - if (landed.length === 0) { - return - } - const landedSet = new Set(landed) - unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) - for (const entry of landed) { - clearTimeout(entry.deadline ?? undefined) - } - }, [messages, draftKey, pendingKey]) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - for (const entry of unconfirmedRef.current) { - clearTimeout(entry.deadline ?? undefined) - } - unconfirmedRef.current = [] - } - }, []) - const waitingForSession = draftKey ? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES @@ -287,6 +224,15 @@ export function useMobileNativeChatDrafts(args: { ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES const pending = combineMobileNativeChatPending(sessionPending, waitingForSession) + const settleDelivery = useMobileNativeChatPendingDeliveryState( + pending, + args.deliveryTracking, + pendingKey, + draftKey, + setPendingBySession, + setPendingWaitingForSession + ) + useEffect(() => { if (!pendingKey) { return @@ -337,7 +283,11 @@ export function useMobileNativeChatDrafts(args: { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, getComposerEditGeneration: draftEditGenerationsRef.current.readComposer, - pending, + pending: pending.map((entry) => + entry.delivery === 'unconfirmed' + ? { ...entry, onDismiss: () => settleDelivery(new Set([entry.id]), new Set(), true) } + : { ...entry, delivery: undefined } + ), imagePreviewsByMessageId: pendingKey ? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS) : NO_IMAGE_PREVIEWS, diff --git a/mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx b/mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx new file mode 100644 index 000000000000..4f02b1e964ef --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment happy-dom +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { beforeEach, afterEach, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { NativeChatDeliveryStatus } from '../../../src/shared/native-chat-pending-delivery' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' + +const roots = new Set>() +function cleanup() { + for (const root of roots) { + act(() => root.unmount()) + } + roots.clear() +} +function renderHook(hook: (props: P) => T, options: { initialProps: P }) { + let props = options.initialProps + let current: T | undefined + const root = createRoot(document.createElement('div')) + roots.add(root) + function Harness() { + current = hook(props) + return null + } + const render = () => act(() => root.render(createElement(Harness))) + render() + return { + result: { + get current(): T { + if (current === undefined) { + throw new Error('Hook did not render') + } + return current + } + }, + rerender(next: P) { + props = next + render() + } + } +} + +const boundary: NativeChatMessage = { + id: 'boundary', + role: 'assistant', + blocks: [{ type: 'text', text: 'before' }], + timestamp: null, + source: 'transcript' +} +const working: NativeChatDeliveryStatus = { state: 'working', stateStartedAt: 1 } +const idle: NativeChatDeliveryStatus = { state: 'done', stateStartedAt: 2 } +const base = { + hostId: 'host', + worktreeId: 'folder', + tabId: 'tab', + sessionId: 'session', + messages: [boundary], + transcriptSettled: true +} +const readTranscript = vi.fn<() => Promise>() +beforeEach(() => { + vi.useFakeTimers() + readTranscript.mockReset() + readTranscript.mockResolvedValue([boundary]) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) +async function tick(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +it('keeps a long queued send pending, then reads on idle and provides a dismissible notice', async () => { + const { result, rerender } = renderHook( + ({ status }) => + useMobileNativeChatDrafts({ ...base, deliveryTracking: { status, readTranscript } }), + { initialProps: { status: working } } + ) + act(() => { + const origin = result.current.captureSendOrigin('follow up')! + result.current.acceptSend(origin, 'follow up') + }) + await tick(120_000) + expect(readTranscript).not.toHaveBeenCalled() + expect(result.current.pending[0]?.delivery).toBeUndefined() + rerender({ status: idle }) + await tick(0) + expect(readTranscript).toHaveBeenCalledOnce() + expect(result.current.pending[0]?.delivery).toBe('unconfirmed') + expect(result.current.pending[0]?.text).toBe('follow up') + act(() => result.current.pending[0]?.onDismiss?.()) + expect(result.current.pending).toEqual([]) +}) + +it('uses the same lifecycle for a lost write acknowledgment and permits a late transcript match', async () => { + const { result, rerender } = renderHook( + ({ status, messages }) => + useMobileNativeChatDrafts({ + ...base, + messages, + deliveryTracking: { status, readTranscript } + }), + { initialProps: { status: working, messages: [boundary] } } + ) + const report = vi.fn() + act(() => + result.current.holdUnconfirmedSend( + result.current.captureSendOrigin('one\ntwo')!, + 'one\ntwo', + report + ) + ) + await tick(120_000) + expect(report).not.toHaveBeenCalled() + expect(result.current.pending).toHaveLength(1) + rerender({ status: idle, messages: [boundary] }) + await tick(0) + expect(result.current.pending[0]?.delivery).toBe('unconfirmed') + const echo: NativeChatMessage = { + ...boundary, + id: 'user', + role: 'user', + blocks: [{ type: 'text', text: '\none\ntwo\n' }] + } + rerender({ status: idle, messages: [boundary, echo] }) + expect(result.current.pending).toEqual([]) +}) + +it('matches old-host wrapped rows and preserves the second identical send', () => { + const text = 'one\ntwo' + const echo: NativeChatMessage = { + ...boundary, + id: 'user', + role: 'user', + blocks: [{ type: 'text', text: `\n${text}\n` }] + } + const entry = { + id: 'one', + text, + expectedOccurrence: 1, + baselineTailMessageId: 'boundary', + baselineResolved: true + } + expect( + retireLandedMobileNativeChatPending( + [boundary, echo], + [entry, { ...entry, id: 'two', expectedOccurrence: 2 }], + new Set() + ).map((item) => item.id) + ).toEqual(['two']) +}) + +it('does not change structured sends that omit terminal delivery tracking', async () => { + const { result } = renderHook(() => useMobileNativeChatDrafts(base), { initialProps: undefined }) + const report = vi.fn() + act(() => + result.current.holdUnconfirmedSend( + result.current.captureSendOrigin('structured')!, + 'structured', + report + ) + ) + await tick(20_000) + expect(report).toHaveBeenCalledOnce() + expect(result.current.pending).toEqual([]) +}) diff --git a/mobile/src/session/use-mobile-native-chat-pending-delivery.ts b/mobile/src/session/use-mobile-native-chat-pending-delivery.ts new file mode 100644 index 000000000000..0188cfcd803c --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-pending-delivery.ts @@ -0,0 +1,140 @@ +import { findLandedImagePreviewEchoes } from './mobile-native-chat-draft-reconcile' +import { useCallback, useEffect, type Dispatch, type SetStateAction } from 'react' +import { + nativeChatDeliveryCheckDelay, + observeNativeChatDeliveryOrigin, + type NativeChatDeliveryStatus +} from '../../../src/shared/native-chat-pending-delivery' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' + +export type MobileNativeChatDeliveryTracking = { + status: NativeChatDeliveryStatus | null | undefined + readTranscript: () => Promise +} + +export function useMobileNativeChatPendingDelivery( + pending: MobileNativeChatPendingMessage[], + tracking: MobileNativeChatDeliveryTracking | undefined, + settle: (checked: ReadonlySet, unmatched: ReadonlySet) => void +): void { + useEffect(() => { + if (!tracking) { + return + } + const checks = pending.flatMap((entry) => { + const delay = + entry.deliveryOrigin && !entry.delivery + ? nativeChatDeliveryCheckDelay(entry.deliveryOrigin, tracking.status) + : null + return delay === null ? [] : [{ entry, delay }] + }) + if (checks.length === 0) { + return + } + let cancelled = false + const timer = setTimeout( + () => { + void (async () => { + const due = checks + .filter( + ({ entry }) => + entry.deliveryOrigin && + nativeChatDeliveryCheckDelay(entry.deliveryOrigin, tracking.status) === 0 + ) + .map(({ entry }) => entry) + const messages = await tracking.readTranscript().catch(() => null) + if (cancelled || (tracking.status && !tracking.status.restoredUnconfirmed && !messages)) { + return + } + // Keep phone-local images until the normal retirement path rebinds their previews. + const imageIds = new Set( + messages + ? findLandedImagePreviewEchoes( + messages, + due.filter((entry) => entry.baselineResolved) + ).map((echo) => echo.pendingId) + : [] + ) + const remaining = messages + ? retireLandedMobileNativeChatPending(messages, due, imageIds) + : due + settle(new Set(due.map((entry) => entry.id)), new Set(remaining.map((entry) => entry.id))) + })().catch(() => { + /* An unreadable host has not proved absence. */ + }) + }, + Math.min(...checks.map(({ delay }) => delay)) + ) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [pending, tracking, settle]) +} + +type PendingByKey = Record +export function useMobileNativeChatPendingDeliveryState( + pending: MobileNativeChatPendingMessage[], + tracking: MobileNativeChatDeliveryTracking | undefined, + pendingKey: string | null, + draftKey: string | null, + setPendingBySession: Dispatch>, + setPendingWaitingForSession: Dispatch> +) { + const updateDeliveryOrigins = useCallback(() => { + const update = ( + previous: Record, + key: string | null + ) => { + if (!key || !previous[key]) { + return previous + } + const next = previous[key].map((entry) => { + if (!entry.deliveryOrigin || entry.delivery === 'confirmed') { + return entry + } + const origin = observeNativeChatDeliveryOrigin(entry.deliveryOrigin, tracking?.status) + return origin === entry.deliveryOrigin + ? entry + : { ...entry, deliveryOrigin: origin, delivery: undefined } + }) + return next.every((entry, index) => entry === previous[key]?.[index]) + ? previous + : { ...previous, [key]: next } + } + setPendingBySession((previous) => update(previous, pendingKey)) + setPendingWaitingForSession((previous) => update(previous, draftKey)) + }, [tracking?.status, pendingKey, draftKey, setPendingBySession, setPendingWaitingForSession]) + useEffect(updateDeliveryOrigins, [updateDeliveryOrigins]) + const settleDelivery = useCallback( + (checked: ReadonlySet, unmatched: ReadonlySet, dismiss = false) => { + const update = ( + previous: Record, + key: string | null + ) => { + if (!key || !previous[key]) { + return previous + } + return { + ...previous, + [key]: previous[key].flatMap((entry) => + !checked.has(entry.id) + ? [entry] + : unmatched.has(entry.id) + ? [{ ...entry, delivery: 'unconfirmed' as const }] + : dismiss + ? [] + : [{ ...entry, delivery: 'confirmed' as const }] + ) + } + } + setPendingBySession((previous) => update(previous, pendingKey)) + setPendingWaitingForSession((previous) => update(previous, draftKey)) + }, + [pendingKey, draftKey, setPendingBySession, setPendingWaitingForSession] + ) + useMobileNativeChatPendingDelivery(pending, tracking, settleDelivery) + return settleDelivery +} diff --git a/mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts b/mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts new file mode 100644 index 000000000000..7df10c20d896 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useRef } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + findLandedUnconfirmedSends, + type UnconfirmedSend +} from './mobile-native-chat-draft-reconcile' +import type { MobileNativeChatSendOrigin } from './mobile-native-chat-pending-echo' + +const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 + +/** Legacy acknowledgment-loss handling; terminal Claude sends use status-based pending delivery. */ +export function useMobileNativeChatUnconfirmedSend( + draftKey: string | null, + pendingKey: string | null, + messages: readonly NativeChatMessage[], + acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void +) { + const mountedRef = useRef(false) + const activeDraftKeyRef = useRef(draftKey) + activeDraftKeyRef.current = draftKey + const activePendingKeyRef = useRef(pendingKey) + activePendingKeyRef.current = pendingKey + const messagesRef = useRef(messages) + messagesRef.current = messages + // Why: a relay drop mid-send loses only the ack in the common case — the + // desktop already delivered the message. Hold the send instead of claiming + // failure (which baits a duplicate): stay quiet when the transcript echo + // lands, and surface the uncertainty if the deadline passes without one. + // The composer was already cleared at send time, so this never touches drafts. + const unconfirmedRef = useRef([]) + const holdUnconfirmedSend = useCallback( + (origin: MobileNativeChatSendOrigin, text: string, onUnconfirmed: () => void) => { + if (origin.deliveryOrigin) { + acceptSend(origin, text) + return + } + if (!mountedRef.current) { + return + } + const isActiveTranscript = + activeDraftKeyRef.current === origin.draftKey && + (origin.pendingKey === null || activePendingKeyRef.current === origin.pendingKey) + const entry: UnconfirmedSend = { + draftKey: origin.draftKey, + pendingKey: origin.pendingKey, + text, + normalizedText: origin.normalizedText, + baselineTailMessageId: origin.baselineTailMessageId, + deadline: null + } + // Why: the transcript event can beat the lost RPC acknowledgement. + if ( + isActiveTranscript && + findLandedUnconfirmedSends(messagesRef.current, [entry]).length > 0 + ) { + return + } + entry.deadline = setTimeout(() => { + unconfirmedRef.current = unconfirmedRef.current.filter((held) => held !== entry) + onUnconfirmed() + }, UNCONFIRMED_SEND_DEADLINE_MS) + unconfirmedRef.current = [...unconfirmedRef.current, entry] + }, + [acceptSend] + ) + + useEffect(() => { + if (!draftKey || unconfirmedRef.current.length === 0) { + return + } + const relevant = unconfirmedRef.current.filter( + (entry) => + entry.draftKey === draftKey && + (entry.pendingKey === null || entry.pendingKey === pendingKey) + ) + const landed = findLandedUnconfirmedSends(messages, relevant) + if (landed.length === 0) { + return + } + const landedSet = new Set(landed) + unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) + for (const entry of landed) { + clearTimeout(entry.deadline ?? undefined) + } + }, [messages, draftKey, pendingKey]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + for (const entry of unconfirmedRef.current) { + clearTimeout(entry.deadline ?? undefined) + } + unconfirmedRef.current = [] + } + }, []) + + return holdUnconfirmedSend +} diff --git a/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts new file mode 100644 index 000000000000..a98f5cb3ab41 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' +import { normalizeNativeChatUserText } from '../../shared/native-chat-image-transcript-markers' +import { + pendingSendsAsMessages, + prunePendingSends +} from '../../renderer/src/components/native-chat/native-chat-pending' +import type { NativeChatMessage } from '../../shared/native-chat-types' + +const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.' +const wrapped = `\n\n\n${prompt}\n\n` +function decode(text: string, role = 'user', array = false) { + return decodeClaudeTranscriptLine( + JSON.stringify({ + type: role, + uuid: 'user', + message: { content: array ? [{ type: 'text', text }] : text } + }), + 'fallback' + )! +} +function message(id: string, role: NativeChatMessage['role'], text: string): NativeChatMessage { + return { id, role, source: 'transcript', timestamp: null, blocks: [{ type: 'text', text }] } +} + +describe('Claude whole-block paste envelope', () => { + it.each([false, true])('decodes host user content (array=%s)', (array) => { + expect(decode(wrapped, 'user', array).blocks).toEqual([{ type: 'text', text: prompt }]) + }) + it('accepts CRLF and a wrapper without ids', () => { + expect(decode(`\r\n${prompt}\r\n`).blocks).toEqual([ + { type: 'text', text: prompt } + ]) + }) + it.each([ + `Explain this:\n${wrapped}`, + wrapped.replace('id="7e64">\n', 'id="other">\n'), + wrapped.replace('', ''), + wrapped.replace('', ''), + `${wrapped}\n${wrapped}` + ])('preserves prose and nonmatching envelopes', (text) => { + expect(decode(text).blocks).toEqual([{ type: 'text', text }]) + }) + it('leaves assistant text untouched', () => { + expect(decode(wrapped, 'assistant').blocks).toEqual([{ type: 'text', text: wrapped }]) + }) + it.each(['new host', 'old host'])('retires desktop echoes with %s rows', (host) => { + const history = [ + message('boundary', 'assistant', 'earlier'), + host === 'new host' ? decode(wrapped) : message('user', 'user', wrapped), + message('reply', 'assistant', 'answer') + ] + const pending = [{ id: 'p1', text: prompt, sentAt: 999_000, afterMessageId: 'boundary' }] + expect(pendingSendsAsMessages(pending, history)).toEqual([]) + expect(prunePendingSends(pending, history)).toEqual([]) + expect( + prunePendingSends([...pending, { ...pending[0]!, id: 'p2', matchingOccurrence: 2 }], history) + ).toHaveLength(1) + }) + it('handles a large prompt in linear passes', () => { + const text = 'line\n'.repeat(50_000) + expect( + normalizeNativeChatUserText(`\n${text}\n`) + ).toBe(text.trim().replace(/\s+/g, ' ')) + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index 5202035bbc6d..bc7a988e79ef 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -15,6 +15,7 @@ import { } from '../ai-vault/session-scanner-values' import { imageSourcePathFromText } from '../../shared/native-chat-image-transcript-markers' import { claudeContentBlocks } from './transcript-record-blocks' +import { unwrapClaudePastedContentBlock } from '../../shared/claude-pasted-content' import { claudeInterruptedMessageId } from './transcript-turn-markers' const MAX_EDIT_PATCH_HUNKS = 40 @@ -124,7 +125,7 @@ export function decodeClaudeTranscriptLine( return { id: messageId ?? fallbackId, role: claudeMessageRole(role, blocks), - blocks, + blocks: role === 'user' ? blocks.map(unwrapClaudePastedContentBlock) : blocks, timestamp, source: 'transcript' } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 60735d705e27..d9c31ab662f2 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -61,6 +61,7 @@ const NativeChatComposerPane = forwardRef void } +export type NativeChatDeliveryNotice = { + text: string + onRetry?: () => void + onDismiss?: () => void +} /** One message: its prose first, then a collapsible run folding all of the * turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a @@ -180,6 +184,11 @@ export const MessageRow = memo(function MessageRow({ {deliveryNotice ? (
{deliveryNotice.text} + {deliveryNotice.onDismiss ? ( + + ) : null} {deliveryNotice.onRetry ? (
@@ -436,7 +402,8 @@ export function NativeChatResolvedView({ isWorking={isWorking} onStop={stopAgent} onOptimisticSend={onOptimisticSend} - onOptimisticSendCanceled={onOptimisticSendCanceled} + onOptimisticSendCanceled={delivery.cancel} + onOptimisticSendRejected={delivery.reject} onSlashCommand={onSlashCommand} onSwitchToTerminal={onSwitchToTerminal} readTerminalScreen={readTerminalScreen} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 516c7e0a8bb5..74cbb6c778e3 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -54,6 +54,7 @@ export type NativeChatComposerProps = { onStop?: () => void /** Render an optimistic echo until the real transcript turn lands. */ onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined + onOptimisticSendRejected?: (pendingId: string) => void /** Remove an optimistic echo when its delayed submit is canceled. */ onOptimisticSendCanceled?: (pendingId: string) => void /** Record a dispatched slash command that does not create a chat turn. */ diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts new file mode 100644 index 000000000000..eb56f1ec0c1e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +const io = vi.hoisted(() => ({ write: vi.fn(), verified: vi.fn() })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + sendRuntimePtyInput: io.write, + sendRuntimePtyInputVerified: io.verified +})) +import { + sendNativeChatMessage, + resetNativeChatPtySendQueuesForTests +} from './native-chat-runtime-send' +import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +beforeEach(() => { + vi.useFakeTimers() + resetNativeChatPtySendQueuesForTests() + io.write.mockReset().mockReturnValue(true) + io.verified.mockReset().mockResolvedValue(true) +}) +afterEach(() => { + resetNativeChatPtySendQueuesForTests() + vi.useRealTimers() +}) +it('observes a refused write, skips Enter, and releases the queue for the next user action', async () => { + const rejected = vi.fn() + io.verified.mockResolvedValueOnce(false) + sendNativeChatMessage(null, 'pane', 'refused', { onWriteRejected: rejected }) + await vi.advanceTimersByTimeAsync(1000) + expect(rejected).toHaveBeenCalledOnce() + expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + buildNativeChatPasteBytes('refused') + ]) + sendNativeChatMessage(null, 'pane', 'next', { onWriteRejected: rejected }) + await vi.advanceTimersByTimeAsync(1000) + expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + buildNativeChatPasteBytes('refused'), + buildNativeChatPasteBytes('next'), + NATIVE_CHAT_SUBMIT + ]) +}) +it('does not report a lost acknowledgment as rejection or resend it', async () => { + const rejected = vi.fn() + io.verified.mockRejectedValueOnce(new Error('lost acknowledgment')) + sendNativeChatMessage(null, 'pane', 'uncertain', { onWriteRejected: rejected }) + await vi.advanceTimersByTimeAsync(120000) + expect(rejected).not.toHaveBeenCalled() + expect(io.verified).toHaveBeenCalledOnce() +}) +it('serializes rapid sends through their acknowledged Enter and preserves the paste delay', async () => { + const rejected = vi.fn() + sendNativeChatMessage(null, 'pane', 'one', { onWriteRejected: rejected }) + sendNativeChatMessage(null, 'pane', 'two', { onWriteRejected: rejected }) + await vi.advanceTimersByTimeAsync(499) + expect(io.verified).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(501) + expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + buildNativeChatPasteBytes('one'), + NATIVE_CHAT_SUBMIT, + buildNativeChatPasteBytes('two'), + NATIVE_CHAT_SUBMIT + ]) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.ts new file mode 100644 index 000000000000..e837d9866681 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.ts @@ -0,0 +1,61 @@ +import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue' +import { + clearThenWrite, + clearUnsubmittedAgentInput, + type NativeChatSendOptions +} from './native-chat-runtime-send' + +/** Observe write refusals without mistaking transport success for provider acceptance. */ +export function sendNativeChatObservedWrites( + settings: ReturnType, + ptyId: string, + writes: readonly { data: string; delayBeforeMs: number }[], + options: NativeChatSendOptions +) { + return enqueueNativeChatPtySend( + ptyId, + writes.reduce((total, write) => total + write.delayBeforeMs, 0), + ({ isCancelled, delay, markSubmitted }) => { + const writeAt = (index: number): void => { + if (isCancelled()) { + return + } + const write = writes[index] + if (!write) { + markSubmitted() + return + } + const send = (): void => { + if (isCancelled()) { + return + } + void sendRuntimePtyInputVerified(settings, ptyId, write.data, 'driving') + .then((accepted) => { + if (isCancelled()) { + return + } + if (!accepted) { + options.onWriteRejected?.() + markSubmitted() + return + } + writeAt(index + 1) + }) + .catch(() => { + // An acknowledgment may have been lost; do not retry the bytes or claim rejection. + markSubmitted() + }) + } + if (write.delayBeforeMs > 0) { + delay(write.delayBeforeMs, send) + } else { + send() + } + } + clearThenWrite(settings, ptyId, options, delay, () => writeAt(0)) + }, + { onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) } + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 406fee3b5795..b62026fe14a7 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -1,3 +1,4 @@ +import type { NativeChatDeliveryOrigin } from '../../../../shared/native-chat-pending-delivery' // Pure logic for desktop optimistic "queued" composer sends (mobile parity). // A sent prompt is echoed immediately as a queued entry and pruned once its real // user turn lands in the transcript. Kept separate from the view so the prune @@ -25,6 +26,8 @@ import { export type NativeChatPendingSend = { /** Renderer-minted id, unique per send, used as the list key. */ id: string + deliveryOrigin?: NativeChatDeliveryOrigin + delivery?: 'unconfirmed' | 'rejected' | 'confirmed' /** The exact draft text the user submitted. */ text: string /** Image paths that were sent through the TUI image attachment paste path. */ diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index aba65acf8db0..1a5a410291fd 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -1,3 +1,4 @@ +import { sendNativeChatObservedWrites } from './native-chat-observed-send' import { agentImagePasteWrites, formatAgentImagePath } from '../../../../shared/agent-image-paste' import type { AgentType } from '../../../../shared/agent-status-types' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' @@ -34,6 +35,21 @@ export function sendNativeChatMessageWithImageAttachments( return sendNativeChatMessage(settings, ptyId, text, options) } const trimmedText = text.trim() + if (options?.onWriteRejected) { + const writes = agentImagePasteWrites( + agent, + imagePaths.map((path) => buildNativeChatImagePasteBytes(formatAgentImagePath(agent, path))), + trimmedText.length > 0 + ).map((data) => ({ data, delayBeforeMs: 0 })) + if (trimmedText) { + writes.push({ + data: buildNativeChatPasteBytes(text), + delayBeforeMs: NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + }) + } + writes.push({ data: NATIVE_CHAT_SUBMIT, delayBeforeMs: NATIVE_CHAT_SUBMIT_DELAY_MS }) + return sendNativeChatObservedWrites(settings, ptyId, writes, options) + } const durationMs = (trimmedText.length > 0 ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 1eb7a676103e..463d43ecc5f2 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -1,3 +1,4 @@ +import { sendNativeChatObservedWrites } from './native-chat-observed-send' // Runtime send for native chat: clear any unsubmitted TUI line, write the framed // body, then Enter as a SEPARATE delayed pty write. Kept apart from the pure // byte builders in native-chat-send.ts so those stay IO-free and unit-testable. @@ -44,6 +45,7 @@ export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15' export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140 export type NativeChatSendOptions = { + onWriteRejected?: () => void /** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */ clearInput?: string /** @@ -134,6 +136,17 @@ export function sendNativeChatMessage( text: string, options?: NativeChatSendOptions ): NativeChatSendHandle { + if (options?.onWriteRejected) { + return sendNativeChatObservedWrites( + settings, + ptyId, + [ + { data: buildNativeChatPasteBytes(text), delayBeforeMs: 0 }, + { data: NATIVE_CHAT_SUBMIT, delayBeforeMs: NATIVE_CHAT_SUBMIT_DELAY_MS } + ], + options + ) + } return enqueueNativeChatPtySend( ptyId, NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options), diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx new file mode 100644 index 000000000000..ed4a0a4479c1 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment happy-dom +import { act, renderHook, cleanup } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery' +import { clearPendingSendCacheForTests } from './native-chat-pending' +const mocks = vi.hoisted(() => { + const state: { status: AgentStatusEntry | undefined; read: ReturnType } = { + status: undefined, + read: vi.fn() + } + return state +}) +vi.mock('../../store', () => ({ + useAppStore: ( + select: (s: { agentStatusByPaneKey: Record }) => unknown + ) => select({ agentStatusByPaneKey: { pane: mocks.status } }) +})) +vi.mock('./native-chat-session-transport', () => ({ + getNativeChatSessionTransport: () => ({ readSession: mocks.read }) +})) +const boundary: NativeChatMessage = { + id: 'boundary', + role: 'assistant', + blocks: [{ type: 'text', text: 'before' }], + timestamp: 1, + source: 'transcript' +} +const args = { + paneKey: 'pane', + agent: 'claude' as const, + sessionId: 'session', + runtimeEnvironmentId: null, + messages: [boundary] +} +function status(state: AgentStatusEntry['state'], epoch: number): AgentStatusEntry { + return { + state, + stateStartedAt: epoch, + updatedAt: epoch, + paneKey: 'pane', + prompt: '', + stateHistory: [] + } +} +async function tick(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} +beforeEach(() => { + vi.useFakeTimers() + clearPendingSendCacheForTests() + mocks.read.mockReset() + mocks.read.mockResolvedValue({ messages: [boundary] }) + mocks.status = status('working', 1) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('desktop and paired-web pending delivery', () => { + it('waits through a long queued turn, checks a fresh read after idle, and keeps text recoverable', async () => { + const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) + act(() => result.current.record('follow up')) + await tick(120_000) + expect(mocks.read).not.toHaveBeenCalled() + expect(result.current.notices.size).toBe(0) + mocks.status = status('done', 2) + rerender() + await tick(0) + expect(mocks.read).toHaveBeenCalledOnce() + expect(result.current.pending[0]?.text).toBe('follow up') + expect([...result.current.notices.values()][0]?.text).toMatch(/Delivery unconfirmed/) + act(() => [...result.current.notices.values()][0]?.onDismiss?.()) + expect(result.current.pending).toEqual([]) + }) + it('uses the fresh read when the status arrives before the transcript stream', async () => { + const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) + act(() => result.current.record('one\ntwo')) + mocks.read.mockResolvedValue({ + messages: [ + boundary, + { + ...boundary, + id: 'user', + role: 'user', + blocks: [ + { type: 'text', text: '\none\ntwo\n' } + ] + } + ] + }) + mocks.status = status('done', 2) + rerender() + await tick(0) + expect(result.current.pending[0]?.delivery).toBe('confirmed') + expect(result.current.notices.size).toBe(0) + }) + it('bounds the no-status case across a remount and retires a late echo', async () => { + mocks.status = undefined + const first = renderHook(() => useNativeChatPendingDelivery(args)) + act(() => first.result.current.record('later')) + await tick(10_000) + first.unmount() + const next = renderHook(({ messages }) => useNativeChatPendingDelivery({ ...args, messages }), { + initialProps: { messages: [boundary] } + }) + await tick(10_000) + expect(next.result.current.pending[0]?.delivery).toBe('unconfirmed') + next.rerender({ + messages: [ + boundary, + { ...boundary, id: 'user', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, + { ...boundary, id: 'answer' } + ] + }) + expect(next.result.current.pending).toEqual([]) + }) + it('keeps rejected writes distinct and does not use an unreadable transcript as absence', async () => { + const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) + act(() => { + const id = result.current.record('refused') + result.current.reject(id) + }) + expect([...result.current.notices.values()][0]?.text).toBe('Message not sent') + act(() => result.current.record('uncertain')) + mocks.read.mockResolvedValue({ error: 'disconnected' }) + mocks.status = status('done', 2) + rerender() + await tick(0) + expect(result.current.pending[1]?.delivery).toBeUndefined() + }) + it('does not let an old source read settle sends after the pane changes', async () => { + let resolve: ((result: { messages: NativeChatMessage[] }) => void) | undefined + mocks.read.mockImplementation( + () => + new Promise((done) => { + resolve = done + }) + ) + const { result, rerender } = renderHook( + ({ paneKey }) => useNativeChatPendingDelivery({ ...args, paneKey }), + { initialProps: { paneKey: 'pane' } } + ) + act(() => result.current.record('old')) + mocks.status = status('done', 2) + rerender({ paneKey: 'pane' }) + await tick(0) + rerender({ paneKey: 'other' }) + await act(async () => resolve?.({ messages: [boundary] })) + expect(result.current.pending).toEqual([]) + }) +}) + +it('adopts newly available working status instead of spending the no-status deadline', async () => { + mocks.status = undefined + const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) + act(() => result.current.record('queued')) + mocks.status = status('working', 10) + rerender() + await tick(120_000) + expect(result.current.notices.size).toBe(0) + mocks.status = status('done', 11) + rerender() + await tick(0) + expect(result.current.pending[0]?.delivery).toBe('unconfirmed') +}) +it('bounds a missing-status send even when its confirmation read rejects', async () => { + mocks.status = undefined + mocks.read.mockRejectedValue(new Error('offline')) + const { result } = renderHook(() => useNativeChatPendingDelivery(args)) + act(() => result.current.record('recover me')) + await tick(20_000) + expect(result.current.pending[0]?.delivery).toBe('unconfirmed') + expect(result.current.pending[0]?.text).toBe('recover me') +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts new file mode 100644 index 000000000000..5b8ba9233fad --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useAppStore } from '../../store' +import type { AgentType, NativeChatMessage } from '../../../../shared/native-chat-types' +import { + captureNativeChatDeliveryOrigin, + observeNativeChatDeliveryOrigin, + nativeChatDeliveryCheckDelay, + NATIVE_CHAT_REJECTED_COPY, + NATIVE_CHAT_UNCONFIRMED_COPY +} from '../../../../shared/native-chat-pending-delivery' +import { + appendPendingSendCache, + nextNativeChatPendingSendId, + pendingSendsAsMessages, + prunePendingSends, + readPendingSendCache, + writePendingSendCache, + type NativeChatPendingSend +} from './native-chat-pending' +import { getNativeChatSessionTransport } from './native-chat-session-transport' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' + +/** Pending presentation follows the existing status store; a fresh read checks each completed turn. */ +export function useNativeChatPendingDelivery(args: { + paneKey: string + agent: AgentType + sessionId: string | null + transcriptPath?: string | null + runtimeEnvironmentId: string | null + messages: NativeChatMessage[] +}) { + const { paneKey, agent, sessionId, transcriptPath, runtimeEnvironmentId, messages } = args + const scope = useMemo(() => ({ paneKey, agent }), [paneKey, agent]) + const status = useAppStore((s) => s.agentStatusByPaneKey[paneKey]) + const [pending, setPending] = useState(() => readPendingSendCache(scope)) + useEffect(() => setPending(readPendingSendCache(scope)), [scope]) + const save = useCallback( + (update: (entries: NativeChatPendingSend[]) => NativeChatPendingSend[]) => { + setPending(writePendingSendCache(scope, update(readPendingSendCache(scope)))) + }, + [scope] + ) + useEffect(() => { + save((entries) => prunePendingSends(entries, messages)) + }, [messages, save]) + useEffect(() => { + save((entries) => + entries.map((entry) => { + if ( + !entry.deliveryOrigin || + entry.delivery === 'rejected' || + entry.delivery === 'confirmed' + ) { + return entry + } + const origin = observeNativeChatDeliveryOrigin(entry.deliveryOrigin, status) + return origin === entry.deliveryOrigin + ? entry + : { ...entry, deliveryOrigin: origin, delivery: undefined } + }) + ) + }, [status, save]) + const record = useCallback( + (text: string, imagePaths?: string[]) => { + const sentAt = Date.now() + const boundary = messages.at(-1) + const entry: NativeChatPendingSend = { + id: nextNativeChatPendingSendId(sentAt), + text, + sentAt, + afterMessageId: boundary?.id ?? null, + afterMessageTimestamp: boundary?.timestamp ?? null, + ...(imagePaths ? { imagePaths } : {}), + ...(agent === 'claude' + ? { deliveryOrigin: captureNativeChatDeliveryOrigin(status, sentAt) } + : {}) + } + setPending(appendPendingSendCache(scope, entry)) + return entry.id + }, + [messages, agent, status, scope] + ) + const cancel = useCallback( + (id: string) => save((entries) => entries.filter((entry) => entry.id !== id)), + [save] + ) + const reject = useCallback( + (id: string) => + save((entries) => + entries.map((entry) => (entry.id === id ? { ...entry, delivery: 'rejected' } : entry)) + ), + [save] + ) + const clear = useCallback(() => save(() => []), [save]) + + useEffect(() => { + if (agent !== 'claude') { + return + } + const candidates = pending.filter((entry) => entry.deliveryOrigin && !entry.delivery) + const checks = candidates.flatMap((entry) => { + const delay = entry.deliveryOrigin + ? nativeChatDeliveryCheckDelay(entry.deliveryOrigin, status) + : null + return delay === null ? [] : [{ entry, delay }] + }) + if (checks.length === 0) { + return + } + let cancelled = false + const timer = setTimeout( + () => { + void (async () => { + const due = checks + .filter( + ({ entry }) => + entry.deliveryOrigin && + nativeChatDeliveryCheckDelay(entry.deliveryOrigin, status) === 0 + ) + .map(({ entry }) => entry) + if (due.length === 0) { + return + } + const result = sessionId + ? await getNativeChatSessionTransport(runtimeEnvironmentId) + .readSession(agent, sessionId, 500, transcriptPath ?? undefined) + .catch(() => null) + : null + if (cancelled) { + return + } + // A failed or not-yet-created transcript cannot prove absence after an idle fact. + if (status && !status.restoredUnconfirmed && (!result || !('messages' in result))) { + return + } + const history = result && 'messages' in result ? result.messages : messages + const unmatched = new Set( + pendingSendsAsMessages(due, history).map((message) => message.id) + ) + const dueIds = new Set(due.map((entry) => entry.id)) + save((entries) => + entries.flatMap((entry) => { + if (!dueIds.has(entry.id) || entry.delivery === 'rejected') { + return [entry] + } + return unmatched.has(`pending:${entry.id}`) + ? [{ ...entry, delivery: 'unconfirmed' as const }] + : [{ ...entry, delivery: 'confirmed' as const }] + }) + ) + })().catch(() => { + /* A failed read is not evidence of non-delivery. */ + }) + }, + Math.min(...checks.map(({ delay }) => delay)) + ) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [agent, pending, status, sessionId, transcriptPath, runtimeEnvironmentId, messages, save]) + + const notices = useMemo(() => { + const result = new Map() + for (const entry of pending) { + if (!entry.delivery || entry.delivery === 'confirmed') { + continue + } + result.set(`pending:${entry.id}`, { + text: + entry.delivery === 'rejected' ? NATIVE_CHAT_REJECTED_COPY : NATIVE_CHAT_UNCONFIRMED_COPY, + onDismiss: () => cancel(entry.id) + }) + } + return result + }, [pending, cancel]) + return { pending, record, cancel, reject, clear, notices } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts index 1cc912f4250f..701c3db48729 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts @@ -31,6 +31,7 @@ export function useNativeChatPtyComposerSend(args: { resolveTarget: () => NativeChatResolvedTarget | null classifySend: NativeChatPickerState['classifySend'] onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined + onOptimisticSendRejected?: (pendingId: string) => void onSlashCommand?: (command: string) => void sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null terminalTabId: string @@ -57,12 +58,24 @@ export function useNativeChatPtyComposerSend(args: { return } const classification = args.classifySend(text) - const { sendOptions } = resolveNativeChatLaunchDraftSend({ + const { sendOptions: launchSendOptions } = resolveNativeChatLaunchDraftSend({ launchDraft: args.launchDraft, launchDraftResolved: args.launchDraftResolved, agent: args.agent, readScreen: () => args.readTerminalScreen?.() }) + let pendingId: string | undefined + const sendOptions = + args.agent === 'claude' && classification === 'chat' + ? { + ...launchSendOptions, + onWriteRejected: () => { + if (pendingId) { + args.onOptimisticSendRejected?.(pendingId) + } + } + } + : launchSendOptions let pendingHandle: NativeChatSendHandle | null = null // Why: slash-like text must not silently drop its attached images. if (classification !== 'chat' && imagePaths.length === 0) { @@ -93,7 +106,7 @@ export function useNativeChatPtyComposerSend(args: { args.sessionOptionsSurface?.recordOutgoingCommand(text.trim()) } } else { - const pendingId = args.onOptimisticSend?.(text, imagePaths) + pendingId = args.onOptimisticSend?.(text, imagePaths) if (pendingHandle) { args.trackPendingSend(pendingHandle, pendingId) } diff --git a/src/shared/claude-pasted-content.ts b/src/shared/claude-pasted-content.ts new file mode 100644 index 000000000000..eef925b2ac9b --- /dev/null +++ b/src/shared/claude-pasted-content.ts @@ -0,0 +1,31 @@ +import type { NativeChatBlock } from './native-chat-types' + +// Claude wraps a whole pasted text block; prose around it is still literal user text. +export function unwrapClaudePastedContent(text: string): string { + const trimmed = text.trim() + const open = /^\r\n]{1,64})")?>\r?\n/.exec(trimmed) + if (!open) { + return text + } + const close = open[1] ? `
` : '
' + if (!trimmed.endsWith(close)) { + return text + } + const body = trimmed.slice(open[0].length, -close.length) + if ( + !body.endsWith('\n') || + body.includes(' block.text) + .map((block) => unwrapClaudePastedContent(block.text)) .join(' ') ) return normalized || null diff --git a/src/shared/native-chat-pending-delivery.test.ts b/src/shared/native-chat-pending-delivery.test.ts new file mode 100644 index 000000000000..6074a683c8a9 --- /dev/null +++ b/src/shared/native-chat-pending-delivery.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + captureNativeChatDeliveryOrigin, + nativeChatDeliveryCheckDelay, + observeNativeChatDeliveryOrigin, + type NativeChatDeliveryStatus +} from './native-chat-pending-delivery' +const working: NativeChatDeliveryStatus = { + state: 'working', + stateStartedAt: 1, + providerSession: { key: 'session_id', id: 'session' } +} + +describe('terminal Chat confirmation evidence', () => { + it('never times out a known working, waiting, or unchanged idle status', () => { + for (const state of ['working', 'waiting', 'done'] as const) { + const status = { ...working, state } + const origin = captureNativeChatDeliveryOrigin(status, 9_000_000) + expect(nativeChatDeliveryCheckDelay(origin, status, 99_000_000)).toBeNull() + } + }) + it('uses a later host idle boundary regardless of client clock skew', () => { + const origin = captureNativeChatDeliveryOrigin(working, 9_000_000) + expect( + nativeChatDeliveryCheckDelay( + origin, + { ...working, state: 'done', stateStartedAt: 2 }, + 9_000_001 + ) + ).toBe(0) + }) + it('does not spend a replaced session, startup boundary, hydrated status, or disconnect as completion', () => { + const origin = captureNativeChatDeliveryOrigin(working, 0) + const idle = { ...working, state: 'done' as const, stateStartedAt: 2 } + for (const status of [ + undefined, + { ...idle, sessionBoundary: true }, + { ...idle, restoredUnconfirmed: true }, + { ...idle, providerSession: { key: 'session_id' as const, id: 'other' } } + ]) { + expect(nativeChatDeliveryCheckDelay(origin, status, 99_000_000)).toBeNull() + } + }) + it('bounds only sends without status facts, without resetting the deadline on remount', () => { + const origin = captureNativeChatDeliveryOrigin(undefined, 1_000) + expect(nativeChatDeliveryCheckDelay(origin, undefined, 5_000)).toBe(16_000) + expect(nativeChatDeliveryCheckDelay(origin, undefined, 22_000)).toBe(0) + expect(nativeChatDeliveryCheckDelay(origin, working, 22_000)).toBeNull() + }) +}) + +it('adopts a working fact first observed after sending, then uses its later idle fact', () => { + const unknown = captureNativeChatDeliveryOrigin(undefined, 1_000) + const known = observeNativeChatDeliveryOrigin(unknown, working) + expect(known.sentAt).toBe(1_000) + expect(nativeChatDeliveryCheckDelay(known, working, 120_000)).toBeNull() + expect( + nativeChatDeliveryCheckDelay(known, { ...working, state: 'done', stateStartedAt: 2 }, 120_001) + ).toBe(0) +}) diff --git a/src/shared/native-chat-pending-delivery.ts b/src/shared/native-chat-pending-delivery.ts new file mode 100644 index 000000000000..1b0d22af074e --- /dev/null +++ b/src/shared/native-chat-pending-delivery.ts @@ -0,0 +1,76 @@ +import type { AgentStatusEntry } from './agent-status-types' + +export const NATIVE_CHAT_UNCONFIRMED_COPY = 'Delivery unconfirmed — check chat before retrying' +export const NATIVE_CHAT_REJECTED_COPY = 'Message not sent' +export const NATIVE_CHAT_NO_STATUS_DEADLINE_MS = 20_000 + +export type NativeChatDeliveryStatus = Pick< + AgentStatusEntry, + | 'state' + | 'stateStartedAt' + | 'mainAgent' + | 'restoredUnconfirmed' + | 'sessionBoundary' + | 'providerSession' +> +export type NativeChatDeliveryOrigin = { + sentAt: number + /** A host-clock boundary, never compared with the client's send clock. */ + statusEpoch: number | null + providerSessionId: string | null +} + +export function captureNativeChatDeliveryOrigin( + status: NativeChatDeliveryStatus | null | undefined, + sentAt = Date.now() +): NativeChatDeliveryOrigin { + return { + sentAt, + statusEpoch: + status && !status.restoredUnconfirmed + ? (status.mainAgent?.stateStartedAt ?? status.stateStartedAt) + : null, + providerSessionId: status?.providerSession?.id ?? null + } +} + +/** Delay until a confirmation read is justified, or null while the provider can still queue it. */ +export function nativeChatDeliveryCheckDelay( + origin: NativeChatDeliveryOrigin, + status: NativeChatDeliveryStatus | null | undefined, + now = Date.now() +): number | null { + if (!status || status.restoredUnconfirmed) { + // Losing an existing status is not evidence that the agent stopped. + return origin.statusEpoch === null + ? Math.max(0, origin.sentAt + NATIVE_CHAT_NO_STATUS_DEADLINE_MS - now) + : null + } + if (origin.providerSessionId && status.providerSession?.id !== origin.providerSessionId) { + return null + } + if (status.state === 'working' || status.state === 'waiting' || status.state === 'blocked') { + return null + } + const state = status.mainAgent?.state ?? status.state + const epoch = status.mainAgent?.stateStartedAt ?? status.stateStartedAt + return state === 'done' && + !status.sessionBoundary && + origin.statusEpoch !== null && + epoch > origin.statusEpoch + ? 0 + : null +} + +/** A send made before hooks attached can adopt the first live working boundary it observes. */ +export function observeNativeChatDeliveryOrigin( + origin: NativeChatDeliveryOrigin, + status: NativeChatDeliveryStatus | null | undefined +): NativeChatDeliveryOrigin { + return origin.statusEpoch === null && + status && + !status.restoredUnconfirmed && + status.state === 'working' + ? captureNativeChatDeliveryOrigin(status, origin.sentAt) + : origin +} From 90fd2e9e41aeabd80c5fbfc5cb9bc490991d456d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:44:18 -0700 Subject: [PATCH 02/13] fix(native-chat): break the observed-send import cycle and keep renderer tests out of main The observed-send path imported the clear helpers from native-chat-runtime-send, which imports it back. Move the input-clear layer into its own module both use. The Claude paste decoder test imported the renderer pending module from src/main, which the node typecheck project cannot see; the echo-retirement assertions now live in a renderer test. --- ...ine-decoders-claude-pasted-content.test.ts | 21 ----- .../native-chat/native-chat-input-clear.ts | 86 ++++++++++++++++++ .../native-chat/native-chat-observed-send.ts | 6 +- ...native-chat-pending-pasted-content.test.ts | 29 +++++++ .../native-chat-runtime-image-send.ts | 5 +- ...ive-chat-runtime-send-launch-draft.test.ts | 6 +- .../native-chat-runtime-send.test.ts | 4 +- .../native-chat/native-chat-runtime-send.ts | 87 ++----------------- 8 files changed, 133 insertions(+), 111 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-input-clear.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts diff --git a/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts index a98f5cb3ab41..8eaece36fdb4 100644 --- a/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts +++ b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vitest' import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' import { normalizeNativeChatUserText } from '../../shared/native-chat-image-transcript-markers' -import { - pendingSendsAsMessages, - prunePendingSends -} from '../../renderer/src/components/native-chat/native-chat-pending' -import type { NativeChatMessage } from '../../shared/native-chat-types' const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.' const wrapped = `\n\n\n${prompt}\n\n` @@ -19,9 +14,6 @@ function decode(text: string, role = 'user', array = false) { 'fallback' )! } -function message(id: string, role: NativeChatMessage['role'], text: string): NativeChatMessage { - return { id, role, source: 'transcript', timestamp: null, blocks: [{ type: 'text', text }] } -} describe('Claude whole-block paste envelope', () => { it.each([false, true])('decodes host user content (array=%s)', (array) => { @@ -44,19 +36,6 @@ describe('Claude whole-block paste envelope', () => { it('leaves assistant text untouched', () => { expect(decode(wrapped, 'assistant').blocks).toEqual([{ type: 'text', text: wrapped }]) }) - it.each(['new host', 'old host'])('retires desktop echoes with %s rows', (host) => { - const history = [ - message('boundary', 'assistant', 'earlier'), - host === 'new host' ? decode(wrapped) : message('user', 'user', wrapped), - message('reply', 'assistant', 'answer') - ] - const pending = [{ id: 'p1', text: prompt, sentAt: 999_000, afterMessageId: 'boundary' }] - expect(pendingSendsAsMessages(pending, history)).toEqual([]) - expect(prunePendingSends(pending, history)).toEqual([]) - expect( - prunePendingSends([...pending, { ...pending[0]!, id: 'p2', matchingOccurrence: 2 }], history) - ).toHaveLength(1) - }) it('handles a large prompt in linear passes', () => { const text = 'line\n'.repeat(50_000) expect( diff --git a/src/renderer/src/components/native-chat/native-chat-input-clear.ts b/src/renderer/src/components/native-chat/native-chat-input-clear.ts new file mode 100644 index 000000000000..462b336c564b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-input-clear.ts @@ -0,0 +1,86 @@ +// Clearing the agent's unsubmitted input line before a chat send writes its body. +import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear' + +// Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends +// start from an empty line so a prior cancelled paste cannot glue onto the next +// prompt. Not used on verified option commands — model-switch confirmation +// observes the PTY and Ctrl+U can miss confirmation markers. +// +// One Ctrl+U only ever clears ONE logical line. When the line may hold an +// injected multi-line launch draft, callers pass `clearInput` built by +// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured +// 2N-1 law and the sequences that do NOT work. +export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15' + +/** Gap before re-reading the agent's input line to confirm a clear landed. */ +export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140 + +export type NativeChatSendOptions = { + onWriteRejected?: () => void + /** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */ + clearInput?: string + /** + * Observed check that the input line is now empty. + * Supplied only for launch-draft replacement; when it reports "not cleared" + * the send widens to a maximal burst before writing the body rather than + * pasting on top of residue. + */ + confirmCleared?: () => boolean +} + +type RuntimeSettings = ReturnType + +export function clearUnsubmittedAgentInput( + settings: RuntimeSettings, + ptyId: string, + options?: NativeChatSendOptions +): void { + sendRuntimePtyInput( + settings, + ptyId, + options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + 'driving' + ) +} + +/** + * Run `writeBody` once the input line is clear. With no `confirmCleared` the + * clear is a plain in-order write on the same byte stream, so the TUI consumes + * it before the body and the body follows immediately. With one, we pause to + * actually look at the agent's input line, and widen to a maximal burst when the + * draft is still visible — the injected line count is only a lower bound on what + * the buffer holds, since the user can type into the TUI directly. + */ +export function clearThenWrite( + settings: RuntimeSettings, + ptyId: string, + options: NativeChatSendOptions | undefined, + delay: (ms: number, fn: () => void) => void, + writeBody: () => void +): void { + clearUnsubmittedAgentInput(settings, ptyId, options) + const confirmCleared = options?.confirmCleared + if (!confirmCleared) { + writeBody() + return + } + delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => { + let cleared = false + try { + cleared = confirmCleared() + } catch { + // An unreadable terminal is unconfirmed; the maximal clear remains safe. + } + if (!cleared) { + sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving') + } + writeBody() + }) +} + +/** Extra time a send needs when it stops to confirm the clear before the body. */ +export function clearConfirmDurationMs(options?: NativeChatSendOptions): number { + return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0 +} diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.ts index e837d9866681..8bca9988fcf2 100644 --- a/src/renderer/src/components/native-chat/native-chat-observed-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.ts @@ -2,10 +2,11 @@ import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspecti import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue' import { + clearConfirmDurationMs, clearThenWrite, clearUnsubmittedAgentInput, type NativeChatSendOptions -} from './native-chat-runtime-send' +} from './native-chat-input-clear' /** Observe write refusals without mistaking transport success for provider acceptance. */ export function sendNativeChatObservedWrites( @@ -16,7 +17,8 @@ export function sendNativeChatObservedWrites( ) { return enqueueNativeChatPtySend( ptyId, - writes.reduce((total, write) => total + write.delayBeforeMs, 0), + writes.reduce((total, write) => total + write.delayBeforeMs, 0) + + clearConfirmDurationMs(options), ({ isCancelled, delay, markSubmitted }) => { const writeAt = (index: number): void => { if (isCancelled()) { diff --git a/src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts new file mode 100644 index 000000000000..a1b1bbe5f5dc --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { pendingSendsAsMessages, prunePendingSends } from './native-chat-pending' + +const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.' +const wrapped = `\n\n\n${prompt}\n\n` +function message(id: string, role: NativeChatMessage['role'], text: string): NativeChatMessage { + return { id, role, source: 'transcript', timestamp: null, blocks: [{ type: 'text', text }] } +} + +describe('pending echoes against Claude pasted-content rows', () => { + // A new host delivers the decoded prompt; an old host still delivers the raw envelope. + it.each([ + ['new host', prompt], + ['old host', wrapped] + ])('retires the echo with %s rows and keeps a second identical send', (_host, row) => { + const history = [ + message('boundary', 'assistant', 'earlier'), + message('user', 'user', row), + message('reply', 'assistant', 'answer') + ] + const pending = [{ id: 'p1', text: prompt, sentAt: 999_000, afterMessageId: 'boundary' }] + expect(pendingSendsAsMessages(pending, history)).toEqual([]) + expect(prunePendingSends(pending, history)).toEqual([]) + expect( + prunePendingSends([...pending, { ...pending[0]!, id: 'p2', matchingOccurrence: 2 }], history) + ).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index 1a5a410291fd..283a437e449a 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -14,10 +14,9 @@ import { clearConfirmDurationMs, clearThenWrite, clearUnsubmittedAgentInput, - sendNativeChatMessage, - type NativeChatSendHandle, type NativeChatSendOptions -} from './native-chat-runtime-send' +} from './native-chat-input-clear' +import { sendNativeChatMessage, type NativeChatSendHandle } from './native-chat-runtime-send' export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts index 1016b466ccb0..a10bb87cd3b6 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -11,12 +11,14 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ })) import { - NATIVE_CHAT_CLEAR_CONFIRM_MS, - NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, NATIVE_CHAT_SUBMIT_DELAY_MS, resetNativeChatPtySendQueuesForTests, sendNativeChatMessage } from './native-chat-runtime-send' +import { + NATIVE_CHAT_CLEAR_CONFIRM_MS, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT +} from './native-chat-input-clear' import { NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, sendNativeChatMessageWithImageAttachments diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 0071b73b21d7..f17d2f34263d 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -19,9 +19,9 @@ import { resetNativeChatPtySendQueuesForTests, NATIVE_CHAT_SUBMIT_DELAY_MS, NATIVE_CHAT_QUESTION_STEP_MS, - NATIVE_CHAT_ADVANCE_BUFFER_MS, - NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT + NATIVE_CHAT_ADVANCE_BUFFER_MS } from './native-chat-runtime-send' +import { NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT } from './native-chat-input-clear' import { NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, sendNativeChatMessageWithImageAttachments diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 463d43ecc5f2..c0710ad15a10 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -9,7 +9,12 @@ import { } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' -import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear' +import { + clearConfirmDurationMs, + clearThenWrite, + clearUnsubmittedAgentInput, + type NativeChatSendOptions +} from './native-chat-input-clear' import { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, @@ -30,33 +35,6 @@ import { export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } export { resetNativeChatPtySendQueuesForTests } -// Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends -// start from an empty line so a prior cancelled paste cannot glue onto the next -// prompt. Not used on verified option commands — model-switch confirmation -// observes the PTY and Ctrl+U can miss confirmation markers. -// -// One Ctrl+U only ever clears ONE logical line. When the line may hold an -// injected multi-line launch draft, callers pass `clearInput` built by -// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured -// 2N-1 law and the sequences that do NOT work. -export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15' - -/** Gap before re-reading the agent's input line to confirm a clear landed. */ -export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140 - -export type NativeChatSendOptions = { - onWriteRejected?: () => void - /** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */ - clearInput?: string - /** - * Observed check that the input line is now empty. - * Supplied only for launch-draft replacement; when it reports "not cleared" - * the send widens to a maximal burst before writing the body rather than - * pasting on top of residue. - */ - confirmCleared?: () => boolean -} - /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ export type NativeChatSendHandle = { @@ -69,59 +47,6 @@ export type NativeChatSendHandle = { type RuntimeSettings = ReturnType -export function clearUnsubmittedAgentInput( - settings: RuntimeSettings, - ptyId: string, - options?: NativeChatSendOptions -): void { - sendRuntimePtyInput( - settings, - ptyId, - options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - 'driving' - ) -} - -/** - * Run `writeBody` once the input line is clear. With no `confirmCleared` the - * clear is a plain in-order write on the same byte stream, so the TUI consumes - * it before the body and the body follows immediately. With one, we pause to - * actually look at the agent's input line, and widen to a maximal burst when the - * draft is still visible — the injected line count is only a lower bound on what - * the buffer holds, since the user can type into the TUI directly. - */ -export function clearThenWrite( - settings: RuntimeSettings, - ptyId: string, - options: NativeChatSendOptions | undefined, - delay: (ms: number, fn: () => void) => void, - writeBody: () => void -): void { - clearUnsubmittedAgentInput(settings, ptyId, options) - const confirmCleared = options?.confirmCleared - if (!confirmCleared) { - writeBody() - return - } - delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => { - let cleared = false - try { - cleared = confirmCleared() - } catch { - // An unreadable terminal is unconfirmed; the maximal clear remains safe. - } - if (!cleared) { - sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving') - } - writeBody() - }) -} - -/** Extra time a send needs when it stops to confirm the clear before the body. */ -export function clearConfirmDurationMs(options?: NativeChatSendOptions): number { - return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0 -} - /** * Chat message path: * 1. clear any unsubmitted TUI line From cca3716a7549bd613e11eeceac843f5ecb66d7ce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:44:21 -0700 Subject: [PATCH 03/13] fix(native-chat): still submit a Claude chat send whose write acknowledgment was lost A remote write whose acknowledgment is lost (timeout, dropped link) is not a refusal, but the observed path stopped there and never sent Enter, leaving a body that did land sitting unsubmitted in Claude's input line until the next send's clear wiped it. Continue to the next write without re-sending the bytes, as the unobserved path always did. --- .../native-chat/native-chat-observed-send.test.ts | 7 +++++-- .../components/native-chat/native-chat-observed-send.ts | 7 +++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts index eb56f1ec0c1e..ceb8906f06cd 100644 --- a/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts @@ -36,13 +36,16 @@ it('observes a refused write, skips Enter, and releases the queue for the next u NATIVE_CHAT_SUBMIT ]) }) -it('does not report a lost acknowledgment as rejection or resend it', async () => { +it('does not report a lost acknowledgment as rejection or resend it, but still submits', async () => { const rejected = vi.fn() io.verified.mockRejectedValueOnce(new Error('lost acknowledgment')) sendNativeChatMessage(null, 'pane', 'uncertain', { onWriteRejected: rejected }) await vi.advanceTimersByTimeAsync(120000) expect(rejected).not.toHaveBeenCalled() - expect(io.verified).toHaveBeenCalledOnce() + expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + buildNativeChatPasteBytes('uncertain'), + NATIVE_CHAT_SUBMIT + ]) }) it('serializes rapid sends through their acknowledged Enter and preserves the paste delay', async () => { const rejected = vi.fn() diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.ts index 8bca9988fcf2..0c01a1dedb65 100644 --- a/src/renderer/src/components/native-chat/native-chat-observed-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.ts @@ -45,10 +45,9 @@ export function sendNativeChatObservedWrites( } writeAt(index + 1) }) - .catch(() => { - // An acknowledgment may have been lost; do not retry the bytes or claim rejection. - markSubmitted() - }) + // A lost acknowledgment is not a refusal: never re-send these bytes, but still submit + // a body that may have landed, as the unobserved path does. + .catch(() => writeAt(index + 1)) } if (write.delayBeforeMs > 0) { delay(write.delayBeforeMs, send) From b868e92c51039a2f61ad1d9d015a002b699ebf6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:01:05 -0700 Subject: [PATCH 04/13] perf(native-chat): keep terminal Chat pending delivery from re-rendering every row The delivery notices were merged into a new Map on every render, which invalidated the transcript row context and re-rendered every memoized row on each stream update. The pending hook also wrote a fresh array on every status ping and prune pass even when nothing changed, and the phone mapped its pending list on every render, rebuilding the chat list data. Memoize the merged notices, skip no-op pending writes, and memoize the phone's rendered pending list. The phone also skips a transcript read when no send is due. --- .../session/use-mobile-native-chat-drafts.ts | 29 +++++++++++++++---- ...use-mobile-native-chat-pending-delivery.ts | 3 ++ .../native-chat/NativeChatResolvedView.tsx | 10 ++++++- .../use-native-chat-pending-delivery.test.tsx | 12 ++++++++ .../use-native-chat-pending-delivery.ts | 19 +++++++++--- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 63b84965af78..a2a800226fc8 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -4,7 +4,15 @@ import { useMobileNativeChatPendingDeliveryState, type MobileNativeChatDeliveryTracking } from './use-mobile-native-chat-pending-delivery' -import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type SetStateAction +} from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countUserTextOccurrences, @@ -279,15 +287,24 @@ export function useMobileNativeChatDrafts(args: { }) }, [messages, pending, pendingKey, transcriptSettled]) + // Why memoized: the view keys its list data and renderItem on this array's identity. + const renderedPending = useMemo( + () => + pending.some((entry) => entry.delivery) + ? pending.map((entry) => + entry.delivery === 'unconfirmed' + ? { ...entry, onDismiss: () => settleDelivery(new Set([entry.id]), new Set(), true) } + : { ...entry, delivery: undefined } + ) + : pending, + [pending, settleDelivery] + ) + return { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, getComposerEditGeneration: draftEditGenerationsRef.current.readComposer, - pending: pending.map((entry) => - entry.delivery === 'unconfirmed' - ? { ...entry, onDismiss: () => settleDelivery(new Set([entry.id]), new Set(), true) } - : { ...entry, delivery: undefined } - ), + pending: renderedPending, imagePreviewsByMessageId: pendingKey ? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS) : NO_IMAGE_PREVIEWS, diff --git a/mobile/src/session/use-mobile-native-chat-pending-delivery.ts b/mobile/src/session/use-mobile-native-chat-pending-delivery.ts index 0188cfcd803c..9fa8426cfa90 100644 --- a/mobile/src/session/use-mobile-native-chat-pending-delivery.ts +++ b/mobile/src/session/use-mobile-native-chat-pending-delivery.ts @@ -44,6 +44,9 @@ export function useMobileNativeChatPendingDelivery( nativeChatDeliveryCheckDelay(entry.deliveryOrigin, tracking.status) === 0 ) .map(({ entry }) => entry) + if (due.length === 0) { + return + } const messages = await tracking.readTranscript().catch(() => null) if (cancelled || (tracking.status && !tracking.status.restoredUnconfirmed && !messages)) { return diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index df77aa8074be..f6421217a97b 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -217,6 +217,14 @@ export function NativeChatResolvedView({ paneLaunchPrompt?.failed ? launchPromptMessage?.id : null, sessionAfterCommandBoundaries.messages ) + // Why memoized: a fresh map each render would re-render every memoized transcript row. + const deliveryNotices = useMemo( + () => + delivery.notices.size === 0 + ? launchPromptDeliveryNotices + : new Map([...(launchPromptDeliveryNotices ?? []), ...delivery.notices]), + [launchPromptDeliveryNotices, delivery.notices] + ) // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. @@ -372,7 +380,7 @@ export function NativeChatResolvedView({ showTurnStatus={false} onLinkClick={onLinkClick} allowFileUriLinks={fileLinkContext !== null} - deliveryNotices={new Map([...(launchPromptDeliveryNotices ?? []), ...delivery.notices])} + deliveryNotices={deliveryNotices} /> )} diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx index ed4a0a4479c1..c1ca512facf4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx @@ -177,3 +177,15 @@ it('bounds a missing-status send even when its confirmation read rejects', async expect(result.current.pending[0]?.delivery).toBe('unconfirmed') expect(result.current.pending[0]?.text).toBe('recover me') }) +it('keeps pending and notices referentially stable across status and stream updates', () => { + const { result, rerender } = renderHook( + ({ messages }) => useNativeChatPendingDelivery({ ...args, messages }), + { initialProps: { messages: [boundary] } } + ) + act(() => result.current.record('stable')) + const { pending, notices } = result.current + mocks.status = status('working', 5) + rerender({ messages: [boundary, { ...boundary, id: 'streamed' }] }) + expect(result.current.pending).toBe(pending) + expect(result.current.notices).toBe(notices) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts index 5b8ba9233fad..bed5339d953d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts @@ -20,6 +20,8 @@ import { import { getNativeChatSessionTransport } from './native-chat-session-transport' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' +const NO_NOTICES: ReadonlyMap = new Map() + /** Pending presentation follows the existing status store; a fresh read checks each completed turn. */ export function useNativeChatPendingDelivery(args: { paneKey: string @@ -36,7 +38,12 @@ export function useNativeChatPendingDelivery(args: { useEffect(() => setPending(readPendingSendCache(scope)), [scope]) const save = useCallback( (update: (entries: NativeChatPendingSend[]) => NativeChatPendingSend[]) => { - setPending(writePendingSendCache(scope, update(readPendingSendCache(scope)))) + const current = readPendingSendCache(scope) + const next = update(current) + // Why: prune and status observation run on every stream/status update; a no-op must not re-render. + if (next !== current) { + setPending(writePendingSendCache(scope, next)) + } }, [scope] ) @@ -44,8 +51,8 @@ export function useNativeChatPendingDelivery(args: { save((entries) => prunePendingSends(entries, messages)) }, [messages, save]) useEffect(() => { - save((entries) => - entries.map((entry) => { + save((entries) => { + const next = entries.map((entry) => { if ( !entry.deliveryOrigin || entry.delivery === 'rejected' || @@ -58,7 +65,8 @@ export function useNativeChatPendingDelivery(args: { ? entry : { ...entry, deliveryOrigin: origin, delivery: undefined } }) - ) + return next.every((entry, index) => entry === entries[index]) ? entries : next + }) }, [status, save]) const record = useCallback( (text: string, imagePaths?: string[]) => { @@ -161,6 +169,9 @@ export function useNativeChatPendingDelivery(args: { }, [agent, pending, status, sessionId, transcriptPath, runtimeEnvironmentId, messages, save]) const notices = useMemo(() => { + if (!pending.some((entry) => entry.delivery && entry.delivery !== 'confirmed')) { + return NO_NOTICES + } const result = new Map() for (const entry of pending) { if (!entry.delivery || entry.delivery === 'confirmed') { From 058d4134d029deca221ef2a7d013443597a4dc91 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:01:33 -0700 Subject: [PATCH 05/13] fix(native-chat): never flag a queued Claude send, and flag one an idle Claude never starts Two gaps in when terminal Chat calls a Claude send "Delivery unconfirmed": A prompt sent while Claude is mid-turn is queued, and Claude folds it into the running turn as a queued-command record. The transcript reader drops those records, so once the turn ended the prompt Claude did run read as unconfirmed, inviting a duplicate resend. A send made while the agent is busy is now never checked; it keeps the pending behaviour it had before. A prompt sent to an idle Claude that never starts a turn (Claude exited to the shell, or the paste went nowhere) left the status at the same idle fact forever, so the check never ran and the bubble stayed pending. An idle agent starts a turn on a delivered prompt at once, so a send whose idle status is unchanged after the existing 20 s bound is now checked against a fresh transcript read. --- ...bile-native-chat-pending-delivery.test.tsx | 25 ++++++- .../use-native-chat-pending-delivery.test.tsx | 33 +++++++- .../native-chat-pending-delivery.test.ts | 45 ++++++----- src/shared/native-chat-pending-delivery.ts | 75 ++++++++++++------- 4 files changed, 128 insertions(+), 50 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx b/mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx index 4f02b1e964ef..b27818d1c4e8 100644 --- a/mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx @@ -50,6 +50,7 @@ const boundary: NativeChatMessage = { } const working: NativeChatDeliveryStatus = { state: 'working', stateStartedAt: 1 } const idle: NativeChatDeliveryStatus = { state: 'done', stateStartedAt: 2 } +const idleBefore: NativeChatDeliveryStatus = { state: 'done', stateStartedAt: 0 } const base = { hostId: 'host', worktreeId: 'folder', @@ -74,7 +75,7 @@ async function tick(ms: number) { }) } -it('keeps a long queued send pending, then reads on idle and provides a dismissible notice', async () => { +it('keeps a send made during a busy turn pending, with no check, after that turn ends', async () => { const { result, rerender } = renderHook( ({ status }) => useMobileNativeChatDrafts({ ...base, deliveryTracking: { status, readTranscript } }), @@ -84,9 +85,28 @@ it('keeps a long queued send pending, then reads on idle and provides a dismissi const origin = result.current.captureSendOrigin('follow up')! result.current.acceptSend(origin, 'follow up') }) + rerender({ status: idle }) await tick(120_000) expect(readTranscript).not.toHaveBeenCalled() expect(result.current.pending[0]?.delivery).toBeUndefined() +}) + +it('checks a send into an idle agent after its turn ends and provides a dismissible notice', async () => { + const { result, rerender } = renderHook( + ({ status }) => + useMobileNativeChatDrafts({ ...base, deliveryTracking: { status, readTranscript } }), + { initialProps: { status: idleBefore } } + ) + act(() => { + const origin = result.current.captureSendOrigin('follow up')! + result.current.acceptSend(origin, 'follow up') + }) + rerender({ status: working }) + await tick(120_000) + expect(readTranscript).not.toHaveBeenCalled() + const pendingBefore = result.current.pending + rerender({ status: working }) + expect(result.current.pending).toBe(pendingBefore) rerender({ status: idle }) await tick(0) expect(readTranscript).toHaveBeenCalledOnce() @@ -104,7 +124,7 @@ it('uses the same lifecycle for a lost write acknowledgment and permits a late t messages, deliveryTracking: { status, readTranscript } }), - { initialProps: { status: working, messages: [boundary] } } + { initialProps: { status: idleBefore, messages: [boundary] } } ) const report = vi.fn() act(() => @@ -114,6 +134,7 @@ it('uses the same lifecycle for a lost write acknowledgment and permits a late t report ) ) + rerender({ status: working, messages: [boundary] }) await tick(120_000) expect(report).not.toHaveBeenCalled() expect(result.current.pending).toHaveLength(1) diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx index c1ca512facf4..9e22ab60153f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx @@ -62,13 +62,27 @@ afterEach(() => { }) describe('desktop and paired-web pending delivery', () => { - it('waits through a long queued turn, checks a fresh read after idle, and keeps text recoverable', async () => { + it('keeps a send made during a busy turn pending, with no check, after that turn ends', async () => { + // Claude folds such a send into the running turn as a record the transcript reader drops. const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) act(() => result.current.record('follow up')) await tick(120_000) + mocks.status = status('done', 2) + rerender() + await tick(120_000) expect(mocks.read).not.toHaveBeenCalled() expect(result.current.notices.size).toBe(0) - mocks.status = status('done', 2) + expect(result.current.pending[0]?.delivery).toBeUndefined() + }) + it('checks a send into an idle agent after its turn ends and keeps the text recoverable', async () => { + mocks.status = status('done', 1) + const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) + act(() => result.current.record('follow up')) + mocks.status = status('working', 2) + rerender() + await tick(120_000) + expect(mocks.read).not.toHaveBeenCalled() + mocks.status = status('done', 3) rerender() await tick(0) expect(mocks.read).toHaveBeenCalledOnce() @@ -77,7 +91,18 @@ describe('desktop and paired-web pending delivery', () => { act(() => [...result.current.notices.values()][0]?.onDismiss?.()) expect(result.current.pending).toEqual([]) }) + it('flags a send an idle agent never starts a turn for, such as Claude having exited', async () => { + mocks.status = status('done', 1) + const { result } = renderHook(() => useNativeChatPendingDelivery(args)) + act(() => result.current.record('lost')) + await tick(19_000) + expect(mocks.read).not.toHaveBeenCalled() + await tick(1_000) + expect(mocks.read).toHaveBeenCalledOnce() + expect(result.current.pending[0]?.delivery).toBe('unconfirmed') + }) it('uses the fresh read when the status arrives before the transcript stream', async () => { + mocks.status = status('done', 1) const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) act(() => result.current.record('one\ntwo')) mocks.read.mockResolvedValue({ @@ -126,6 +151,8 @@ describe('desktop and paired-web pending delivery', () => { result.current.reject(id) }) expect([...result.current.notices.values()][0]?.text).toBe('Message not sent') + mocks.status = status('done', 1) + rerender() act(() => result.current.record('uncertain')) mocks.read.mockResolvedValue({ error: 'disconnected' }) mocks.status = status('done', 2) @@ -145,6 +172,8 @@ describe('desktop and paired-web pending delivery', () => { ({ paneKey }) => useNativeChatPendingDelivery({ ...args, paneKey }), { initialProps: { paneKey: 'pane' } } ) + mocks.status = status('done', 1) + rerender({ paneKey: 'pane' }) act(() => result.current.record('old')) mocks.status = status('done', 2) rerender({ paneKey: 'pane' }) diff --git a/src/shared/native-chat-pending-delivery.test.ts b/src/shared/native-chat-pending-delivery.test.ts index 6074a683c8a9..605d64b2cee0 100644 --- a/src/shared/native-chat-pending-delivery.test.ts +++ b/src/shared/native-chat-pending-delivery.test.ts @@ -10,33 +10,39 @@ const working: NativeChatDeliveryStatus = { stateStartedAt: 1, providerSession: { key: 'session_id', id: 'session' } } +const idle: NativeChatDeliveryStatus = { ...working, state: 'done', stateStartedAt: 1 } describe('terminal Chat confirmation evidence', () => { - it('never times out a known working, waiting, or unchanged idle status', () => { - for (const state of ['working', 'waiting', 'done'] as const) { - const status = { ...working, state } - const origin = captureNativeChatDeliveryOrigin(status, 9_000_000) - expect(nativeChatDeliveryCheckDelay(origin, status, 99_000_000)).toBeNull() + it('never checks a send made into a busy turn, even after that turn ends', () => { + for (const state of ['working', 'waiting', 'blocked'] as const) { + const origin = captureNativeChatDeliveryOrigin({ ...working, state }, 9_000_000) + expect(nativeChatDeliveryCheckDelay(origin, { ...working, state }, 99_000_000)).toBeNull() + expect( + nativeChatDeliveryCheckDelay(origin, { ...idle, stateStartedAt: 2 }, 99_000_000) + ).toBeNull() } }) - it('uses a later host idle boundary regardless of client clock skew', () => { - const origin = captureNativeChatDeliveryOrigin(working, 9_000_000) + it('checks a send into an idle agent once a later turn ends, regardless of client clock skew', () => { + const origin = captureNativeChatDeliveryOrigin(idle, 9_000_000) expect( - nativeChatDeliveryCheckDelay( - origin, - { ...working, state: 'done', stateStartedAt: 2 }, - 9_000_001 - ) - ).toBe(0) + nativeChatDeliveryCheckDelay(origin, { ...working, stateStartedAt: 2 }, 99_000_000) + ).toBe(null) + expect(nativeChatDeliveryCheckDelay(origin, { ...idle, stateStartedAt: 3 }, 9_000_001)).toBe(0) + }) + it('bounds a send an idle agent never starts a turn for', () => { + const origin = captureNativeChatDeliveryOrigin(idle, 1_000) + expect(nativeChatDeliveryCheckDelay(origin, idle, 5_000)).toBe(16_000) + expect(nativeChatDeliveryCheckDelay(origin, idle, 22_000)).toBe(0) + expect(nativeChatDeliveryCheckDelay(origin, { ...idle, sessionBoundary: true }, 22_000)).toBe(0) }) - it('does not spend a replaced session, startup boundary, hydrated status, or disconnect as completion', () => { - const origin = captureNativeChatDeliveryOrigin(working, 0) - const idle = { ...working, state: 'done' as const, stateStartedAt: 2 } + it('does not spend a replaced session, later boundary, hydrated status, or disconnect as completion', () => { + const origin = captureNativeChatDeliveryOrigin(idle, 0) + const later = { ...idle, stateStartedAt: 2 } for (const status of [ undefined, - { ...idle, sessionBoundary: true }, - { ...idle, restoredUnconfirmed: true }, - { ...idle, providerSession: { key: 'session_id' as const, id: 'other' } } + { ...later, sessionBoundary: true }, + { ...later, restoredUnconfirmed: true }, + { ...later, providerSession: { key: 'session_id' as const, id: 'other' } } ]) { expect(nativeChatDeliveryCheckDelay(origin, status, 99_000_000)).toBeNull() } @@ -53,6 +59,7 @@ it('adopts a working fact first observed after sending, then uses its later idle const unknown = captureNativeChatDeliveryOrigin(undefined, 1_000) const known = observeNativeChatDeliveryOrigin(unknown, working) expect(known.sentAt).toBe(1_000) + expect(known.stateAtSend).toBeNull() expect(nativeChatDeliveryCheckDelay(known, working, 120_000)).toBeNull() expect( nativeChatDeliveryCheckDelay(known, { ...working, state: 'done', stateStartedAt: 2 }, 120_001) diff --git a/src/shared/native-chat-pending-delivery.ts b/src/shared/native-chat-pending-delivery.ts index 1b0d22af074e..f8ba5ccdfa11 100644 --- a/src/shared/native-chat-pending-delivery.ts +++ b/src/shared/native-chat-pending-delivery.ts @@ -1,8 +1,9 @@ -import type { AgentStatusEntry } from './agent-status-types' +import type { AgentStatusEntry, AgentStatusState } from './agent-status-types' export const NATIVE_CHAT_UNCONFIRMED_COPY = 'Delivery unconfirmed — check chat before retrying' export const NATIVE_CHAT_REJECTED_COPY = 'Message not sent' -export const NATIVE_CHAT_NO_STATUS_DEADLINE_MS = 20_000 +/** How long a send may go without starting a turn before its absence is checked. */ +export const NATIVE_CHAT_UNSTARTED_SEND_DEADLINE_MS = 20_000 export type NativeChatDeliveryStatus = Pick< AgentStatusEntry, @@ -18,48 +19,69 @@ export type NativeChatDeliveryOrigin = { /** A host-clock boundary, never compared with the client's send clock. */ statusEpoch: number | null providerSessionId: string | null + /** Live state when the send was written; null when no live status was known. */ + stateAtSend: AgentStatusState | null +} + +function liveStatus( + status: NativeChatDeliveryStatus | null | undefined +): NativeChatDeliveryStatus | null { + return status && !status.restoredUnconfirmed ? status : null +} + +function statusEpoch(status: NativeChatDeliveryStatus): number { + return status.mainAgent?.stateStartedAt ?? status.stateStartedAt } export function captureNativeChatDeliveryOrigin( status: NativeChatDeliveryStatus | null | undefined, sentAt = Date.now() ): NativeChatDeliveryOrigin { + const live = liveStatus(status) return { sentAt, - statusEpoch: - status && !status.restoredUnconfirmed - ? (status.mainAgent?.stateStartedAt ?? status.stateStartedAt) - : null, - providerSessionId: status?.providerSession?.id ?? null + statusEpoch: live ? statusEpoch(live) : null, + providerSessionId: status?.providerSession?.id ?? null, + stateAtSend: live?.state ?? null } } -/** Delay until a confirmation read is justified, or null while the provider can still queue it. */ +function unstartedSendDelay(origin: NativeChatDeliveryOrigin, now: number): number { + return Math.max(0, origin.sentAt + NATIVE_CHAT_UNSTARTED_SEND_DEADLINE_MS - now) +} + +/** Delay until a confirmation read is justified, or null while absence would prove nothing. */ export function nativeChatDeliveryCheckDelay( origin: NativeChatDeliveryOrigin, status: NativeChatDeliveryStatus | null | undefined, now = Date.now() ): number | null { - if (!status || status.restoredUnconfirmed) { + // Why: Claude folds a prompt sent during a busy turn into that turn as a queued-command + // record, which the transcript reader drops, so its absence after the turn proves nothing. + if (origin.stateAtSend !== null && origin.stateAtSend !== 'done') { + return null + } + const live = liveStatus(status) + if (!live) { // Losing an existing status is not evidence that the agent stopped. - return origin.statusEpoch === null - ? Math.max(0, origin.sentAt + NATIVE_CHAT_NO_STATUS_DEADLINE_MS - now) - : null + return origin.statusEpoch === null ? unstartedSendDelay(origin, now) : null } - if (origin.providerSessionId && status.providerSession?.id !== origin.providerSessionId) { + if (origin.providerSessionId && live.providerSession?.id !== origin.providerSessionId) { return null } - if (status.state === 'working' || status.state === 'waiting' || status.state === 'blocked') { + if (live.state === 'working' || live.state === 'waiting' || live.state === 'blocked') { return null } - const state = status.mainAgent?.state ?? status.state - const epoch = status.mainAgent?.stateStartedAt ?? status.stateStartedAt - return state === 'done' && - !status.sessionBoundary && - origin.statusEpoch !== null && - epoch > origin.statusEpoch - ? 0 - : null + const state = live.mainAgent?.state ?? live.state + const epoch = statusEpoch(live) + if (state !== 'done' || origin.statusEpoch === null) { + return null + } + if (epoch === origin.statusEpoch) { + // An idle agent starts a turn on a delivered prompt at once; staying idle means it has not. + return unstartedSendDelay(origin, now) + } + return epoch > origin.statusEpoch && !live.sessionBoundary ? 0 : null } /** A send made before hooks attached can adopt the first live working boundary it observes. */ @@ -67,10 +89,9 @@ export function observeNativeChatDeliveryOrigin( origin: NativeChatDeliveryOrigin, status: NativeChatDeliveryStatus | null | undefined ): NativeChatDeliveryOrigin { - return origin.statusEpoch === null && - status && - !status.restoredUnconfirmed && - status.state === 'working' - ? captureNativeChatDeliveryOrigin(status, origin.sentAt) + const live = liveStatus(status) + return origin.statusEpoch === null && live?.state === 'working' + ? // Keep the unknown send-time state: this turn may be the one the send started. + { ...captureNativeChatDeliveryOrigin(live, origin.sentAt), stateAtSend: origin.stateAtSend } : origin } From e8cab72d9fbd637722c22563f4b25819b736e59c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:02:28 -0700 Subject: [PATCH 06/13] fix(native-chat): add the delivery notice strings to the English catalog The Dismiss action's translate key was missing from en.json, which fails the localization catalog and extraction gates. The desktop "Message not sent" and "Delivery unconfirmed" notices were hard-coded English; route them through translate with the same wording. --- .../native-chat/use-native-chat-pending-delivery.ts | 12 ++++++++---- src/renderer/src/i18n/locales/en.json | 5 ++++- src/shared/native-chat-pending-delivery.ts | 1 - 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts index bed5339d953d..7e7c79bf61f3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts @@ -1,12 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useAppStore } from '../../store' +import { translate } from '@/i18n/i18n' import type { AgentType, NativeChatMessage } from '../../../../shared/native-chat-types' import { captureNativeChatDeliveryOrigin, observeNativeChatDeliveryOrigin, - nativeChatDeliveryCheckDelay, - NATIVE_CHAT_REJECTED_COPY, - NATIVE_CHAT_UNCONFIRMED_COPY + nativeChatDeliveryCheckDelay } from '../../../../shared/native-chat-pending-delivery' import { appendPendingSendCache, @@ -179,7 +178,12 @@ export function useNativeChatPendingDelivery(args: { } result.set(`pending:${entry.id}`, { text: - entry.delivery === 'rejected' ? NATIVE_CHAT_REJECTED_COPY : NATIVE_CHAT_UNCONFIRMED_COPY, + entry.delivery === 'rejected' + ? translate('components.native-chat.messageNotSent', 'Message not sent') + : translate( + 'components.native-chat.deliveryUnconfirmed', + 'Delivery unconfirmed — check chat before retrying' + ), onDismiss: () => cancel(entry.id) }) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cbbba795d593..4e12f2a6a98a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17876,7 +17876,10 @@ "label": "Context {{used}} of {{window}} tokens, {{percent}}% used", "title": "Context", "estimated": "Estimated from the last response." - } + }, + "dismissDeliveryNotice": "Dismiss", + "messageNotSent": "Message not sent", + "deliveryUnconfirmed": "Delivery unconfirmed — check chat before retrying" }, "tab": { "bar": { diff --git a/src/shared/native-chat-pending-delivery.ts b/src/shared/native-chat-pending-delivery.ts index f8ba5ccdfa11..ca075f26bf8e 100644 --- a/src/shared/native-chat-pending-delivery.ts +++ b/src/shared/native-chat-pending-delivery.ts @@ -1,7 +1,6 @@ import type { AgentStatusEntry, AgentStatusState } from './agent-status-types' export const NATIVE_CHAT_UNCONFIRMED_COPY = 'Delivery unconfirmed — check chat before retrying' -export const NATIVE_CHAT_REJECTED_COPY = 'Message not sent' /** How long a send may go without starting a turn before its absence is checked. */ export const NATIVE_CHAT_UNSTARTED_SEND_DEADLINE_MS = 20_000 From 367f4ecc593cb04d8c9323e7b99b068d6fdb73ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:06:33 -0700 Subject: [PATCH 07/13] fix(mobile): sync the held-send refs after commit instead of during render Moving the acknowledgment-loss hold into its own hook made its render-time ref writes new lines, which the React Doctor changed-lines gate blocks. Held sends report after commit, so syncing those refs in a layout effect keeps them current where they are read. --- .../use-mobile-native-chat-unconfirmed-send.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts b/mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts index 7df10c20d896..4b620cc967b7 100644 --- a/mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts +++ b/mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { findLandedUnconfirmedSends, @@ -17,11 +17,14 @@ export function useMobileNativeChatUnconfirmedSend( ) { const mountedRef = useRef(false) const activeDraftKeyRef = useRef(draftKey) - activeDraftKeyRef.current = draftKey const activePendingKeyRef = useRef(pendingKey) - activePendingKeyRef.current = pendingKey const messagesRef = useRef(messages) - messagesRef.current = messages + // Why an effect: a held send reports after commit, so refs synced here are current then. + useLayoutEffect(() => { + activeDraftKeyRef.current = draftKey + activePendingKeyRef.current = pendingKey + messagesRef.current = messages + }) // Why: a relay drop mid-send loses only the ack in the common case — the // desktop already delivered the message. Hold the send instead of claiming // failure (which baits a duplicate): stay quiet when the transcript echo From eaee929b96c101118897b4996b229e8968563006 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:19:22 -0700 Subject: [PATCH 08/13] fix(native-chat): report only definite terminal Chat send outcomes The delivery rule inferred "Delivery unconfirmed" from "the turn ended and the transcript has no matching row". Claude records a prompt sent mid-turn only as a queued-command attachment, which the transcript reader drops, so that rule flagged prompts Claude had answered. It also never fired for an idle Claude that lost the write, because no newer turn arrives. Keep only facts the transport reports: - a refused write reads "Message not sent", keeps its text, and can be dismissed; - a lost write acknowledgment holds the echo for 20 s, the phone's existing rule, then reads "Delivery unconfirmed" unless its row has landed. An ordinary send, including one Claude queues mid-turn, stays pending as before. Remove the agent-status subscription, the status-epoch origin, the fresh 500-row transcript read, the confirmed state and the no-status clock. The phone already implements this rule, so its changes revert to main; only a test for old-host paste envelopes remains. --- .../src/session/MobileNativeChatMessage.tsx | 13 +- mobile/src/session/MobileNativeChatView.tsx | 3 +- .../mobile-native-chat-delivery-read.ts | 53 ---- .../mobile-native-chat-pending-echo.ts | 6 - ...native-chat-pending-pasted-content.test.ts | 37 +++ .../session/mobile-native-chat-render-data.ts | 2 - .../use-mobile-native-chat-controller.ts | 11 +- .../session/use-mobile-native-chat-drafts.ts | 133 ++++++---- ...bile-native-chat-pending-delivery.test.tsx | 191 -------------- ...use-mobile-native-chat-pending-delivery.ts | 143 ----------- ...use-mobile-native-chat-unconfirmed-send.ts | 102 -------- .../native-chat/NativeChatComposer.tsx | 2 + .../native-chat/NativeChatResolvedView.tsx | 12 +- .../native-chat/native-chat-composer-types.ts | 3 + .../native-chat/native-chat-input-clear.ts | 3 + .../native-chat-observed-send.test.ts | 10 +- .../native-chat/native-chat-observed-send.ts | 9 +- .../native-chat/native-chat-pending.ts | 7 +- .../use-native-chat-pending-delivery.test.tsx | 238 +++++------------- .../use-native-chat-pending-delivery.ts | 146 ++++------- .../use-native-chat-pty-composer-send.ts | 6 + .../native-chat-pending-delivery.test.ts | 67 ----- src/shared/native-chat-pending-delivery.ts | 96 ------- 23 files changed, 275 insertions(+), 1018 deletions(-) delete mode 100644 mobile/src/session/mobile-native-chat-delivery-read.ts create mode 100644 mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts delete mode 100644 mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx delete mode 100644 mobile/src/session/use-mobile-native-chat-pending-delivery.ts delete mode 100644 mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts delete mode 100644 src/shared/native-chat-pending-delivery.test.ts delete mode 100644 src/shared/native-chat-pending-delivery.ts diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index fd55da733786..08feb9bc7ceb 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -1,7 +1,6 @@ -import { NATIVE_CHAT_UNCONFIRMED_COPY } from '../../../src/shared/native-chat-pending-delivery' import { MobileSelectableText as Text } from '../components/MobileSelectableText' import { memo } from 'react' -import { Image, Pressable, Text as NativeText, View } from 'react-native' +import { Image, Text as NativeText, View } from 'react-native' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' @@ -70,7 +69,6 @@ function Prose({ function MobileNativeChatMessageImpl({ message, - deliveryNotice, toolsExpanded = false, fontScale = 1, onOpenFile, @@ -83,7 +81,6 @@ function MobileNativeChatMessageImpl({ subagentLabel }: { message: NativeChatMessage - deliveryNotice?: { onDismiss?: () => void } toolsExpanded?: boolean /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number @@ -133,14 +130,6 @@ function MobileNativeChatMessageImpl({ return ( <> - {isUser && deliveryNotice ? ( - - {NATIVE_CHAT_UNCONFIRMED_COPY} - - Dismiss - - - ) : null} ( entry.id === item.id && entry.delivery)} toolsExpanded={toolsExpanded} fontScale={fontScale} onOpenFile={onOpenFile} @@ -291,7 +290,7 @@ export function MobileNativeChatView({ {...turns.resolveRow(index, item)} /> ), - [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, subagentLabels, turns, pending] + [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, subagentLabels, turns] ) const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) diff --git a/mobile/src/session/mobile-native-chat-delivery-read.ts b/mobile/src/session/mobile-native-chat-delivery-read.ts deleted file mode 100644 index 7097b6d85e74..000000000000 --- a/mobile/src/session/mobile-native-chat-delivery-read.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback, useMemo } from 'react' -import type { NativeChatDeliveryStatus } from '../../../src/shared/native-chat-pending-delivery' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import type { RpcClient } from '../transport/rpc-client' -import { nativeChatSessionPageRead } from './mobile-session-read-operations' - -export async function readMobileNativeChatDeliveryTranscript( - client: RpcClient | null, - sessionId: string | null, - transcriptPath: string | null -): Promise { - if (!client || !sessionId) { - return null - } - const response = await nativeChatSessionPageRead.request(client, { - agent: 'claude', - sessionId, - limit: 500, - ...(transcriptPath ? { transcriptPath } : {}) - }) - const result = nativeChatSessionPageRead.interpret(response) - if (!result.accepted) { - return null - } - const value = result.value - if ( - !value || - typeof value !== 'object' || - !('messages' in value) || - !Array.isArray(value.messages) - ) { - return null - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the nativeChat reader returns the host's message array, as on the existing history-page path. - return value.messages as NativeChatMessage[] -} - -export function useMobileNativeChatDeliveryTracking( - client: RpcClient | null, - sessionId: string | null, - transcriptPath: string | null, - enabled: boolean, - status: NativeChatDeliveryStatus | null | undefined -) { - const readTranscript = useCallback( - () => readMobileNativeChatDeliveryTranscript(client, sessionId, transcriptPath), - [client, sessionId, transcriptPath] - ) - return useMemo( - () => (enabled ? { status, readTranscript } : undefined), - [enabled, status, readTranscript] - ) -} diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 7874dfa97e12..91bf778ba7c3 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -1,11 +1,7 @@ -import type { NativeChatDeliveryOrigin } from '../../../src/shared/native-chat-pending-delivery' import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile' export type MobileNativeChatPendingMessage = { id: string - deliveryOrigin?: NativeChatDeliveryOrigin - delivery?: 'unconfirmed' | 'confirmed' - onDismiss?: () => void text: string expectedOccurrence: number /** Local preview URIs carried by the send for its optimistic echo. */ @@ -19,7 +15,6 @@ export type MobileNativeChatPendingMessage = { } export type MobileNativeChatSendOrigin = { - deliveryOrigin?: NativeChatDeliveryOrigin draftKey: string draftEditGeneration: number pendingKey: string | null @@ -69,7 +64,6 @@ export function appendMobileNativeChatPending( { id, text, - ...(origin.deliveryOrigin ? { deliveryOrigin: origin.deliveryOrigin } : {}), expectedOccurrence: origin.normalizedText === '' ? expectedImageEchoOrdinal diff --git a/mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts b/mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts new file mode 100644 index 000000000000..3ea7171c6bb0 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' + +const boundary: NativeChatMessage = { + id: 'boundary', + role: 'assistant', + blocks: [{ type: 'text', text: 'before' }], + timestamp: null, + source: 'transcript' +} + +// An old host still sends Claude's raw paste envelope; the shared normalizer must match it. +it('retires an echo against an old-host wrapped row and keeps the second identical send', () => { + const text = 'one\ntwo' + const echo: NativeChatMessage = { + ...boundary, + id: 'user', + role: 'user', + blocks: [{ type: 'text', text: `\n${text}\n` }] + } + const reply: NativeChatMessage = { ...boundary, id: 'reply' } + const entry = { + id: 'one', + text, + expectedOccurrence: 1, + baselineTailMessageId: 'boundary', + baselineResolved: true + } + expect( + retireLandedMobileNativeChatPending( + [boundary, echo, reply], + [entry, { ...entry, id: 'two', expectedOccurrence: 2 }], + new Set() + ).map((item) => item.id) + ).toEqual(['two']) +}) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 46bb8032c575..da93efadbb87 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -42,8 +42,6 @@ export function mobileNativeChatEmptyState( /** An optimistic user echo: the text and/or the local preview URIs of any images * ridden along on the send, shown until the transcript catches up. */ export type MobileNativeChatPendingItem = { - delivery?: 'unconfirmed' | 'confirmed' - onDismiss?: () => void id: string text: string images?: string[] diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index c68b804816cd..dfefcf476279 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,4 +1,3 @@ -import { useMobileNativeChatDeliveryTracking } from './mobile-native-chat-delivery-read' import { useLayoutEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -98,13 +97,6 @@ export function useMobileNativeChatController(args: { hostSupport: agentSessionHostSupport, onSendError }) - const deliveryTracking = useMobileNativeChatDeliveryTracking( - client, - activeChatSessionId, - activeChatResolution?.transcriptPath ?? null, - !activeChatStructured && activeChatAgent === 'claude', - nativeChatStatus - ) const { composerText: chatComposerText, setComposerText: setChatComposerText, @@ -131,8 +123,7 @@ export function useMobileNativeChatController(args: { // terminal view would permanently decline the prefill. chatActive: showNativeChat, transcriptLoading: nativeChatSession.transcriptLoading, - transcriptSettled: nativeChatSession.status === 'ready', - deliveryTracking + transcriptSettled: nativeChatSession.status === 'ready' }) // Deliberately not gated on the chat view being visible: the streaming gate diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a2a800226fc8..a50ccf4c9235 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -1,25 +1,13 @@ -import { useMobileNativeChatUnconfirmedSend } from './use-mobile-native-chat-unconfirmed-send' -import { captureNativeChatDeliveryOrigin } from '../../../src/shared/native-chat-pending-delivery' -import { - useMobileNativeChatPendingDeliveryState, - type MobileNativeChatDeliveryTracking -} from './use-mobile-native-chat-pending-delivery' -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type Dispatch, - type SetStateAction -} from 'react' +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countUserTextOccurrences, findLandedImagePreviewEchoes, + findLandedUnconfirmedSends, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, - normalizeReconcileText + normalizeReconcileText, + type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' @@ -41,6 +29,9 @@ export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] const NO_IMAGE_PREVIEWS: Record = {} +// Ack-lost sends wait for a transcript echo before surfacing as unconfirmed. +const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 + export function useMobileNativeChatDrafts(args: { hostId: string worktreeId: string @@ -61,7 +52,6 @@ export function useMobileNativeChatDrafts(args: { * is an empty conversation, not a read that failed or never ran. Only then * does a send's captured tail describe a real boundary. */ transcriptSettled: boolean - deliveryTracking?: MobileNativeChatDeliveryTracking }): { composerText: string setComposerText: Dispatch> @@ -115,6 +105,12 @@ export function useMobileNativeChatDrafts(args: { const draftEditGenerationsRef = useRef(new MobileNativeChatDraftEditGenerations()) const messagesRef = useRef(messages) messagesRef.current = messages + const activeDraftKeyRef = useRef(draftKey) + activeDraftKeyRef.current = draftKey + const activePendingKeyRef = useRef(pendingKey) + activePendingKeyRef.current = pendingKey + const mountedRef = useRef(false) + const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({ draftKey, messages, @@ -147,9 +143,6 @@ export function useMobileNativeChatDrafts(args: { const normalizedText = normalizeReconcileText(text) return { draftKey, - ...(args.deliveryTracking - ? { deliveryOrigin: captureNativeChatDeliveryOrigin(args.deliveryTracking.status) } - : {}), draftEditGeneration: draftEditGenerationsRef.current.readDraft(draftKey), pendingKey, normalizedText, @@ -161,7 +154,7 @@ export function useMobileNativeChatDrafts(args: { baselineResolved: transcriptSettled } }, - [draftKey, pendingKey, transcriptSettled, args.deliveryTracking] + [draftKey, pendingKey, transcriptSettled] ) // Why: over relay the send RPC can take seconds (or lose only its ack), and a @@ -188,7 +181,7 @@ export function useMobileNativeChatDrafts(args: { const acceptSend = useCallback( (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { - if (!origin.pendingKey && !images?.length && !origin.deliveryOrigin) { + if (!origin.pendingKey && !images?.length) { return } pendingCounterRef.current += 1 @@ -207,13 +200,75 @@ export function useMobileNativeChatDrafts(args: { [] ) - const holdUnconfirmedSend = useMobileNativeChatUnconfirmedSend( - draftKey, - pendingKey, - messages, - acceptSend + // Why: a relay drop mid-send loses only the ack in the common case — the + // desktop already delivered the message. Hold the send instead of claiming + // failure (which baits a duplicate): stay quiet when the transcript echo + // lands, and surface the uncertainty if the deadline passes without one. + // The composer was already cleared at send time, so this never touches drafts. + const unconfirmedRef = useRef([]) + const holdUnconfirmedSend = useCallback( + (origin: MobileNativeChatSendOrigin, text: string, onUnconfirmed: () => void) => { + if (!mountedRef.current) { + return + } + const isActiveTranscript = + activeDraftKeyRef.current === origin.draftKey && + (origin.pendingKey === null || activePendingKeyRef.current === origin.pendingKey) + const entry: UnconfirmedSend = { + draftKey: origin.draftKey, + pendingKey: origin.pendingKey, + text, + normalizedText: origin.normalizedText, + baselineTailMessageId: origin.baselineTailMessageId, + deadline: null + } + // Why: the transcript event can beat the lost RPC acknowledgement. + if ( + isActiveTranscript && + findLandedUnconfirmedSends(messagesRef.current, [entry]).length > 0 + ) { + return + } + entry.deadline = setTimeout(() => { + unconfirmedRef.current = unconfirmedRef.current.filter((held) => held !== entry) + onUnconfirmed() + }, UNCONFIRMED_SEND_DEADLINE_MS) + unconfirmedRef.current = [...unconfirmedRef.current, entry] + }, + [] ) + useEffect(() => { + if (!draftKey || unconfirmedRef.current.length === 0) { + return + } + const relevant = unconfirmedRef.current.filter( + (entry) => + entry.draftKey === draftKey && + (entry.pendingKey === null || entry.pendingKey === pendingKey) + ) + const landed = findLandedUnconfirmedSends(messages, relevant) + if (landed.length === 0) { + return + } + const landedSet = new Set(landed) + unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) + for (const entry of landed) { + clearTimeout(entry.deadline ?? undefined) + } + }, [messages, draftKey, pendingKey]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + for (const entry of unconfirmedRef.current) { + clearTimeout(entry.deadline ?? undefined) + } + unconfirmedRef.current = [] + } + }, []) + const waitingForSession = draftKey ? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES @@ -232,15 +287,6 @@ export function useMobileNativeChatDrafts(args: { ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES const pending = combineMobileNativeChatPending(sessionPending, waitingForSession) - const settleDelivery = useMobileNativeChatPendingDeliveryState( - pending, - args.deliveryTracking, - pendingKey, - draftKey, - setPendingBySession, - setPendingWaitingForSession - ) - useEffect(() => { if (!pendingKey) { return @@ -287,24 +333,11 @@ export function useMobileNativeChatDrafts(args: { }) }, [messages, pending, pendingKey, transcriptSettled]) - // Why memoized: the view keys its list data and renderItem on this array's identity. - const renderedPending = useMemo( - () => - pending.some((entry) => entry.delivery) - ? pending.map((entry) => - entry.delivery === 'unconfirmed' - ? { ...entry, onDismiss: () => settleDelivery(new Set([entry.id]), new Set(), true) } - : { ...entry, delivery: undefined } - ) - : pending, - [pending, settleDelivery] - ) - return { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, getComposerEditGeneration: draftEditGenerationsRef.current.readComposer, - pending: renderedPending, + pending, imagePreviewsByMessageId: pendingKey ? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS) : NO_IMAGE_PREVIEWS, diff --git a/mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx b/mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx deleted file mode 100644 index b27818d1c4e8..000000000000 --- a/mobile/src/session/use-mobile-native-chat-pending-delivery.test.tsx +++ /dev/null @@ -1,191 +0,0 @@ -// @vitest-environment happy-dom -import { act, createElement } from 'react' -import { createRoot } from 'react-dom/client' -import { beforeEach, afterEach, expect, it, vi } from 'vitest' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import type { NativeChatDeliveryStatus } from '../../../src/shared/native-chat-pending-delivery' -import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' -import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' - -const roots = new Set>() -function cleanup() { - for (const root of roots) { - act(() => root.unmount()) - } - roots.clear() -} -function renderHook(hook: (props: P) => T, options: { initialProps: P }) { - let props = options.initialProps - let current: T | undefined - const root = createRoot(document.createElement('div')) - roots.add(root) - function Harness() { - current = hook(props) - return null - } - const render = () => act(() => root.render(createElement(Harness))) - render() - return { - result: { - get current(): T { - if (current === undefined) { - throw new Error('Hook did not render') - } - return current - } - }, - rerender(next: P) { - props = next - render() - } - } -} - -const boundary: NativeChatMessage = { - id: 'boundary', - role: 'assistant', - blocks: [{ type: 'text', text: 'before' }], - timestamp: null, - source: 'transcript' -} -const working: NativeChatDeliveryStatus = { state: 'working', stateStartedAt: 1 } -const idle: NativeChatDeliveryStatus = { state: 'done', stateStartedAt: 2 } -const idleBefore: NativeChatDeliveryStatus = { state: 'done', stateStartedAt: 0 } -const base = { - hostId: 'host', - worktreeId: 'folder', - tabId: 'tab', - sessionId: 'session', - messages: [boundary], - transcriptSettled: true -} -const readTranscript = vi.fn<() => Promise>() -beforeEach(() => { - vi.useFakeTimers() - readTranscript.mockReset() - readTranscript.mockResolvedValue([boundary]) -}) -afterEach(() => { - cleanup() - vi.useRealTimers() -}) -async function tick(ms: number) { - await act(async () => { - await vi.advanceTimersByTimeAsync(ms) - }) -} - -it('keeps a send made during a busy turn pending, with no check, after that turn ends', async () => { - const { result, rerender } = renderHook( - ({ status }) => - useMobileNativeChatDrafts({ ...base, deliveryTracking: { status, readTranscript } }), - { initialProps: { status: working } } - ) - act(() => { - const origin = result.current.captureSendOrigin('follow up')! - result.current.acceptSend(origin, 'follow up') - }) - rerender({ status: idle }) - await tick(120_000) - expect(readTranscript).not.toHaveBeenCalled() - expect(result.current.pending[0]?.delivery).toBeUndefined() -}) - -it('checks a send into an idle agent after its turn ends and provides a dismissible notice', async () => { - const { result, rerender } = renderHook( - ({ status }) => - useMobileNativeChatDrafts({ ...base, deliveryTracking: { status, readTranscript } }), - { initialProps: { status: idleBefore } } - ) - act(() => { - const origin = result.current.captureSendOrigin('follow up')! - result.current.acceptSend(origin, 'follow up') - }) - rerender({ status: working }) - await tick(120_000) - expect(readTranscript).not.toHaveBeenCalled() - const pendingBefore = result.current.pending - rerender({ status: working }) - expect(result.current.pending).toBe(pendingBefore) - rerender({ status: idle }) - await tick(0) - expect(readTranscript).toHaveBeenCalledOnce() - expect(result.current.pending[0]?.delivery).toBe('unconfirmed') - expect(result.current.pending[0]?.text).toBe('follow up') - act(() => result.current.pending[0]?.onDismiss?.()) - expect(result.current.pending).toEqual([]) -}) - -it('uses the same lifecycle for a lost write acknowledgment and permits a late transcript match', async () => { - const { result, rerender } = renderHook( - ({ status, messages }) => - useMobileNativeChatDrafts({ - ...base, - messages, - deliveryTracking: { status, readTranscript } - }), - { initialProps: { status: idleBefore, messages: [boundary] } } - ) - const report = vi.fn() - act(() => - result.current.holdUnconfirmedSend( - result.current.captureSendOrigin('one\ntwo')!, - 'one\ntwo', - report - ) - ) - rerender({ status: working, messages: [boundary] }) - await tick(120_000) - expect(report).not.toHaveBeenCalled() - expect(result.current.pending).toHaveLength(1) - rerender({ status: idle, messages: [boundary] }) - await tick(0) - expect(result.current.pending[0]?.delivery).toBe('unconfirmed') - const echo: NativeChatMessage = { - ...boundary, - id: 'user', - role: 'user', - blocks: [{ type: 'text', text: '\none\ntwo\n' }] - } - rerender({ status: idle, messages: [boundary, echo] }) - expect(result.current.pending).toEqual([]) -}) - -it('matches old-host wrapped rows and preserves the second identical send', () => { - const text = 'one\ntwo' - const echo: NativeChatMessage = { - ...boundary, - id: 'user', - role: 'user', - blocks: [{ type: 'text', text: `\n${text}\n` }] - } - const entry = { - id: 'one', - text, - expectedOccurrence: 1, - baselineTailMessageId: 'boundary', - baselineResolved: true - } - expect( - retireLandedMobileNativeChatPending( - [boundary, echo], - [entry, { ...entry, id: 'two', expectedOccurrence: 2 }], - new Set() - ).map((item) => item.id) - ).toEqual(['two']) -}) - -it('does not change structured sends that omit terminal delivery tracking', async () => { - const { result } = renderHook(() => useMobileNativeChatDrafts(base), { initialProps: undefined }) - const report = vi.fn() - act(() => - result.current.holdUnconfirmedSend( - result.current.captureSendOrigin('structured')!, - 'structured', - report - ) - ) - await tick(20_000) - expect(report).toHaveBeenCalledOnce() - expect(result.current.pending).toEqual([]) -}) diff --git a/mobile/src/session/use-mobile-native-chat-pending-delivery.ts b/mobile/src/session/use-mobile-native-chat-pending-delivery.ts deleted file mode 100644 index 9fa8426cfa90..000000000000 --- a/mobile/src/session/use-mobile-native-chat-pending-delivery.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { findLandedImagePreviewEchoes } from './mobile-native-chat-draft-reconcile' -import { useCallback, useEffect, type Dispatch, type SetStateAction } from 'react' -import { - nativeChatDeliveryCheckDelay, - observeNativeChatDeliveryOrigin, - type NativeChatDeliveryStatus -} from '../../../src/shared/native-chat-pending-delivery' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' -import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' - -export type MobileNativeChatDeliveryTracking = { - status: NativeChatDeliveryStatus | null | undefined - readTranscript: () => Promise -} - -export function useMobileNativeChatPendingDelivery( - pending: MobileNativeChatPendingMessage[], - tracking: MobileNativeChatDeliveryTracking | undefined, - settle: (checked: ReadonlySet, unmatched: ReadonlySet) => void -): void { - useEffect(() => { - if (!tracking) { - return - } - const checks = pending.flatMap((entry) => { - const delay = - entry.deliveryOrigin && !entry.delivery - ? nativeChatDeliveryCheckDelay(entry.deliveryOrigin, tracking.status) - : null - return delay === null ? [] : [{ entry, delay }] - }) - if (checks.length === 0) { - return - } - let cancelled = false - const timer = setTimeout( - () => { - void (async () => { - const due = checks - .filter( - ({ entry }) => - entry.deliveryOrigin && - nativeChatDeliveryCheckDelay(entry.deliveryOrigin, tracking.status) === 0 - ) - .map(({ entry }) => entry) - if (due.length === 0) { - return - } - const messages = await tracking.readTranscript().catch(() => null) - if (cancelled || (tracking.status && !tracking.status.restoredUnconfirmed && !messages)) { - return - } - // Keep phone-local images until the normal retirement path rebinds their previews. - const imageIds = new Set( - messages - ? findLandedImagePreviewEchoes( - messages, - due.filter((entry) => entry.baselineResolved) - ).map((echo) => echo.pendingId) - : [] - ) - const remaining = messages - ? retireLandedMobileNativeChatPending(messages, due, imageIds) - : due - settle(new Set(due.map((entry) => entry.id)), new Set(remaining.map((entry) => entry.id))) - })().catch(() => { - /* An unreadable host has not proved absence. */ - }) - }, - Math.min(...checks.map(({ delay }) => delay)) - ) - return () => { - cancelled = true - clearTimeout(timer) - } - }, [pending, tracking, settle]) -} - -type PendingByKey = Record -export function useMobileNativeChatPendingDeliveryState( - pending: MobileNativeChatPendingMessage[], - tracking: MobileNativeChatDeliveryTracking | undefined, - pendingKey: string | null, - draftKey: string | null, - setPendingBySession: Dispatch>, - setPendingWaitingForSession: Dispatch> -) { - const updateDeliveryOrigins = useCallback(() => { - const update = ( - previous: Record, - key: string | null - ) => { - if (!key || !previous[key]) { - return previous - } - const next = previous[key].map((entry) => { - if (!entry.deliveryOrigin || entry.delivery === 'confirmed') { - return entry - } - const origin = observeNativeChatDeliveryOrigin(entry.deliveryOrigin, tracking?.status) - return origin === entry.deliveryOrigin - ? entry - : { ...entry, deliveryOrigin: origin, delivery: undefined } - }) - return next.every((entry, index) => entry === previous[key]?.[index]) - ? previous - : { ...previous, [key]: next } - } - setPendingBySession((previous) => update(previous, pendingKey)) - setPendingWaitingForSession((previous) => update(previous, draftKey)) - }, [tracking?.status, pendingKey, draftKey, setPendingBySession, setPendingWaitingForSession]) - useEffect(updateDeliveryOrigins, [updateDeliveryOrigins]) - const settleDelivery = useCallback( - (checked: ReadonlySet, unmatched: ReadonlySet, dismiss = false) => { - const update = ( - previous: Record, - key: string | null - ) => { - if (!key || !previous[key]) { - return previous - } - return { - ...previous, - [key]: previous[key].flatMap((entry) => - !checked.has(entry.id) - ? [entry] - : unmatched.has(entry.id) - ? [{ ...entry, delivery: 'unconfirmed' as const }] - : dismiss - ? [] - : [{ ...entry, delivery: 'confirmed' as const }] - ) - } - } - setPendingBySession((previous) => update(previous, pendingKey)) - setPendingWaitingForSession((previous) => update(previous, draftKey)) - }, - [pendingKey, draftKey, setPendingBySession, setPendingWaitingForSession] - ) - useMobileNativeChatPendingDelivery(pending, tracking, settleDelivery) - return settleDelivery -} diff --git a/mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts b/mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts deleted file mode 100644 index 4b620cc967b7..000000000000 --- a/mobile/src/session/use-mobile-native-chat-unconfirmed-send.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { useCallback, useEffect, useLayoutEffect, useRef } from 'react' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { - findLandedUnconfirmedSends, - type UnconfirmedSend -} from './mobile-native-chat-draft-reconcile' -import type { MobileNativeChatSendOrigin } from './mobile-native-chat-pending-echo' - -const UNCONFIRMED_SEND_DEADLINE_MS = 20_000 - -/** Legacy acknowledgment-loss handling; terminal Claude sends use status-based pending delivery. */ -export function useMobileNativeChatUnconfirmedSend( - draftKey: string | null, - pendingKey: string | null, - messages: readonly NativeChatMessage[], - acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void -) { - const mountedRef = useRef(false) - const activeDraftKeyRef = useRef(draftKey) - const activePendingKeyRef = useRef(pendingKey) - const messagesRef = useRef(messages) - // Why an effect: a held send reports after commit, so refs synced here are current then. - useLayoutEffect(() => { - activeDraftKeyRef.current = draftKey - activePendingKeyRef.current = pendingKey - messagesRef.current = messages - }) - // Why: a relay drop mid-send loses only the ack in the common case — the - // desktop already delivered the message. Hold the send instead of claiming - // failure (which baits a duplicate): stay quiet when the transcript echo - // lands, and surface the uncertainty if the deadline passes without one. - // The composer was already cleared at send time, so this never touches drafts. - const unconfirmedRef = useRef([]) - const holdUnconfirmedSend = useCallback( - (origin: MobileNativeChatSendOrigin, text: string, onUnconfirmed: () => void) => { - if (origin.deliveryOrigin) { - acceptSend(origin, text) - return - } - if (!mountedRef.current) { - return - } - const isActiveTranscript = - activeDraftKeyRef.current === origin.draftKey && - (origin.pendingKey === null || activePendingKeyRef.current === origin.pendingKey) - const entry: UnconfirmedSend = { - draftKey: origin.draftKey, - pendingKey: origin.pendingKey, - text, - normalizedText: origin.normalizedText, - baselineTailMessageId: origin.baselineTailMessageId, - deadline: null - } - // Why: the transcript event can beat the lost RPC acknowledgement. - if ( - isActiveTranscript && - findLandedUnconfirmedSends(messagesRef.current, [entry]).length > 0 - ) { - return - } - entry.deadline = setTimeout(() => { - unconfirmedRef.current = unconfirmedRef.current.filter((held) => held !== entry) - onUnconfirmed() - }, UNCONFIRMED_SEND_DEADLINE_MS) - unconfirmedRef.current = [...unconfirmedRef.current, entry] - }, - [acceptSend] - ) - - useEffect(() => { - if (!draftKey || unconfirmedRef.current.length === 0) { - return - } - const relevant = unconfirmedRef.current.filter( - (entry) => - entry.draftKey === draftKey && - (entry.pendingKey === null || entry.pendingKey === pendingKey) - ) - const landed = findLandedUnconfirmedSends(messages, relevant) - if (landed.length === 0) { - return - } - const landedSet = new Set(landed) - unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) - for (const entry of landed) { - clearTimeout(entry.deadline ?? undefined) - } - }, [messages, draftKey, pendingKey]) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - for (const entry of unconfirmedRef.current) { - clearTimeout(entry.deadline ?? undefined) - } - unconfirmedRef.current = [] - } - }, []) - - return holdUnconfirmedSend -} diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9c31ab662f2..78023af1c135 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -62,6 +62,7 @@ const NativeChatComposerPane = forwardRef ({ paneKey, agent, sessionId }), [paneKey, agent, sessionId] ) - const delivery = useNativeChatPendingDelivery({ - paneKey, - agent, - sessionId, - transcriptPath, - runtimeEnvironmentId, - messages: session.messages - }) + const delivery = useNativeChatPendingDelivery({ paneKey, agent, messages: session.messages }) const { pending, record, clear } = delivery // Slash commands aren't chat turns, so they get a small local "Ran /clear" // system line instead of a user bubble. Capped + cached per conversation. @@ -412,6 +405,7 @@ export function NativeChatResolvedView({ onOptimisticSend={onOptimisticSend} onOptimisticSendCanceled={delivery.cancel} onOptimisticSendRejected={delivery.reject} + onOptimisticSendUnconfirmed={delivery.holdUnconfirmed} onSlashCommand={onSlashCommand} onSwitchToTerminal={onSwitchToTerminal} readTerminalScreen={readTerminalScreen} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 74cbb6c778e3..8542536ed0b5 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -54,7 +54,10 @@ export type NativeChatComposerProps = { onStop?: () => void /** Render an optimistic echo until the real transcript turn lands. */ onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined + /** Mark an optimistic echo whose write the host refused. */ onOptimisticSendRejected?: (pendingId: string) => void + /** Hold an optimistic echo whose write acknowledgment was lost. */ + onOptimisticSendUnconfirmed?: (pendingId: string) => void /** Remove an optimistic echo when its delayed submit is canceled. */ onOptimisticSendCanceled?: (pendingId: string) => void /** Record a dispatched slash command that does not create a chat turn. */ diff --git a/src/renderer/src/components/native-chat/native-chat-input-clear.ts b/src/renderer/src/components/native-chat/native-chat-input-clear.ts index 462b336c564b..0f15c98b54ec 100644 --- a/src/renderer/src/components/native-chat/native-chat-input-clear.ts +++ b/src/renderer/src/components/native-chat/native-chat-input-clear.ts @@ -18,7 +18,10 @@ export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15' export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140 export type NativeChatSendOptions = { + /** The host refused a write; nothing after it was sent. */ onWriteRejected?: () => void + /** A write's acknowledgment was lost; it may or may not have landed. */ + onWriteUnconfirmed?: () => void /** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */ clearInput?: string /** diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts index ceb8906f06cd..666e695ac4c2 100644 --- a/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts @@ -36,12 +36,18 @@ it('observes a refused write, skips Enter, and releases the queue for the next u NATIVE_CHAT_SUBMIT ]) }) -it('does not report a lost acknowledgment as rejection or resend it, but still submits', async () => { +it('reports a lost acknowledgment once as unconfirmed, never as rejection, and still submits', async () => { const rejected = vi.fn() + const unconfirmed = vi.fn() io.verified.mockRejectedValueOnce(new Error('lost acknowledgment')) - sendNativeChatMessage(null, 'pane', 'uncertain', { onWriteRejected: rejected }) + io.verified.mockRejectedValueOnce(new Error('lost acknowledgment')) + sendNativeChatMessage(null, 'pane', 'uncertain', { + onWriteRejected: rejected, + onWriteUnconfirmed: unconfirmed + }) await vi.advanceTimersByTimeAsync(120000) expect(rejected).not.toHaveBeenCalled() + expect(unconfirmed).toHaveBeenCalledOnce() expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ buildNativeChatPasteBytes('uncertain'), NATIVE_CHAT_SUBMIT diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.ts index 0c01a1dedb65..2de4b557c803 100644 --- a/src/renderer/src/components/native-chat/native-chat-observed-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.ts @@ -20,6 +20,7 @@ export function sendNativeChatObservedWrites( writes.reduce((total, write) => total + write.delayBeforeMs, 0) + clearConfirmDurationMs(options), ({ isCancelled, delay, markSubmitted }) => { + let reportedUnconfirmed = false const writeAt = (index: number): void => { if (isCancelled()) { return @@ -47,7 +48,13 @@ export function sendNativeChatObservedWrites( }) // A lost acknowledgment is not a refusal: never re-send these bytes, but still submit // a body that may have landed, as the unobserved path does. - .catch(() => writeAt(index + 1)) + .catch(() => { + if (!reportedUnconfirmed) { + reportedUnconfirmed = true + options.onWriteUnconfirmed?.() + } + writeAt(index + 1) + }) } if (write.delayBeforeMs > 0) { delay(write.delayBeforeMs, send) diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index b62026fe14a7..25bf2dd4287d 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -1,4 +1,3 @@ -import type { NativeChatDeliveryOrigin } from '../../../../shared/native-chat-pending-delivery' // Pure logic for desktop optimistic "queued" composer sends (mobile parity). // A sent prompt is echoed immediately as a queued entry and pruned once its real // user turn lands in the transcript. Kept separate from the view so the prune @@ -26,8 +25,10 @@ import { export type NativeChatPendingSend = { /** Renderer-minted id, unique per send, used as the list key. */ id: string - deliveryOrigin?: NativeChatDeliveryOrigin - delivery?: 'unconfirmed' | 'rejected' | 'confirmed' + /** Definite send outcome; absent while the send is simply awaiting its transcript row. */ + delivery?: 'unconfirmed' | 'rejected' + /** When a write's acknowledgment was lost, so a remount keeps the original hold deadline. */ + writeUnconfirmedAt?: number /** The exact draft text the user submitted. */ text: string /** Image paths that were sent through the TUI image attachment paste path. */ diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx index 9e22ab60153f..df485b08bd79 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx @@ -1,48 +1,28 @@ // @vitest-environment happy-dom import { act, renderHook, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery' +import { + NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS, + useNativeChatPendingDelivery +} from './use-native-chat-pending-delivery' import { clearPendingSendCacheForTests } from './native-chat-pending' -const mocks = vi.hoisted(() => { - const state: { status: AgentStatusEntry | undefined; read: ReturnType } = { - status: undefined, - read: vi.fn() - } - return state -}) -vi.mock('../../store', () => ({ - useAppStore: ( - select: (s: { agentStatusByPaneKey: Record }) => unknown - ) => select({ agentStatusByPaneKey: { pane: mocks.status } }) -})) -vi.mock('./native-chat-session-transport', () => ({ - getNativeChatSessionTransport: () => ({ readSession: mocks.read }) -})) + const boundary: NativeChatMessage = { id: 'boundary', role: 'assistant', blocks: [{ type: 'text', text: 'before' }], - timestamp: 1, + timestamp: null, source: 'transcript' } -const args = { - paneKey: 'pane', - agent: 'claude' as const, - sessionId: 'session', - runtimeEnvironmentId: null, - messages: [boundary] +const args = { paneKey: 'pane', agent: 'claude' as const, messages: [boundary] } +function userRow(text: string): NativeChatMessage { + return { ...boundary, id: `user:${text}`, role: 'user', blocks: [{ type: 'text', text }] } } -function status(state: AgentStatusEntry['state'], epoch: number): AgentStatusEntry { - return { - state, - stateStartedAt: epoch, - updatedAt: epoch, - paneKey: 'pane', - prompt: '', - stateHistory: [] - } +function render() { + return renderHook(({ messages }) => useNativeChatPendingDelivery({ ...args, messages }), { + initialProps: { messages: [boundary] } + }) } async function tick(ms: number) { await act(async () => { @@ -52,169 +32,75 @@ async function tick(ms: number) { beforeEach(() => { vi.useFakeTimers() clearPendingSendCacheForTests() - mocks.read.mockReset() - mocks.read.mockResolvedValue({ messages: [boundary] }) - mocks.status = status('working', 1) }) afterEach(() => { cleanup() vi.useRealTimers() }) -describe('desktop and paired-web pending delivery', () => { - it('keeps a send made during a busy turn pending, with no check, after that turn ends', async () => { - // Claude folds such a send into the running turn as a record the transcript reader drops. - const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) - act(() => result.current.record('follow up')) - await tick(120_000) - mocks.status = status('done', 2) - rerender() - await tick(120_000) - expect(mocks.read).not.toHaveBeenCalled() +describe('terminal Chat pending delivery', () => { + it('never flags an ordinary send, such as one Claude queues mid-turn', async () => { + const { result } = render() + act(() => result.current.record('queued follow up')) + await tick(10 * 60_000) expect(result.current.notices.size).toBe(0) expect(result.current.pending[0]?.delivery).toBeUndefined() }) - it('checks a send into an idle agent after its turn ends and keeps the text recoverable', async () => { - mocks.status = status('done', 1) - const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) - act(() => result.current.record('follow up')) - mocks.status = status('working', 2) - rerender() - await tick(120_000) - expect(mocks.read).not.toHaveBeenCalled() - mocks.status = status('done', 3) - rerender() - await tick(0) - expect(mocks.read).toHaveBeenCalledOnce() - expect(result.current.pending[0]?.text).toBe('follow up') - expect([...result.current.notices.values()][0]?.text).toMatch(/Delivery unconfirmed/) + it('marks a refused write as not sent, keeps its text, and dismisses it', () => { + const { result } = render() + act(() => { + result.current.reject(result.current.record('refused')) + }) + expect(result.current.pending[0]?.text).toBe('refused') + expect([...result.current.notices.values()][0]?.text).toBe('Message not sent') act(() => [...result.current.notices.values()][0]?.onDismiss?.()) expect(result.current.pending).toEqual([]) }) - it('flags a send an idle agent never starts a turn for, such as Claude having exited', async () => { - mocks.status = status('done', 1) - const { result } = renderHook(() => useNativeChatPendingDelivery(args)) - act(() => result.current.record('lost')) - await tick(19_000) - expect(mocks.read).not.toHaveBeenCalled() - await tick(1_000) - expect(mocks.read).toHaveBeenCalledOnce() - expect(result.current.pending[0]?.delivery).toBe('unconfirmed') - }) - it('uses the fresh read when the status arrives before the transcript stream', async () => { - mocks.status = status('done', 1) - const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) - act(() => result.current.record('one\ntwo')) - mocks.read.mockResolvedValue({ - messages: [ - boundary, - { - ...boundary, - id: 'user', - role: 'user', - blocks: [ - { type: 'text', text: '\none\ntwo\n' } - ] - } - ] + it('reports a lost acknowledgment as unconfirmed only after the hold passes with no row', async () => { + const { result } = render() + act(() => { + result.current.holdUnconfirmed(result.current.record('lost ack')) }) - mocks.status = status('done', 2) - rerender() - await tick(0) - expect(result.current.pending[0]?.delivery).toBe('confirmed') + await tick(NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS - 1) expect(result.current.notices.size).toBe(0) + await tick(1) + expect([...result.current.notices.values()][0]?.text).toMatch(/Delivery unconfirmed/) + expect(result.current.pending[0]?.text).toBe('lost ack') }) - it('bounds the no-status case across a remount and retires a late echo', async () => { - mocks.status = undefined - const first = renderHook(() => useNativeChatPendingDelivery(args)) - act(() => first.result.current.record('later')) - await tick(10_000) - first.unmount() - const next = renderHook(({ messages }) => useNativeChatPendingDelivery({ ...args, messages }), { - initialProps: { messages: [boundary] } + it('clears a held send whose echo lands, before or after the hold ends', async () => { + const { result, rerender } = render() + act(() => { + result.current.holdUnconfirmed(result.current.record('early')) + result.current.holdUnconfirmed(result.current.record('late')) }) - await tick(10_000) - expect(next.result.current.pending[0]?.delivery).toBe('unconfirmed') - next.rerender({ - messages: [ - boundary, - { ...boundary, id: 'user', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, - { ...boundary, id: 'answer' } - ] + rerender({ messages: [boundary, userRow('early')] }) + await tick(NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS) + expect(result.current.pending.map((entry) => [entry.text, entry.delivery])).toEqual([ + ['early', undefined], + ['late', 'unconfirmed'] + ]) + rerender({ + messages: [boundary, userRow('early'), userRow('late'), { ...boundary, id: 'answer' }] }) - expect(next.result.current.pending).toEqual([]) + expect(result.current.pending).toEqual([]) }) - it('keeps rejected writes distinct and does not use an unreadable transcript as absence', async () => { - const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) + it('keeps the hold deadline across a remount', async () => { + const first = render() act(() => { - const id = result.current.record('refused') - result.current.reject(id) + first.result.current.holdUnconfirmed(first.result.current.record('remounted')) }) - expect([...result.current.notices.values()][0]?.text).toBe('Message not sent') - mocks.status = status('done', 1) - rerender() - act(() => result.current.record('uncertain')) - mocks.read.mockResolvedValue({ error: 'disconnected' }) - mocks.status = status('done', 2) - rerender() - await tick(0) - expect(result.current.pending[1]?.delivery).toBeUndefined() + await tick(NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS / 2) + first.unmount() + const next = render() + await tick(NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS / 2) + expect(next.result.current.pending[0]?.delivery).toBe('unconfirmed') }) - it('does not let an old source read settle sends after the pane changes', async () => { - let resolve: ((result: { messages: NativeChatMessage[] }) => void) | undefined - mocks.read.mockImplementation( - () => - new Promise((done) => { - resolve = done - }) - ) - const { result, rerender } = renderHook( - ({ paneKey }) => useNativeChatPendingDelivery({ ...args, paneKey }), - { initialProps: { paneKey: 'pane' } } - ) - mocks.status = status('done', 1) - rerender({ paneKey: 'pane' }) - act(() => result.current.record('old')) - mocks.status = status('done', 2) - rerender({ paneKey: 'pane' }) - await tick(0) - rerender({ paneKey: 'other' }) - await act(async () => resolve?.({ messages: [boundary] })) - expect(result.current.pending).toEqual([]) + it('keeps pending and notices referentially stable across stream updates', () => { + const { result, rerender } = render() + act(() => result.current.record('stable')) + const { pending, notices } = result.current + rerender({ messages: [boundary, { ...boundary, id: 'streamed' }] }) + expect(result.current.pending).toBe(pending) + expect(result.current.notices).toBe(notices) }) }) - -it('adopts newly available working status instead of spending the no-status deadline', async () => { - mocks.status = undefined - const { result, rerender } = renderHook(() => useNativeChatPendingDelivery(args)) - act(() => result.current.record('queued')) - mocks.status = status('working', 10) - rerender() - await tick(120_000) - expect(result.current.notices.size).toBe(0) - mocks.status = status('done', 11) - rerender() - await tick(0) - expect(result.current.pending[0]?.delivery).toBe('unconfirmed') -}) -it('bounds a missing-status send even when its confirmation read rejects', async () => { - mocks.status = undefined - mocks.read.mockRejectedValue(new Error('offline')) - const { result } = renderHook(() => useNativeChatPendingDelivery(args)) - act(() => result.current.record('recover me')) - await tick(20_000) - expect(result.current.pending[0]?.delivery).toBe('unconfirmed') - expect(result.current.pending[0]?.text).toBe('recover me') -}) -it('keeps pending and notices referentially stable across status and stream updates', () => { - const { result, rerender } = renderHook( - ({ messages }) => useNativeChatPendingDelivery({ ...args, messages }), - { initialProps: { messages: [boundary] } } - ) - act(() => result.current.record('stable')) - const { pending, notices } = result.current - mocks.status = status('working', 5) - rerender({ messages: [boundary, { ...boundary, id: 'streamed' }] }) - expect(result.current.pending).toBe(pending) - expect(result.current.notices).toBe(notices) -}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts index 7e7c79bf61f3..f89636647d32 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts @@ -1,12 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { useAppStore } from '../../store' import { translate } from '@/i18n/i18n' import type { AgentType, NativeChatMessage } from '../../../../shared/native-chat-types' -import { - captureNativeChatDeliveryOrigin, - observeNativeChatDeliveryOrigin, - nativeChatDeliveryCheckDelay -} from '../../../../shared/native-chat-pending-delivery' import { appendPendingSendCache, nextNativeChatPendingSendId, @@ -16,30 +10,28 @@ import { writePendingSendCache, type NativeChatPendingSend } from './native-chat-pending' -import { getNativeChatSessionTransport } from './native-chat-session-transport' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' +/** How long a send whose write acknowledgment was lost waits for its row; the phone's hold matches. */ +export const NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS = 20_000 + const NO_NOTICES: ReadonlyMap = new Map() -/** Pending presentation follows the existing status store; a fresh read checks each completed turn. */ +/** Optimistic terminal-Chat echoes plus the definite send outcomes the transport reports. */ export function useNativeChatPendingDelivery(args: { paneKey: string agent: AgentType - sessionId: string | null - transcriptPath?: string | null - runtimeEnvironmentId: string | null messages: NativeChatMessage[] }) { - const { paneKey, agent, sessionId, transcriptPath, runtimeEnvironmentId, messages } = args + const { paneKey, agent, messages } = args const scope = useMemo(() => ({ paneKey, agent }), [paneKey, agent]) - const status = useAppStore((s) => s.agentStatusByPaneKey[paneKey]) const [pending, setPending] = useState(() => readPendingSendCache(scope)) useEffect(() => setPending(readPendingSendCache(scope)), [scope]) const save = useCallback( (update: (entries: NativeChatPendingSend[]) => NativeChatPendingSend[]) => { const current = readPendingSendCache(scope) const next = update(current) - // Why: prune and status observation run on every stream/status update; a no-op must not re-render. + // Why: pruning runs on every stream update; a no-op must not re-render the list. if (next !== current) { setPending(writePendingSendCache(scope, next)) } @@ -49,24 +41,6 @@ export function useNativeChatPendingDelivery(args: { useEffect(() => { save((entries) => prunePendingSends(entries, messages)) }, [messages, save]) - useEffect(() => { - save((entries) => { - const next = entries.map((entry) => { - if ( - !entry.deliveryOrigin || - entry.delivery === 'rejected' || - entry.delivery === 'confirmed' - ) { - return entry - } - const origin = observeNativeChatDeliveryOrigin(entry.deliveryOrigin, status) - return origin === entry.deliveryOrigin - ? entry - : { ...entry, deliveryOrigin: origin, delivery: undefined } - }) - return next.every((entry, index) => entry === entries[index]) ? entries : next - }) - }, [status, save]) const record = useCallback( (text: string, imagePaths?: string[]) => { const sentAt = Date.now() @@ -77,15 +51,12 @@ export function useNativeChatPendingDelivery(args: { sentAt, afterMessageId: boundary?.id ?? null, afterMessageTimestamp: boundary?.timestamp ?? null, - ...(imagePaths ? { imagePaths } : {}), - ...(agent === 'claude' - ? { deliveryOrigin: captureNativeChatDeliveryOrigin(status, sentAt) } - : {}) + ...(imagePaths ? { imagePaths } : {}) } setPending(appendPendingSendCache(scope, entry)) return entry.id }, - [messages, agent, status, scope] + [messages, scope] ) const cancel = useCallback( (id: string) => save((entries) => entries.filter((entry) => entry.id !== id)), @@ -98,82 +69,71 @@ export function useNativeChatPendingDelivery(args: { ), [save] ) + const holdUnconfirmed = useCallback( + (id: string) => + save((entries) => + entries.map((entry) => + entry.id === id && !entry.delivery && entry.writeUnconfirmedAt === undefined + ? { ...entry, writeUnconfirmedAt: Date.now() } + : entry + ) + ), + [save] + ) const clear = useCallback(() => save(() => []), [save]) + // A lost acknowledgment is the only unconfirmed trigger: an ordinary send, including one Claude + // queues mid-turn, has no transport doubt and stays pending until its row lands. + const nextHoldDeadline = useMemo(() => { + const deadlines = pending.flatMap((entry) => + entry.writeUnconfirmedAt !== undefined && !entry.delivery + ? [entry.writeUnconfirmedAt + NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS] + : [] + ) + return deadlines.length > 0 ? Math.min(...deadlines) : null + }, [pending]) useEffect(() => { - if (agent !== 'claude') { - return - } - const candidates = pending.filter((entry) => entry.deliveryOrigin && !entry.delivery) - const checks = candidates.flatMap((entry) => { - const delay = entry.deliveryOrigin - ? nativeChatDeliveryCheckDelay(entry.deliveryOrigin, status) - : null - return delay === null ? [] : [{ entry, delay }] - }) - if (checks.length === 0) { + if (nextHoldDeadline === null) { return } - let cancelled = false const timer = setTimeout( () => { - void (async () => { - const due = checks - .filter( - ({ entry }) => - entry.deliveryOrigin && - nativeChatDeliveryCheckDelay(entry.deliveryOrigin, status) === 0 - ) - .map(({ entry }) => entry) + const now = Date.now() + save((entries) => { + const due = entries.filter( + (entry) => + entry.writeUnconfirmedAt !== undefined && + !entry.delivery && + entry.writeUnconfirmedAt + NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS <= now + ) if (due.length === 0) { - return + return entries } - const result = sessionId - ? await getNativeChatSessionTransport(runtimeEnvironmentId) - .readSession(agent, sessionId, 500, transcriptPath ?? undefined) - .catch(() => null) - : null - if (cancelled) { - return - } - // A failed or not-yet-created transcript cannot prove absence after an idle fact. - if (status && !status.restoredUnconfirmed && (!result || !('messages' in result))) { - return - } - const history = result && 'messages' in result ? result.messages : messages const unmatched = new Set( - pendingSendsAsMessages(due, history).map((message) => message.id) + pendingSendsAsMessages(due, messages).map((message) => message.id) ) - const dueIds = new Set(due.map((entry) => entry.id)) - save((entries) => - entries.flatMap((entry) => { - if (!dueIds.has(entry.id) || entry.delivery === 'rejected') { - return [entry] - } - return unmatched.has(`pending:${entry.id}`) - ? [{ ...entry, delivery: 'unconfirmed' as const }] - : [{ ...entry, delivery: 'confirmed' as const }] - }) + if (unmatched.size === 0) { + return entries + } + return entries.map((entry) => + due.includes(entry) && unmatched.has(`pending:${entry.id}`) + ? { ...entry, delivery: 'unconfirmed' as const } + : entry ) - })().catch(() => { - /* A failed read is not evidence of non-delivery. */ }) }, - Math.min(...checks.map(({ delay }) => delay)) + Math.max(0, nextHoldDeadline - Date.now()) ) - return () => { - cancelled = true - clearTimeout(timer) - } - }, [agent, pending, status, sessionId, transcriptPath, runtimeEnvironmentId, messages, save]) + return () => clearTimeout(timer) + }, [nextHoldDeadline, messages, save]) const notices = useMemo(() => { - if (!pending.some((entry) => entry.delivery && entry.delivery !== 'confirmed')) { + if (!pending.some((entry) => entry.delivery)) { return NO_NOTICES } const result = new Map() for (const entry of pending) { - if (!entry.delivery || entry.delivery === 'confirmed') { + if (!entry.delivery) { continue } result.set(`pending:${entry.id}`, { @@ -189,5 +149,5 @@ export function useNativeChatPendingDelivery(args: { } return result }, [pending, cancel]) - return { pending, record, cancel, reject, clear, notices } + return { pending, record, cancel, reject, holdUnconfirmed, clear, notices } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts index 701c3db48729..ba6422f16201 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts @@ -32,6 +32,7 @@ export function useNativeChatPtyComposerSend(args: { classifySend: NativeChatPickerState['classifySend'] onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined onOptimisticSendRejected?: (pendingId: string) => void + onOptimisticSendUnconfirmed?: (pendingId: string) => void onSlashCommand?: (command: string) => void sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null terminalTabId: string @@ -73,6 +74,11 @@ export function useNativeChatPtyComposerSend(args: { if (pendingId) { args.onOptimisticSendRejected?.(pendingId) } + }, + onWriteUnconfirmed: () => { + if (pendingId) { + args.onOptimisticSendUnconfirmed?.(pendingId) + } } } : launchSendOptions diff --git a/src/shared/native-chat-pending-delivery.test.ts b/src/shared/native-chat-pending-delivery.test.ts deleted file mode 100644 index 605d64b2cee0..000000000000 --- a/src/shared/native-chat-pending-delivery.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - captureNativeChatDeliveryOrigin, - nativeChatDeliveryCheckDelay, - observeNativeChatDeliveryOrigin, - type NativeChatDeliveryStatus -} from './native-chat-pending-delivery' -const working: NativeChatDeliveryStatus = { - state: 'working', - stateStartedAt: 1, - providerSession: { key: 'session_id', id: 'session' } -} -const idle: NativeChatDeliveryStatus = { ...working, state: 'done', stateStartedAt: 1 } - -describe('terminal Chat confirmation evidence', () => { - it('never checks a send made into a busy turn, even after that turn ends', () => { - for (const state of ['working', 'waiting', 'blocked'] as const) { - const origin = captureNativeChatDeliveryOrigin({ ...working, state }, 9_000_000) - expect(nativeChatDeliveryCheckDelay(origin, { ...working, state }, 99_000_000)).toBeNull() - expect( - nativeChatDeliveryCheckDelay(origin, { ...idle, stateStartedAt: 2 }, 99_000_000) - ).toBeNull() - } - }) - it('checks a send into an idle agent once a later turn ends, regardless of client clock skew', () => { - const origin = captureNativeChatDeliveryOrigin(idle, 9_000_000) - expect( - nativeChatDeliveryCheckDelay(origin, { ...working, stateStartedAt: 2 }, 99_000_000) - ).toBe(null) - expect(nativeChatDeliveryCheckDelay(origin, { ...idle, stateStartedAt: 3 }, 9_000_001)).toBe(0) - }) - it('bounds a send an idle agent never starts a turn for', () => { - const origin = captureNativeChatDeliveryOrigin(idle, 1_000) - expect(nativeChatDeliveryCheckDelay(origin, idle, 5_000)).toBe(16_000) - expect(nativeChatDeliveryCheckDelay(origin, idle, 22_000)).toBe(0) - expect(nativeChatDeliveryCheckDelay(origin, { ...idle, sessionBoundary: true }, 22_000)).toBe(0) - }) - it('does not spend a replaced session, later boundary, hydrated status, or disconnect as completion', () => { - const origin = captureNativeChatDeliveryOrigin(idle, 0) - const later = { ...idle, stateStartedAt: 2 } - for (const status of [ - undefined, - { ...later, sessionBoundary: true }, - { ...later, restoredUnconfirmed: true }, - { ...later, providerSession: { key: 'session_id' as const, id: 'other' } } - ]) { - expect(nativeChatDeliveryCheckDelay(origin, status, 99_000_000)).toBeNull() - } - }) - it('bounds only sends without status facts, without resetting the deadline on remount', () => { - const origin = captureNativeChatDeliveryOrigin(undefined, 1_000) - expect(nativeChatDeliveryCheckDelay(origin, undefined, 5_000)).toBe(16_000) - expect(nativeChatDeliveryCheckDelay(origin, undefined, 22_000)).toBe(0) - expect(nativeChatDeliveryCheckDelay(origin, working, 22_000)).toBeNull() - }) -}) - -it('adopts a working fact first observed after sending, then uses its later idle fact', () => { - const unknown = captureNativeChatDeliveryOrigin(undefined, 1_000) - const known = observeNativeChatDeliveryOrigin(unknown, working) - expect(known.sentAt).toBe(1_000) - expect(known.stateAtSend).toBeNull() - expect(nativeChatDeliveryCheckDelay(known, working, 120_000)).toBeNull() - expect( - nativeChatDeliveryCheckDelay(known, { ...working, state: 'done', stateStartedAt: 2 }, 120_001) - ).toBe(0) -}) diff --git a/src/shared/native-chat-pending-delivery.ts b/src/shared/native-chat-pending-delivery.ts deleted file mode 100644 index ca075f26bf8e..000000000000 --- a/src/shared/native-chat-pending-delivery.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { AgentStatusEntry, AgentStatusState } from './agent-status-types' - -export const NATIVE_CHAT_UNCONFIRMED_COPY = 'Delivery unconfirmed — check chat before retrying' -/** How long a send may go without starting a turn before its absence is checked. */ -export const NATIVE_CHAT_UNSTARTED_SEND_DEADLINE_MS = 20_000 - -export type NativeChatDeliveryStatus = Pick< - AgentStatusEntry, - | 'state' - | 'stateStartedAt' - | 'mainAgent' - | 'restoredUnconfirmed' - | 'sessionBoundary' - | 'providerSession' -> -export type NativeChatDeliveryOrigin = { - sentAt: number - /** A host-clock boundary, never compared with the client's send clock. */ - statusEpoch: number | null - providerSessionId: string | null - /** Live state when the send was written; null when no live status was known. */ - stateAtSend: AgentStatusState | null -} - -function liveStatus( - status: NativeChatDeliveryStatus | null | undefined -): NativeChatDeliveryStatus | null { - return status && !status.restoredUnconfirmed ? status : null -} - -function statusEpoch(status: NativeChatDeliveryStatus): number { - return status.mainAgent?.stateStartedAt ?? status.stateStartedAt -} - -export function captureNativeChatDeliveryOrigin( - status: NativeChatDeliveryStatus | null | undefined, - sentAt = Date.now() -): NativeChatDeliveryOrigin { - const live = liveStatus(status) - return { - sentAt, - statusEpoch: live ? statusEpoch(live) : null, - providerSessionId: status?.providerSession?.id ?? null, - stateAtSend: live?.state ?? null - } -} - -function unstartedSendDelay(origin: NativeChatDeliveryOrigin, now: number): number { - return Math.max(0, origin.sentAt + NATIVE_CHAT_UNSTARTED_SEND_DEADLINE_MS - now) -} - -/** Delay until a confirmation read is justified, or null while absence would prove nothing. */ -export function nativeChatDeliveryCheckDelay( - origin: NativeChatDeliveryOrigin, - status: NativeChatDeliveryStatus | null | undefined, - now = Date.now() -): number | null { - // Why: Claude folds a prompt sent during a busy turn into that turn as a queued-command - // record, which the transcript reader drops, so its absence after the turn proves nothing. - if (origin.stateAtSend !== null && origin.stateAtSend !== 'done') { - return null - } - const live = liveStatus(status) - if (!live) { - // Losing an existing status is not evidence that the agent stopped. - return origin.statusEpoch === null ? unstartedSendDelay(origin, now) : null - } - if (origin.providerSessionId && live.providerSession?.id !== origin.providerSessionId) { - return null - } - if (live.state === 'working' || live.state === 'waiting' || live.state === 'blocked') { - return null - } - const state = live.mainAgent?.state ?? live.state - const epoch = statusEpoch(live) - if (state !== 'done' || origin.statusEpoch === null) { - return null - } - if (epoch === origin.statusEpoch) { - // An idle agent starts a turn on a delivered prompt at once; staying idle means it has not. - return unstartedSendDelay(origin, now) - } - return epoch > origin.statusEpoch && !live.sessionBoundary ? 0 : null -} - -/** A send made before hooks attached can adopt the first live working boundary it observes. */ -export function observeNativeChatDeliveryOrigin( - origin: NativeChatDeliveryOrigin, - status: NativeChatDeliveryStatus | null | undefined -): NativeChatDeliveryOrigin { - const live = liveStatus(status) - return origin.statusEpoch === null && live?.state === 'working' - ? // Keep the unknown send-time state: this turn may be the one the send started. - { ...captureNativeChatDeliveryOrigin(live, origin.sentAt), stateAtSend: origin.stateAtSend } - : origin -} From a32b33710c81e1409d2eb83658ee397171d5adb2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:19:34 -0700 Subject: [PATCH 09/13] fix(i18n): translate the terminal Chat delivery notices Add the Dismiss, "Message not sent" and "Delivery unconfirmed" strings to the es, fr, ja, ko and zh catalogs, reusing each catalog's existing Dismiss wording. --- src/renderer/src/i18n/locales/es.json | 3 +++ src/renderer/src/i18n/locales/fr.json | 5 ++++- src/renderer/src/i18n/locales/ja.json | 5 ++++- src/renderer/src/i18n/locales/ko.json | 5 ++++- src/renderer/src/i18n/locales/zh.json | 5 ++++- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3e2132d6011a..c5f29df2ec17 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15106,6 +15106,9 @@ "title": "Contexto", "estimated": "Estimado a partir de la última respuesta." }, + "dismissDeliveryNotice": "Descartar", + "messageNotSent": "Mensaje no enviado", + "deliveryUnconfirmed": "Entrega sin confirmar — revisa el chat antes de reintentar", "subagents": { "unnamed": "Subagente", "writtenBy": "Escrito por el subagente {{value0}}" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 53ceaf15c39e..7f4e2db035e4 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17808,7 +17808,10 @@ "label": "Contexte : {{used}} tokens sur {{window}}, {{percent}} % utilisés", "title": "Contexte", "estimated": "Estimation basée sur la dernière réponse." - } + }, + "dismissDeliveryNotice": "Ignorer", + "messageNotSent": "Message non envoyé", + "deliveryUnconfirmed": "Livraison non confirmée — vérifiez le chat avant de réessayer" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1b55d87552ac..d43095157337 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17744,7 +17744,10 @@ "label": "コンテキスト {{used}} / {{window}} トークン、{{percent}}% 使用済み", "title": "コンテキスト", "estimated": "最後の応答から推定。" - } + }, + "dismissDeliveryNotice": "閉じる", + "messageNotSent": "メッセージは送信されませんでした", + "deliveryUnconfirmed": "配信を確認できません — 再試行する前にチャットを確認してください" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a5aca8c17b6b..dbbd4d8376b9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17744,7 +17744,10 @@ "label": "컨텍스트 {{used}}/{{window}} 토큰, {{percent}}% 사용", "title": "컨텍스트", "estimated": "마지막 응답 기준 추정치입니다." - } + }, + "dismissDeliveryNotice": "닫기", + "messageNotSent": "메시지가 전송되지 않음", + "deliveryUnconfirmed": "전달 확인 안 됨 — 다시 시도하기 전에 채팅을 확인하세요" }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 47fefd2f0e36..a4c2c4f49d96 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17709,7 +17709,10 @@ "label": "上下文 {{used}}/{{window}} token,已用 {{percent}}%", "title": "上下文", "estimated": "根据上一次响应的估算值。" - } + }, + "dismissDeliveryNotice": "关闭", + "messageNotSent": "消息未发送", + "deliveryUnconfirmed": "无法确认送达 — 重试前请检查聊天" }, "tab": { "bar": { From 22ad4cde4cc3c25836ecf8dcd98f336ca10444c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:32:03 -0700 Subject: [PATCH 10/13] fix(native-chat): let a resend replace its failed terminal Chat echo A "Message not sent" or "Delivery unconfirmed" echo kept its transcript occurrence, so resending the same text numbered the resend as the second copy: the one landed row retired the failed echo and pinned the resend below the reply forever. Appending a send now drops a failed echo with the same content first. --- .../native-chat/native-chat-pending.ts | 6 +++++- .../use-native-chat-pending-delivery.test.tsx | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 25bf2dd4287d..680ca687e77b 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -84,7 +84,11 @@ export function appendPendingSendCache( scope: NativeChatPendingSendScope, entry: NativeChatPendingSend ): NativeChatPendingSend[] { - const existing = readPendingSendCache(scope) + const contentKey = nativeChatPendingContentKey(entry) + // Why: a resend replaces its failed copy; kept, that copy would claim the resend's row and pin it. + const existing = readPendingSendCache(scope).filter( + (candidate) => !candidate.delivery || nativeChatPendingContentKey(candidate) !== contentKey + ) const next = assignNativeChatPendingOccurrence(existing, entry) return writePendingSendCache(scope, [...existing, next]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx index df485b08bd79..0455aab89523 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx @@ -84,6 +84,26 @@ describe('terminal Chat pending delivery', () => { }) expect(result.current.pending).toEqual([]) }) + it.each(['rejected', 'unconfirmed'] as const)( + 'retires a resend of a %s message once its row lands', + async (outcome) => { + const { result, rerender } = render() + act(() => { + const id = result.current.record('try again') + if (outcome === 'rejected') { + result.current.reject(id) + } else { + result.current.holdUnconfirmed(id) + } + }) + await tick(NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS) + expect(result.current.pending[0]?.delivery).toBe(outcome) + act(() => result.current.record('try again')) + expect(result.current.pending.map((entry) => entry.delivery)).toEqual([undefined]) + rerender({ messages: [boundary, userRow('try again'), { ...boundary, id: 'answer' }] }) + expect(result.current.pending).toEqual([]) + } + ) it('keeps the hold deadline across a remount', async () => { const first = render() act(() => { From 702a8450b3124e501630e6a086856cd31d90d521 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:32:22 -0700 Subject: [PATCH 11/13] fix(native-chat): unwrap a Claude paste that quotes pasted_content tags The envelope parser refused any body containing a pasted_content tag, so a pasted prompt that itself quotes one (a transcript excerpt, or code that handles these tags) kept its wrapper and its echo stayed pinned below the reply. Claude's per-paste id exists to disambiguate exactly that; only a same-id tag inside the body is now ambiguous. Wrappers without an id keep the strict rule. --- ...nscript-line-decoders-claude-pasted-content.test.ts | 8 ++++++++ src/shared/claude-pasted-content.ts | 10 +++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts index 8eaece36fdb4..3711bdf038cf 100644 --- a/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts +++ b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts @@ -24,8 +24,16 @@ describe('Claude whole-block paste envelope', () => { { type: 'text', text: prompt } ]) }) + it('decodes a paste whose own text quotes differently identified or bare tags', () => { + const quoted = `\nx\n\n` + expect( + decode(`\n${quoted}\n`).blocks + ).toEqual([{ type: 'text', text: quoted }]) + }) it.each([ `Explain this:\n${wrapped}`, + `\n\nx\n`, + `\n\nx\n\n`, wrapped.replace('id="7e64">\n', 'id="other">\n'), wrapped.replace('', ''), wrapped.replace('', ''), diff --git a/src/shared/claude-pasted-content.ts b/src/shared/claude-pasted-content.ts index eef925b2ac9b..5f9f074a3404 100644 --- a/src/shared/claude-pasted-content.ts +++ b/src/shared/claude-pasted-content.ts @@ -12,11 +12,11 @@ export function unwrapClaudePastedContent(text: string): string { return text } const body = trimmed.slice(open[0].length, -close.length) - if ( - !body.endsWith('\n') || - body.includes('`) + : body.includes(' Date: Mon, 28 Sep 2026 22:32:29 -0700 Subject: [PATCH 12/13] test(native-chat): pin which terminal Chat sends observe write outcomes Only a Claude chat send (text or images) reports a refused or unacknowledged write to its pending echo; other agents and slash commands keep the unobserved write path exactly as before. --- ...use-native-chat-pty-composer-send.test.tsx | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx new file mode 100644 index 000000000000..fa6740f8ac2e --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { NativeChatSendClassification } from '../../../../shared/native-chat-slash-commands' +import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-send' +import { sendNativeChatMessage } from './native-chat-runtime-send' +import { sendNativeChatMessageWithImageAttachments } from './native-chat-runtime-image-send' + +const handle = vi.hoisted(() => ({ cancel: () => {}, settleAfterMs: 0 })) +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: vi.fn(() => handle), + sendNativeChatTypedCommand: vi.fn(() => handle), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('./native-chat-runtime-image-send', () => ({ + sendNativeChatMessageWithImageAttachments: vi.fn(() => handle) +})) +vi.mock('../../store', () => ({ + useAppStore: { getState: () => ({ clearNativeChatLaunchDraft: vi.fn() }) } +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) + +function send( + agent: AgentType, + classification: NativeChatSendClassification, + draft: string, + imagePaths: string[] = [] +) { + const callbacks = { rejected: vi.fn(), unconfirmed: vi.fn() } + const { result } = renderHook(() => + useNativeChatPtyComposerSend({ + agent, + draft, + imageAttachments: imagePaths.map((path) => ({ path })), + disabled: false, + isDispatchingSessionOption: false, + launchDraftResolved: true, + resolveTarget: () => ({ ptyId: 'pty', settings: null }), + classifySend: () => classification, + onOptimisticSend: () => 'pending-1', + onOptimisticSendRejected: callbacks.rejected, + onOptimisticSendUnconfirmed: callbacks.unconfirmed, + sessionOptionsSurface: null, + terminalTabId: 'tab', + trackPendingSend: vi.fn(), + setHistory: vi.fn(), + setDraft: vi.fn(), + setCaret: vi.fn(), + clearSkillOrigin: vi.fn(), + clearImageAttachments: vi.fn(), + setNotice: vi.fn() + }) + ) + result.current() + return callbacks +} + +beforeEach(() => { + vi.mocked(sendNativeChatMessage).mockClear() + vi.mocked(sendNativeChatMessageWithImageAttachments).mockClear() +}) + +it('routes a Claude chat send outcome to its own pending echo', () => { + const callbacks = send('claude', 'chat', 'hello') + const options = vi.mocked(sendNativeChatMessage).mock.calls[0]?.[3] + options?.onWriteRejected?.() + options?.onWriteUnconfirmed?.() + expect(callbacks.rejected).toHaveBeenCalledWith('pending-1') + expect(callbacks.unconfirmed).toHaveBeenCalledWith('pending-1') +}) + +it('routes a Claude image send outcome to its own pending echo', () => { + const callbacks = send('claude', 'chat', 'look', ['/tmp/shot.png']) + vi.mocked(sendNativeChatMessageWithImageAttachments).mock.calls[0]?.[5]?.onWriteRejected?.() + expect(callbacks.rejected).toHaveBeenCalledWith('pending-1') +}) + +it.each([ + ['codex', 'chat', 'hello'], + ['claude', 'command', '/compact'] +] as const)('leaves a %s %s send on the unobserved write path', (agent, classification, draft) => { + send(agent, classification, draft) + expect(vi.mocked(sendNativeChatMessage).mock.calls[0]?.[3]?.onWriteRejected).toBeUndefined() +}) From 72b1edebd0571ceabe3c7efd0883809bbff51495 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:43:50 -0700 Subject: [PATCH 13/13] fix(native-chat): keep failed terminal Chat sends through Stop Stop cleared every optimistic echo, including a "Message not sent" or "Delivery unconfirmed" bubble whose send had already settled. Stop cannot affect that send, and the bubble is the only place its text stays copyable, so it now survives until the user dismisses or resends it. Also moves the observed-send import below the file header comment. --- .../native-chat/native-chat-runtime-send.ts | 2 +- .../use-native-chat-pending-delivery.test.tsx | 11 +++++++++++ .../native-chat/use-native-chat-pending-delivery.ts | 6 +++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index c0710ad15a10..7187148ed41c 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -1,8 +1,8 @@ -import { sendNativeChatObservedWrites } from './native-chat-observed-send' // Runtime send for native chat: clear any unsubmitted TUI line, write the framed // body, then Enter as a SEPARATE delayed pty write. Kept apart from the pure // byte builders in native-chat-send.ts so those stay IO-free and unit-testable. +import { sendNativeChatObservedWrites } from './native-chat-observed-send' import { sendRuntimePtyInput, sendRuntimePtyInputVerified diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx index 0455aab89523..fa601e938351 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx @@ -104,6 +104,17 @@ describe('terminal Chat pending delivery', () => { expect(result.current.pending).toEqual([]) } ) + it('keeps a failed send through Stop while dropping sends Stop may have cancelled', () => { + const { result } = render() + act(() => { + result.current.reject(result.current.record('refused')) + result.current.record('in flight') + }) + act(() => result.current.clear()) + expect(result.current.pending.map((entry) => [entry.text, entry.delivery])).toEqual([ + ['refused', 'rejected'] + ]) + }) it('keeps the hold deadline across a remount', async () => { const first = render() act(() => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts index f89636647d32..a15a05fcb4db 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts @@ -80,7 +80,11 @@ export function useNativeChatPendingDelivery(args: { ), [save] ) - const clear = useCallback(() => save(() => []), [save]) + // Why keep outcomes: Stop cannot affect a settled failure, and its bubble holds the only copy. + const clear = useCallback( + () => save((entries) => entries.filter((entry) => entry.delivery)), + [save] + ) // A lost acknowledgment is the only unconfirmed trigger: an ordinary send, including one Claude // queues mid-turn, has no transport doubt and stays pending until its row lands.