diff --git a/mobile/src/session/MobileNativeChatDeliveryRecoveryNotice.tsx b/mobile/src/session/MobileNativeChatDeliveryRecoveryNotice.tsx new file mode 100644 index 000000000000..70328b065eba --- /dev/null +++ b/mobile/src/session/MobileNativeChatDeliveryRecoveryNotice.tsx @@ -0,0 +1,37 @@ +import { Pressable, Text, View } from 'react-native' +import { AlertTriangle } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' + +export function MobileNativeChatDeliveryRecoveryNotice({ + onShowTerminal +}: { + onShowTerminal?: () => void +}): React.JSX.Element { + return ( + + + + + + + Message wasn’t sent + + The terminal may be waiting for input. Your message is still in the composer. + + + + [styles.deliveryRecoveryButton, pressed && styles.pressed]} + onPress={onShowTerminal} + accessibilityRole="button" + > + Show terminal + + + ) +} diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 699f8a1614b9..9e0c1c0178e5 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -97,6 +97,8 @@ export function MobileNativeChatOverlay({ inputLockReason={inputLockReason} sendErrorMessage={sendErrorMessage} onClearSendError={onClearSendError} + deliveryFailed={controller.nativeChatDeliveryFailed} + onShowTerminal={controller.showNativeChatTerminal} filePaths={controller.nativeChatFilePaths} onNeedFiles={controller.loadNativeChatFiles} sessionOptions={controller.nativeChatSessionOptions} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 21a5a7c4aa1e..5bed0029135a 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -31,6 +31,7 @@ vi.mock('react-native-gesture-handler', () => { }) vi.mock('lucide-react-native', () => ({ + AlertTriangle: 'AlertTriangle', ArrowDown: 'ArrowDown', ChevronsDownUp: 'ChevronsDownUp', ChevronsUpDown: 'ChevronsUpDown', @@ -71,6 +72,8 @@ type Overrides = { onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null onSend?: (text: string) => Promise + deliveryFailed?: boolean + onShowTerminal?: () => void } function suppressRendererWarning(): () => void { @@ -170,6 +173,22 @@ describe('MobileNativeChatView', () => { expect(bannerText()).toContain('Permission reply failed') }) + it('shows persistent delivery recovery with a terminal action', async () => { + const onShowTerminal = vi.fn() + await render({ deliveryFailed: true, onShowTerminal }) + + expect(bannerText()).toContain('Message wasn’t sent') + expect(bannerText()).toContain('Your message is still in the composer.') + const action = renderer!.root.find( + (node) => + node.type === 'Pressable' && + node.findAll((child) => child.type === 'Text' && child.props.children === 'Show terminal') + .length > 0 + ) + act(() => action.props.onPress()) + expect(onShowTerminal).toHaveBeenCalledOnce() + }) + it('does not duplicate the route banner when the composer rejects', async () => { const onClearSendError = vi.fn() await render({ diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 478d36267a70..72aa7c1d5df0 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -23,6 +23,7 @@ import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch- import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import { MobileNativeChatDeliveryRecoveryNotice } from './MobileNativeChatDeliveryRecoveryNotice' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { MobileNativeChatMessage } from './MobileNativeChatMessage' import { MobileNativeChatAsk } from './MobileNativeChatAsk' @@ -85,6 +86,8 @@ type Props = { sendErrorMessage?: string | null /** Clears `sendErrorMessage` once a later send is accepted. */ onClearSendError?: () => void + deliveryFailed?: boolean + onShowTerminal?: () => void filePaths?: string[] onNeedFiles?: (query: string) => void /** Model/session-option pickers for the composer action row (desktop parity). */ @@ -143,6 +146,8 @@ export function MobileNativeChatView({ inputLockReason, sendErrorMessage, onClearSendError, + deliveryFailed, + onShowTerminal, filePaths, onNeedFiles, sessionOptions, @@ -429,6 +434,7 @@ export function MobileNativeChatView({ {sendErrorMessage} ) : null} + {deliveryFailed && } > chatPending: MobileNativeChatPendingMessage[] chatImagePreviewsByMessageId: Record + nativeChatDeliveryFailed: boolean + showNativeChatTerminal: () => void nativeChatSession: ReturnType nativeChatAgentWorking: boolean nativeChatStreamingText?: string diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index b18136a9d473..6f16f9e8f6db 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -1,3 +1,5 @@ +import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' + export type MobileNativeChatPendingMessage = { id: string text: string @@ -13,6 +15,8 @@ export type MobileNativeChatSendOrigin = { normalizedText: string baselineOccurrences: number baselineTailMessageId: string | null + /** Latest hook occurrence observed before dispatch starts. */ + promptSubmissionBaseline?: AgentPromptSubmissionOccurrence } type PendingByKey = Record diff --git a/mobile/src/session/mobile-native-chat-prompt-delivery.test.ts b/mobile/src/session/mobile-native-chat-prompt-delivery.test.ts new file mode 100644 index 000000000000..592003c29865 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-prompt-delivery.test.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + assignMobileNativeChatPromptSubmissions, + mobileNativeChatPromptDigest, + mobileNativeChatPromptWasAcknowledged, + type MobileNativeChatDeliveryCheck +} from './mobile-native-chat-prompt-delivery' + +describe('mobile native chat prompt delivery', () => { + it('matches the desktop full-prompt digest contract', () => { + const prompt = ` ${'long '.repeat(60)}\nemoji 🚀 ` + expect(mobileNativeChatPromptDigest(prompt)).toBe( + `sha256:${createHash('sha256').update(prompt).digest('hex')}` + ) + }) + + it('requires an exact newer occurrence', () => { + const check: MobileNativeChatDeliveryCheck = { + pendingId: 'pending-1', + draftKey: 'draft', + pendingKey: 'pending', + text: 'hello', + normalizedText: 'hello', + baselineTailMessageId: null, + submittedAt: 20, + expectedDigest: mobileNativeChatPromptDigest('hello'), + baseline: { streamId: 'stream', sequence: 2, digest: 'old', receivedAt: 10 }, + deadline: null + } + expect( + mobileNativeChatPromptWasAcknowledged(check, [ + { streamId: 'stream', sequence: 2, digest: check.expectedDigest, receivedAt: 21 } + ]) + ).toBe(false) + expect( + mobileNativeChatPromptWasAcknowledged(check, [ + { streamId: 'stream', sequence: 3, digest: check.expectedDigest, receivedAt: 21 } + ]) + ).toBe(true) + }) + + it('accepts only a newer host occurrence from a newly observed stream', () => { + const check: MobileNativeChatDeliveryCheck = { + pendingId: 'pending-1', + draftKey: 'draft', + pendingKey: 'pending', + text: 'hello', + normalizedText: 'hello', + baselineTailMessageId: null, + submittedAt: 9_000, + expectedDigest: mobileNativeChatPromptDigest('hello'), + baseline: { streamId: 'old-stream', sequence: 8, digest: 'old', receivedAt: 8_000 }, + deadline: null + } + + expect( + mobileNativeChatPromptWasAcknowledged(check, [ + { + streamId: 'new-stream', + sequence: 1, + digest: check.expectedDigest, + receivedAt: 100 + } + ]) + ).toBe(false) + expect( + mobileNativeChatPromptWasAcknowledged(check, [ + { + streamId: 'new-stream', + sequence: 2, + digest: check.expectedDigest, + receivedAt: 8_001 + } + ]) + ).toBe(true) + }) + + it('assigns one occurrence to only one repeated pending send', () => { + const digest = mobileNativeChatPromptDigest('repeat') + const base = { + draftKey: 'draft', + pendingKey: 'pending', + text: 'repeat', + normalizedText: 'repeat', + baselineTailMessageId: null, + submittedAt: 20, + expectedDigest: digest, + deadline: null + } + const occurrence = { + streamId: 'stream', + sequence: 1, + digest, + receivedAt: 21 + } + + expect([ + ...assignMobileNativeChatPromptSubmissions( + [ + { ...base, pendingId: 'first' }, + { ...base, pendingId: 'second' } + ], + [occurrence] + ).keys() + ]).toEqual(['first']) + expect([ + ...assignMobileNativeChatPromptSubmissions( + [ + { ...base, pendingId: 'first', acknowledgedBy: occurrence }, + { ...base, pendingId: 'second' } + ], + [occurrence] + ).keys() + ]).toEqual(['first']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-prompt-delivery.ts b/mobile/src/session/mobile-native-chat-prompt-delivery.ts new file mode 100644 index 000000000000..ed143e29d037 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-prompt-delivery.ts @@ -0,0 +1,76 @@ +import { sha256 } from '@noble/hashes/sha256' +import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' + +export const MOBILE_NATIVE_CHAT_DELIVERY_CONFIRMATION_MS = 8_000 + +export type MobileNativeChatDeliveryCheck = { + pendingId: string + draftKey: string + pendingKey: string | null + text: string + normalizedText: string + baselineTailMessageId: string | null + expectedDigest: string + baseline?: AgentPromptSubmissionOccurrence + deadline: ReturnType | null + acknowledgedBy?: AgentPromptSubmissionOccurrence +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function mobileNativeChatPromptDigest(text: string): string { + return `sha256:${bytesToHex(sha256(new TextEncoder().encode(text)))}` +} + +export function mobileNativeChatPromptWasAcknowledged( + check: MobileNativeChatDeliveryCheck, + occurrences: readonly AgentPromptSubmissionOccurrence[] +): boolean { + return occurrences.some((occurrence) => { + if (occurrence.digest !== check.expectedDigest) { + return false + } + if (!check.baseline) { + return true + } + if (occurrence.streamId === check.baseline.streamId) { + return occurrence.sequence > check.baseline.sequence + } + return occurrence.receivedAt > check.baseline.receivedAt + }) +} + +function occurrenceKey(occurrence: AgentPromptSubmissionOccurrence): string { + return `${occurrence.streamId}\0${occurrence.sequence}` +} + +/** Assign each hook occurrence to at most one pending send. */ +export function assignMobileNativeChatPromptSubmissions( + checks: readonly MobileNativeChatDeliveryCheck[], + occurrences: readonly AgentPromptSubmissionOccurrence[] +): Map { + const assignments = new Map() + const claimed = new Set() + for (const check of checks) { + if (check.acknowledgedBy) { + assignments.set(check.pendingId, check.acknowledgedBy) + claimed.add(occurrenceKey(check.acknowledgedBy)) + } + } + for (const check of checks) { + if (check.acknowledgedBy) { + continue + } + const occurrence = occurrences.find( + (item) => + !claimed.has(occurrenceKey(item)) && mobileNativeChatPromptWasAcknowledged(check, [item]) + ) + if (occurrence) { + assignments.set(check.pendingId, occurrence) + claimed.add(occurrenceKey(occurrence)) + } + } + return assignments +} diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts index 160477cfad80..ad3de7d3f58e 100644 --- a/mobile/src/session/mobile-native-chat-view-styles.ts +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -39,6 +39,54 @@ export const styles = StyleSheet.create({ fontSize: typography.metaSize, fontWeight: '600' }, + deliveryRecovery: { + gap: spacing.sm, + marginHorizontal: spacing.md, + marginBottom: spacing.sm, + padding: spacing.md, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgRaised + }, + deliveryRecoveryHeader: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm + }, + deliveryRecoveryIcon: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + backgroundColor: colors.bgPanel + }, + deliveryRecoveryCopy: { + flex: 1, + gap: 2 + }, + deliveryRecoveryTitle: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '700' + }, + deliveryRecoveryDescription: { + color: colors.textMuted, + fontSize: typography.metaSize, + lineHeight: 16 + }, + deliveryRecoveryButton: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + borderRadius: 8, + backgroundColor: colors.surfaceBright + }, + deliveryRecoveryButtonText: { + color: colors.bgBase, + fontSize: typography.metaSize, + fontWeight: '700' + }, chromeToggle: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 46012dadbe41..473a523869c3 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -98,7 +98,8 @@ export function useMobileNativeChatController(args: { clearDraftForSend, restoreRejectedDraft, acceptSend, - holdUnconfirmedSend + holdUnconfirmedSend, + deliveryFailed } = useMobileNativeChatDrafts({ hostId, worktreeId, @@ -111,7 +112,8 @@ export function useMobileNativeChatController(args: { // a null is indistinguishable from a host retraction, and peeking at the // terminal view would permanently decline the prefill. chatActive: showNativeChat, - transcriptLoading: nativeChatSession.transcriptLoading + transcriptLoading: nativeChatSession.transcriptLoading, + promptSubmissions: activeSessionTab?.agentStatus?.promptSubmissions }) const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null @@ -260,6 +262,8 @@ export function useMobileNativeChatController(args: { setChatComposerText, chatPending, chatImagePreviewsByMessageId, + nativeChatDeliveryFailed: deliveryFailed, + showNativeChatTerminal: handleAgentPicker, nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, diff --git a/mobile/src/session/use-mobile-native-chat-delivery-recovery.test.ts b/mobile/src/session/use-mobile-native-chat-delivery-recovery.test.ts new file mode 100644 index 000000000000..c1cf833868f3 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-delivery-recovery.test.ts @@ -0,0 +1,139 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { mobileNativeChatPromptDigest } from './mobile-native-chat-prompt-delivery' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +describe('mobile native chat delivery recovery', () => { + let renderer: ReactTestRenderer | null = null + let state: DraftState | null = null + + function Harness({ + messages = [], + promptSubmissions = [] + }: { + messages?: NativeChatMessage[] + promptSubmissions?: AgentPromptSubmissionOccurrence[] + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'tab', + sessionId: 'session', + messages, + promptSubmissions + }) + return null + } + + async function render(props: Parameters[0] = {}): Promise { + await act(async () => { + const element = createElement(Harness, props) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + + beforeEach(async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await render() + } finally { + consoleSpy.mockRestore() + } + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + vi.useRealTimers() + }) + + it('removes an unacknowledged echo and restores it ahead of newer edits', () => { + act(() => state?.setComposerText('ping')) + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.clearDraftForSend(origin, 'ping') + state?.acceptSend(origin, 'ping') + } + state?.setComposerText('newer edit') + vi.advanceTimersByTime(8_001) + }) + + expect(state?.pending).toEqual([]) + expect(state?.composerText).toBe('ping\n\nnewer edit') + expect(state?.deliveryFailed).toBe(true) + }) + + it('keeps an acknowledged echo pending until the transcript replaces it', async () => { + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + } + }) + await render({ + promptSubmissions: [ + { + streamId: 'stream', + sequence: 1, + digest: mobileNativeChatPromptDigest('ping'), + receivedAt: 1 + } + ] + }) + + act(() => vi.advanceTimersByTime(8_001)) + + expect(state?.pending.map((entry) => entry.text)).toEqual(['ping']) + expect(state?.deliveryFailed).toBe(false) + }) + + it('uses the pre-dispatch baseline when the acknowledgement beats the RPC', async () => { + const baseline = { + streamId: 'stream', + sequence: 1, + digest: mobileNativeChatPromptDigest('older'), + receivedAt: 1 + } + await render({ promptSubmissions: [baseline] }) + const origin = state?.captureSendOrigin('ping') + await render({ + promptSubmissions: [ + baseline, + { + streamId: 'stream', + sequence: 2, + digest: mobileNativeChatPromptDigest('ping'), + receivedAt: 2 + } + ] + }) + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + } + vi.advanceTimersByTime(8_001) + }) + + expect(state?.pending.map((entry) => entry.text)).toEqual(['ping']) + expect(state?.deliveryFailed).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-delivery-recovery.ts b/mobile/src/session/use-mobile-native-chat-delivery-recovery.ts new file mode 100644 index 000000000000..1712c38dd0a9 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-delivery-recovery.ts @@ -0,0 +1,218 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type Dispatch, + type SetStateAction +} from 'react' +import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + assignMobileNativeChatPromptSubmissions, + MOBILE_NATIVE_CHAT_DELIVERY_CONFIRMATION_MS, + mobileNativeChatPromptDigest, + type MobileNativeChatDeliveryCheck +} from './mobile-native-chat-prompt-delivery' +import type { + MobileNativeChatPendingMessage, + MobileNativeChatSendOrigin +} from './mobile-native-chat-pending-echo' +import { appendMobileNativeChatPending } from './mobile-native-chat-pending-echo' +import { findLandedUnconfirmedSends } from './mobile-native-chat-draft-reconcile' + +type PendingByKey = Record + +type DeliveryRecoveryArgs = { + draftKey: string | null + pendingKey: string | null + messages: readonly NativeChatMessage[] + promptSubmissions: readonly AgentPromptSubmissionOccurrence[] + setDrafts: Dispatch>> + setPendingBySession: Dispatch> + setPendingWaitingForSession: Dispatch> +} + +function occurrenceKey(occurrence: AgentPromptSubmissionOccurrence): string { + return `${occurrence.streamId}\0${occurrence.sequence}` +} + +function removePendingCheck( + previous: PendingByKey, + check: MobileNativeChatDeliveryCheck +): PendingByKey { + const key = check.pendingKey ?? check.draftKey + const current = previous[key] ?? [] + const next = current.filter((entry) => entry.id !== check.pendingId) + if (next.length === current.length) { + return previous + } + if (next.length > 0) { + return { ...previous, [key]: next } + } + const remaining = { ...previous } + delete remaining[key] + return remaining +} + +export function useMobileNativeChatDeliveryRecovery({ + draftKey, + pendingKey, + messages, + promptSubmissions, + setDrafts, + setPendingBySession, + setPendingWaitingForSession +}: DeliveryRecoveryArgs): { + deliveryFailed: boolean + acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void + readPromptSubmissionBaseline: () => AgentPromptSubmissionOccurrence | undefined +} { + const activeDraftKeyRef = useRef(draftKey) + const activePendingKeyRef = useRef(pendingKey) + const messagesRef = useRef(messages) + const promptSubmissionsRef = useRef(promptSubmissions) + const pendingCounterRef = useRef(0) + const deliveryChecksRef = useRef([]) + const [failureDraftKey, setFailureDraftKey] = useState(null) + + useLayoutEffect(() => { + activeDraftKeyRef.current = draftKey + activePendingKeyRef.current = pendingKey + messagesRef.current = messages + promptSubmissionsRef.current = promptSubmissions + }, [draftKey, messages, pendingKey, promptSubmissions]) + + const readPromptSubmissionBaseline = useCallback(() => promptSubmissionsRef.current.at(-1), []) + + const trackAcceptedTextSend = useCallback( + (pendingId: string, origin: MobileNativeChatSendOrigin, text: string) => { + const check: MobileNativeChatDeliveryCheck = { + pendingId, + draftKey: origin.draftKey, + pendingKey: origin.pendingKey, + text, + normalizedText: origin.normalizedText, + baselineTailMessageId: origin.baselineTailMessageId, + expectedDigest: mobileNativeChatPromptDigest(text), + ...(origin.promptSubmissionBaseline ? { baseline: origin.promptSubmissionBaseline } : {}), + deadline: null + } + check.deadline = setTimeout(() => { + const isActiveTranscript = + activeDraftKeyRef.current === check.draftKey && + (check.pendingKey === null || activePendingKeyRef.current === check.pendingKey) + const transcriptLanded = + isActiveTranscript && findLandedUnconfirmedSends(messagesRef.current, [check]).length > 0 + if (transcriptLanded) { + deliveryChecksRef.current = deliveryChecksRef.current.filter((held) => held !== check) + return + } + const acknowledgedBy = assignMobileNativeChatPromptSubmissions( + deliveryChecksRef.current, + promptSubmissionsRef.current + ).get(check.pendingId) + if (acknowledgedBy) { + check.deadline = null + check.acknowledgedBy = acknowledgedBy + return + } + deliveryChecksRef.current = deliveryChecksRef.current.filter((held) => held !== check) + if (check.pendingKey) { + setPendingBySession((previous) => removePendingCheck(previous, check)) + } else { + setPendingWaitingForSession((previous) => removePendingCheck(previous, check)) + } + setDrafts((previous) => { + const current = previous[check.draftKey] ?? '' + if (current === check.text) { + return previous + } + const restored = current.length === 0 ? check.text : `${check.text}\n\n${current}` + return { ...previous, [check.draftKey]: restored } + }) + setFailureDraftKey(check.draftKey) + }, MOBILE_NATIVE_CHAT_DELIVERY_CONFIRMATION_MS) + deliveryChecksRef.current = [...deliveryChecksRef.current, check] + }, + [setDrafts, setPendingBySession, setPendingWaitingForSession] + ) + + const acceptSend = useCallback( + (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { + pendingCounterRef.current += 1 + const id = `pending-${pendingCounterRef.current}` + setFailureDraftKey(null) + if (origin.pendingKey) { + const key = origin.pendingKey + setPendingBySession((previous) => + appendMobileNativeChatPending(previous, key, id, origin, text, images) + ) + } else if (images?.length) { + setPendingWaitingForSession((previous) => + appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) + ) + } + if (!images?.length && text.trim().length > 0) { + trackAcceptedTextSend(id, origin, text) + } + }, + [setPendingBySession, setPendingWaitingForSession, trackAcceptedTextSend] + ) + + useEffect(() => { + const assignments = assignMobileNativeChatPromptSubmissions( + deliveryChecksRef.current, + promptSubmissions + ) + for (const check of deliveryChecksRef.current) { + const acknowledgedBy = assignments.get(check.pendingId) + if (!check.acknowledgedBy && acknowledgedBy) { + clearTimeout(check.deadline ?? undefined) + check.deadline = null + check.acknowledgedBy = acknowledgedBy + } + } + const visibleOccurrences = new Set(promptSubmissions.map(occurrenceKey)) + deliveryChecksRef.current = deliveryChecksRef.current.filter( + (check) => + !check.acknowledgedBy || visibleOccurrences.has(occurrenceKey(check.acknowledgedBy)) + ) + }, [promptSubmissions]) + + useEffect(() => { + if (!draftKey) { + return + } + const relevant = deliveryChecksRef.current.filter( + (check) => + check.draftKey === draftKey && + (check.pendingKey === null || check.pendingKey === pendingKey) + ) + const landed = new Set(findLandedUnconfirmedSends(messages, relevant)) + if (landed.size === 0) { + return + } + deliveryChecksRef.current = deliveryChecksRef.current.filter((check) => !landed.has(check)) + for (const check of landed) { + clearTimeout(check.deadline ?? undefined) + } + }, [draftKey, messages, pendingKey]) + + useEffect( + () => () => { + for (const check of deliveryChecksRef.current) { + clearTimeout(check.deadline ?? undefined) + } + deliveryChecksRef.current = [] + }, + [] + ) + + return { + deliveryFailed: failureDraftKey === draftKey, + acceptSend, + readPromptSubmissionBaseline + } +} diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 9da4dea6a9ba..8603ebf80754 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { AgentPromptSubmissionOccurrence } from '../../../src/shared/agent-status-types' import { countImageSourceTurnsAfter, countUserTextOccurrences, @@ -11,7 +12,6 @@ import { type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' import { - appendMobileNativeChatPending, combineMobileNativeChatPending, mergeWaitingSessionPending, removeWaitingSessionPending, @@ -21,6 +21,7 @@ import { import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' +import { useMobileNativeChatDeliveryRecovery } from './use-mobile-native-chat-delivery-recovery' export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } @@ -47,6 +48,7 @@ export function useMobileNativeChatDrafts(args: { * transcript still belongs to the previously active tab), so it cannot be * trusted to decline or retire the seed. */ transcriptLoading?: boolean + promptSubmissions?: readonly AgentPromptSubmissionOccurrence[] }): { composerText: string setComposerText: Dispatch> @@ -70,6 +72,7 @@ export function useMobileNativeChatDrafts(args: { text: string, onUnconfirmed: () => void ) => void + deliveryFailed: boolean } { const { hostId, @@ -80,7 +83,8 @@ export function useMobileNativeChatDrafts(args: { launchDraft, launchDraftCreatedAt, chatActive = true, - transcriptLoading + transcriptLoading, + promptSubmissions = [] } = args const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId) const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null @@ -94,7 +98,6 @@ export function useMobileNativeChatDrafts(args: { const [imagePreviewsBySession, setImagePreviewsBySession] = useState< Record> >({}) - const pendingCounterRef = useRef(0) const messagesRef = useRef(messages) messagesRef.current = messages const activeDraftKeyRef = useRef(draftKey) @@ -103,6 +106,17 @@ export function useMobileNativeChatDrafts(args: { activePendingKeyRef.current = pendingKey const mountedRef = useRef(false) + const { deliveryFailed, acceptSend, readPromptSubmissionBaseline } = + useMobileNativeChatDeliveryRecovery({ + draftKey, + pendingKey, + messages, + promptSubmissions, + setDrafts, + setPendingBySession, + setPendingWaitingForSession + }) + const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({ draftKey, messages, @@ -134,15 +148,17 @@ export function useMobileNativeChatDrafts(args: { } const normalizedText = text.trim() const currentMessages = messagesRef.current + const promptSubmissionBaseline = readPromptSubmissionBaseline() return { draftKey, pendingKey, normalizedText, baselineOccurrences: countUserTextOccurrences(currentMessages, normalizedText), - baselineTailMessageId: currentMessages[currentMessages.length - 1]?.id ?? null + baselineTailMessageId: currentMessages.at(-1)?.id ?? null, + ...(promptSubmissionBaseline ? { promptSubmissionBaseline } : {}) } }, - [draftKey, pendingKey] + [draftKey, pendingKey, readPromptSubmissionBaseline] ) // Why: over relay the send RPC can take seconds (or lose only its ack), and a @@ -163,27 +179,6 @@ export function useMobileNativeChatDrafts(args: { ) }, []) - const acceptSend = useCallback( - (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { - if (!origin.pendingKey && !images?.length) { - return - } - pendingCounterRef.current += 1 - const id = `pending-${pendingCounterRef.current}` - const key = origin.pendingKey - if (key) { - setPendingBySession((previous) => - appendMobileNativeChatPending(previous, key, id, origin, text, images) - ) - } else { - setPendingWaitingForSession((previous) => - appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) - ) - } - }, - [] - ) - // 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 @@ -345,6 +340,7 @@ export function useMobileNativeChatDrafts(args: { clearDraftForSend, restoreRejectedDraft, acceptSend, - holdUnconfirmedSend + holdUnconfirmedSend, + deliveryFailed } } diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts index 14235654d37b..f9d693b4b7db 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -29,6 +29,44 @@ describe('createHookStatusSessionTabsInvalidator', () => { expect(changed(working())).toBe(false) }) + it('invalidates and retains a transient prompt acknowledgement through later pings', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working()) + const promptSubmission = { + streamId: 'stream-1', + sequence: 1, + digest: `sha256:${'a'.repeat(64)}`, + receivedAt: 100 + } + + expect(changed({ ...working(), promptSubmission })).toBe(true) + expect(changed(working())).toBe(false) + expect(changed.getPromptSubmissions('tab:leaf')).toEqual([promptSubmission]) + + changed.forgetPane('tab:leaf') + expect(changed.getPromptSubmissions('tab:leaf')).toEqual([]) + }) + + it('bounds prompt acknowledgement history per pane', () => { + const changed = createHookStatusSessionTabsInvalidator() + + for (let sequence = 1; sequence <= 10; sequence += 1) { + changed({ + ...working(), + promptSubmission: { + streamId: 'stream-1', + sequence, + digest: `sha256:${sequence.toString(16).padStart(64, '0')}`, + receivedAt: sequence + } + }) + } + + expect(changed.getPromptSubmissions('tab:leaf').map(({ sequence }) => sequence)).toEqual([ + 3, 4, 5, 6, 7, 8, 9, 10 + ]) + }) + it.each([ ['state', { state: 'waiting' as const }], ['prompt', { prompt: 'ship it' }], diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 59b259fa8495..749d94a3645b 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -1,28 +1,58 @@ import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import { + AGENT_PROMPT_SUBMISSION_HISTORY_MAX, + type AgentPromptSubmissionOccurrence, + type ParsedAgentStatusPayload +} from '../../shared/agent-status-types' -type KnownStatus = { connectionId: string | null; payload: ParsedAgentStatusPayload } +type KnownStatus = { + connectionId: string | null + payload: ParsedAgentStatusPayload + promptSubmissions?: AgentPromptSubmissionOccurrence[] +} + +type HookSessionTabsStatusEvent = AgentHookEventPayload & { + promptSubmission?: AgentPromptSubmissionOccurrence +} /** Reports whether a hook status event changed anything the `session.tabs` * projection publishes, so a repeated same-state ping costs no snapshot rebuild. * Mirrors `retainAgentRowSnapshot`'s change set so both carriers invalidate alike. */ export function createHookStatusSessionTabsInvalidator(): { - (event: AgentHookEventPayload): boolean + (event: HookSessionTabsStatusEvent): boolean forgetPane: (paneKey: string) => void forgetConnection: (connectionId: string) => string[] + getPromptSubmissions: (paneKey: string) => readonly AgentPromptSubmissionOccurrence[] } { const known = new Map() - const invalidator = (event: AgentHookEventPayload): boolean => { + const invalidator = (event: HookSessionTabsStatusEvent): boolean => { // Why: resume-identity rows carry transport placeholders, not status; the // provider-session invalidator owns their republish. if (event.providerSessionOnly === true) { return false } - const previous = known.get(event.paneKey)?.payload + const previousStatus = known.get(event.paneKey) + const previous = previousStatus?.payload const next = event.payload - known.set(event.paneKey, { connectionId: event.connectionId, payload: next }) + const occurrence = event.promptSubmission + const promptSubmissions = occurrence + ? [ + ...(previousStatus?.promptSubmissions ?? []).filter( + (candidate) => + candidate.streamId !== occurrence.streamId || + candidate.sequence !== occurrence.sequence + ), + occurrence + ].slice(-AGENT_PROMPT_SUBMISSION_HISTORY_MAX) + : previousStatus?.promptSubmissions + known.set(event.paneKey, { + connectionId: event.connectionId, + payload: next, + ...(promptSubmissions ? { promptSubmissions } : {}) + }) return ( !previous || + occurrence !== undefined || previous.state !== next.state || previous.prompt !== next.prompt || (previous.agentType ?? null) !== (next.agentType ?? null) || @@ -48,5 +78,8 @@ export function createHookStatusSessionTabsInvalidator(): { } return forgotten } + invalidator.getPromptSubmissions = ( + paneKey: string + ): readonly AgentPromptSubmissionOccurrence[] => known.get(paneKey)?.promptSubmissions ?? [] return invalidator } diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 96bb121c7e74..2198ec1a488b 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -8071,6 +8071,58 @@ describe('AgentHookServer ingestRemote', () => { ) }) + it('stamps monotonic live prompt submissions and ignores replay or malformed digests', () => { + vi.useFakeTimers() + vi.setSystemTime(5_000) + try { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + const digest = (value: string): string => + `sha256:${createHash('sha256').update(value).digest('hex')}` + const submit = ( + paneKey: string, + source: 'codex' | 'claude', + prompt: string, + options: { digest?: string; replay?: boolean } = {} + ): void => { + server.ingestRemote( + { + paneKey, + source, + hookEventName: 'UserPromptSubmit', + hasExplicitPrompt: true, + submittedPromptDigest: options.digest ?? digest(prompt), + isReplay: options.replay, + payload: { state: 'working', prompt, agentType: source } + }, + 'conn-1' + ) + } + + submit(PANE, 'codex', 'first') + submit(GOOD_PANE, 'claude', 'second') + submit(PANE, 'codex', 'replayed', { replay: true }) + submit(PANE, 'codex', 'malformed', { digest: 'sha256:not-a-digest' }) + submit(PANE, 'codex', 'third') + + const occurrences = listener.mock.calls + .map(([event]) => event.promptSubmission) + .filter(Boolean) + expect(occurrences).toHaveLength(3) + expect(occurrences.map((occurrence) => occurrence.sequence)).toEqual([1, 2, 3]) + expect(new Set(occurrences.map((occurrence) => occurrence.streamId)).size).toBe(1) + expect(occurrences.map((occurrence) => occurrence.receivedAt)).toEqual([5_000, 5_001, 5_004]) + expect(occurrences.map((occurrence) => occurrence.digest)).toEqual([ + digest('first'), + digest('second'), + digest('third') + ]) + } finally { + vi.useRealTimers() + } + }) + it('preserves active pane identity when a nested remote hook reports another agent', () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 0f9e0a7812d7..9b5293bfe99b 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -56,6 +56,7 @@ import { } from '../../shared/claude-statusline-rate-limits' import { AGENT_STATUS_STALE_AFTER_MS, + type AgentPromptSubmissionOccurrence, type AgentStatusClearIpcPayload, type AgentStatusIpcPayload, type AgentType, @@ -90,6 +91,7 @@ export type { AgentHookSource } type EnrichedAgentHookEventPayload = AgentHookEventPayload & { receivedAt: number stateStartedAt: number + promptSubmission?: AgentPromptSubmissionOccurrence /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ restoredUnconfirmed?: true } @@ -101,7 +103,12 @@ type NormalizedLocalHook = { type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, - 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' + | 'claudeRunningNonAgentTask' + | 'launchToken' + | 'promptInteractionKey' + | 'promptSubmission' + | 'restoredUnconfirmed' + | 'submittedPromptDigest' > & { launchTokenHash?: string } @@ -167,6 +174,11 @@ const LAST_STATUS_FILE_VERSION = 2 const STATUS_PERSIST_DEBOUNCE_MS = 250 const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) +const SUBMITTED_PROMPT_DIGEST_RE = /^sha256:[a-f0-9]{64}$/ + +function normalizeSubmittedPromptDigest(value: unknown): string | undefined { + return typeof value === 'string' && SUBMITTED_PROMPT_DIGEST_RE.test(value) ? value : undefined +} // Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 @@ -397,6 +409,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), + ...(entry.promptSubmission ? { promptSubmission: entry.promptSubmission } : {}), ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...entry.payload } @@ -617,6 +630,8 @@ export class AgentHookServer { private codexSubagentPollTimers = new Map>() private promptSentDedupeByPaneKey = new Map() private promptSentHashSalt = randomBytes(16).toString('hex') + private promptSubmissionStreamId = randomUUID() + private promptSubmissionSequence = 0 private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() private connectionTimestampWatermarkById = new Map() @@ -1039,10 +1054,25 @@ export class AgentHookServer { previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn ? previous.stateStartedAt : now + const submittedPromptDigest = normalizeSubmittedPromptDigest(payload.submittedPromptDigest) + const promptSubmission = + payload.isReplay !== true && + payload.hookEventName === 'UserPromptSubmit' && + payload.hasExplicitPrompt === true && + (payload.source === 'codex' || payload.source === 'claude') && + submittedPromptDigest + ? { + streamId: this.promptSubmissionStreamId, + sequence: ++this.promptSubmissionSequence, + digest: submittedPromptDigest, + receivedAt: now + } + : undefined return { ...payload, receivedAt: now, - stateStartedAt + stateStartedAt, + ...(promptSubmission ? { promptSubmission } : {}) } } @@ -1319,7 +1349,9 @@ export class AgentHookServer { } const subagentsChanged = JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + const next = subagentsChanged + ? this.applyNormalizedStatus({ ...normalized, submittedPromptDigest: undefined }) + : original this.scheduleCodexSubagentPoll(source, body, next) }, CODEX_SUBAGENT_POLL_MS) this.codexSubagentPollTimers.set(original.paneKey, timer) @@ -1852,6 +1884,7 @@ export class AgentHookServer { hasExplicitPrompt?: boolean promptInteractionKey?: string hookEventName?: string + submittedPromptDigest?: string source?: unknown providerPromptId?: unknown compactTrigger?: unknown @@ -1917,6 +1950,7 @@ export class AgentHookServer { ? envelope.hookEventName.trim() : undefined const source = isAgentHookSource(envelope.source) ? envelope.source : undefined + const submittedPromptDigest = normalizeSubmittedPromptDigest(envelope.submittedPromptDigest) const providerPromptId = source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined const compactTrigger = @@ -2030,6 +2064,7 @@ export class AgentHookServer { hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, promptInteractionKey, hookEventName, + submittedPromptDigest, providerPromptId, compactTrigger, toolUseId, @@ -2748,6 +2783,8 @@ export class AgentHookServer { promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, + promptSubmission: _promptSubmission, + submittedPromptDigest: _submittedPromptDigest, launchToken, ...persistedPayload } = payload as EnrichedAgentHookEventPayload diff --git a/src/main/index.ts b/src/main/index.ts index 61c7d799f94b..c069e730b3b5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1425,6 +1425,7 @@ function openMainWindow(): BrowserWindow { providerSession, providerSessionOnly, promptInteractionKey, + promptSubmission, restoredUnconfirmed, isReplay }) => { @@ -1462,6 +1463,7 @@ function openMainWindow(): BrowserWindow { stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), + ...(promptSubmission ? { promptSubmission } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(orchestration ? { orchestration } : {}) }) @@ -2363,6 +2365,8 @@ void app.whenReady().then(async () => { // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentPromptSubmissionsForPane: (paneKey) => + hookStatusChangedSessionTabs.getPromptSubmissions(paneKey), // Why: the filter above hides resume-identity rows from the live-agent views, but // those rows carry the provider session mobile native chat addresses transcripts // by — Pi publishes identity that way and would otherwise be unreachable. diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 47715ea7c5d2..f5febc033ddb 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { + AgentPromptSubmissionOccurrence, + AgentStatusIpcPayload +} from '../../shared/agent-status-types' import { makePaneKey } from '../../shared/stable-pane-id' vi.mock('electron', () => ({ @@ -58,10 +61,12 @@ function hookRow(overrides: Partial = {}): AgentStatusIpc } async function createRuntimeWithHookRows( - rows: AgentStatusIpcPayload[] + rows: AgentStatusIpcPayload[], + promptSubmissions: readonly AgentPromptSubmissionOccurrence[] = [] ): Promise { const runtime = new OrcaRuntimeService(null, undefined, { - getAgentStatusSnapshot: () => rows + getAgentStatusSnapshot: () => rows, + getAgentPromptSubmissionsForPane: () => promptSubmissions }) const internals = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise @@ -130,6 +135,23 @@ describe('headless hook agent-status projection (#11761)', () => { ) }) + it('carries transient prompt acknowledgements after the hook row has advanced', async () => { + const promptSubmission = { + streamId: 'stream-1', + sequence: 4, + digest: `sha256:${'a'.repeat(64)}`, + receivedAt: Date.now() + } + const runtime = await createRuntimeWithHookRows([hookRow()], [promptSubmission]) + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + + expect(tab?.type === 'terminal' && tab.agentStatus?.promptSubmissions).toEqual([ + promptSubmission + ]) + }) + it('publishes no hook transport identity to clients', async () => { const agentStatus = await projectAgentStatus([ hookRow({ launchToken: 'lt-secret', promptInteractionKey: 'turn-1' }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1a2aed7a74ba..4fc29043fd09 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -40,6 +40,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, isFreshNonDoneAgentStatus, pickParsedAgentStatusPayload, + type AgentPromptSubmissionOccurrence, type AgentStatusIpcPayload, type ParsedAgentStatusPayload, type AgentStatusOrchestrationContext, @@ -3265,6 +3266,9 @@ export class OrcaRuntimeService { private terminalSideEffectLocalConsumerAvailable = false private terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null + private readonly getAgentPromptSubmissionsForPaneFn: + | ((paneKey: string) => readonly AgentPromptSubmissionOccurrence[]) + | null private readonly getAgentProviderSessionSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly getAgentProviderSessionRowsForPaneFn: | ((paneKey: string) => AgentStatusIpcPayload[]) @@ -3338,6 +3342,9 @@ export class OrcaRuntimeService { // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + getAgentPromptSubmissionsForPane?: ( + paneKey: string + ) => readonly AgentPromptSubmissionOccurrence[] /** Same rows, but including the resume-identity-only ones `getAgentStatusSnapshot` * filters out so they can't read as running agents. Mobile native chat needs * them: for an agent that publishes identity separately (Pi), that row is the @@ -3381,6 +3388,7 @@ export class OrcaRuntimeService { this.agentDetector = new AgentDetector(stats) } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null + this.getAgentPromptSubmissionsForPaneFn = deps?.getAgentPromptSubmissionsForPane ?? null this.getAgentProviderSessionSnapshotFn = deps?.getAgentProviderSessionSnapshot ?? deps?.getAgentStatusSnapshot ?? null this.getAgentProviderSessionRowsForPaneFn = deps?.getAgentProviderSessionRowsForPane ?? null @@ -30099,6 +30107,7 @@ export class OrcaRuntimeService { const paneKey = isTerminalLeafId(tab.leafId) ? makePaneKey(tab.parentTabId, tab.leafId) : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` + const promptSubmissions = this.getAgentPromptSubmissionsForPaneFn?.(paneKey) ?? [] const mobileStatusPty = livePty ?? pty // Why: headless hooks live only in main's retained rows; reuse this lookup // for both title ownership and status publication so the two cannot diverge. @@ -30161,7 +30170,8 @@ export class OrcaRuntimeService { ? normalizeCompatibleAgentStatusEntryForOwner( { ...tab.agentStatus, - ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) + ...(hookProviderSession ? { providerSession: hookProviderSession } : {}), + ...(promptSubmissions.length > 0 ? { promptSubmissions: [...promptSubmissions] } : {}) }, ownerAgent ) @@ -30193,6 +30203,9 @@ export class OrcaRuntimeService { agentType: normalizedTabAgentStatus.agentType, ...(normalizedTabAgentStatus.providerSession ? { providerSession: normalizedTabAgentStatus.providerSession } + : {}), + ...(promptSubmissions.length > 0 + ? { promptSubmissions: [...promptSubmissions] } : {}) } } @@ -30289,6 +30302,7 @@ export class OrcaRuntimeService { getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] ): { agentStatus: AgentStatusEntry } | Record { const paneKey = this.getMobileTerminalPaneKey(tab) + const promptSubmissions = this.getAgentPromptSubmissionsForPaneFn?.(paneKey) ?? [] // Why: neither the OSC-retained row nor a title-derived status can carry a // provider session — only the hook payload does, and headless serve has no // renderer to publish `tab.agentStatus`. Without it mobile native chat has no @@ -30365,7 +30379,8 @@ export class OrcaRuntimeService { : {}), tabId: tab.parentTabId, terminalTitle, - ...providerSession + ...providerSession, + ...(promptSubmissions.length > 0 ? { promptSubmissions: [...promptSubmissions] } : {}) }, ownerAgent ) @@ -30397,7 +30412,8 @@ export class OrcaRuntimeService { tabId: tab.parentTabId, terminalTitle, stateHistory: [], - ...providerSession + ...providerSession, + ...(promptSubmissions.length > 0 ? { promptSubmissions: [...promptSubmissions] } : {}) } } } diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index f44d2a77dbdc..9f6e148f6d0f 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1444,6 +1444,7 @@ export class SshRelaySession { hasExplicitPrompt?: unknown promptInteractionKey?: unknown hookEventName?: unknown + submittedPromptDigest?: unknown source?: unknown providerPromptId?: unknown compactTrigger?: unknown @@ -1476,6 +1477,10 @@ export class SshRelaySession { : undefined, hookEventName: typeof envelope.hookEventName === 'string' ? envelope.hookEventName : undefined, + submittedPromptDigest: + typeof envelope.submittedPromptDigest === 'string' + ? envelope.submittedPromptDigest + : undefined, source: envelope.source, providerPromptId: envelope.providerPromptId, compactTrigger: envelope.compactTrigger, diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 32d5691cbe07..f36194aa0628 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -65,6 +66,9 @@ describe('RelayAgentHookServer', () => { expect(envelope.connectionId).toBeNull() expect(envelope.payload.state).toBe('working') expect(envelope.payload.prompt).toBe('hi') + expect(envelope.submittedPromptDigest).toBe( + `sha256:${createHash('sha256').update('hi').digest('hex')}` + ) expect(envelope.claudeRunningNonAgentTask).toBe(false) // Why: the relay forwards body env/version so Orca's warn-once // protocol diagnostics and remote-location marker survive the wire. diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 3166c70a2f5c..de18b3872a76 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -317,6 +317,7 @@ export class RelayAgentHookServer { hasExplicitPrompt: event.hasExplicitPrompt, promptInteractionKey: event.promptInteractionKey, hookEventName: event.hookEventName, + submittedPromptDigest: event.submittedPromptDigest, providerPromptId: event.providerPromptId, compactTrigger: event.compactTrigger, toolUseId: event.toolUseId, @@ -403,9 +404,10 @@ export class RelayAgentHookServer { } const subagentsChanged = JSON.stringify(event.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? event : original + const refresh = { ...event, submittedPromptDigest: undefined } + const next = subagentsChanged ? refresh : original if (subagentsChanged) { - this.applyEvent(event, source, env, version) + this.applyEvent(refresh, source, env, version) } this.scheduleCodexSubagentPoll(source, body, next, env, version) }, CODEX_SUBAGENT_POLL_MS) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 665f95449e9f..7a2077e2bfd3 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -105,7 +105,8 @@ vi.mock('./use-native-chat-composer-attachments', () => ({ imageAttachments: [], attachResolvedPaths: vi.fn(), clearImageAttachments: vi.fn(), - removeImageAttachment: vi.fn() + removeImageAttachment: vi.fn(), + restoreFailedMessage: vi.fn(() => true) }) })) vi.mock('./use-native-chat-composer-paste', () => ({ diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 3cad0b3ac2c3..d8a041b0d25a 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,13 +1,12 @@ import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' -import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { sendNativeChatMessage, sendNativeChatMessageWithImageAttachments, - submitNativeChatPrompt + submitNativeChatPrompt, + type NativeChatSendHandle } from './native-chat-runtime-send' -import type { NativeChatSendHandle } from './native-chat-runtime-send' import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' @@ -21,10 +20,7 @@ import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useNativeChatDraft } from './use-native-chat-draft' import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft-adoption' import { NativeChatComposerField } from './NativeChatComposerField' -import { - nativeChatComposerTargetIsRemote, - type NativeChatResolvedTarget -} from './native-chat-composer-target' +import { nativeChatComposerTargetIsRemote } from './native-chat-composer-target' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' @@ -37,6 +33,7 @@ import { useNativeChatSessionOptionCommand } from './use-native-chat-session-opt import { useNativeChatPickerState } from './use-native-chat-picker-state' import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch' import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion' +import { useNativeChatComposerTarget } from './use-native-chat-composer-target' import type { NativeChatComposerHandle, NativeChatComposerProps @@ -73,6 +70,7 @@ export const NativeChatComposer = forwardRef store.dictationState) const voiceSettings = useAppStore((store) => store.settings?.voice) @@ -149,34 +148,30 @@ export const NativeChatComposer = forwardRef { - if (!targetPtyId) { - return null - } - return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) } - }, [targetPtyId, terminalTabId]) - - const [hasPty, disabled] = [targetPtyId !== null, targetPtyId === null || !canSend] - - const syncCaret = useCallback((el: HTMLTextAreaElement) => { - setCaret(el.selectionStart ?? el.value.length) - }, []) + const { resolveTarget, hasPty, disabled, syncCaret } = useNativeChatComposerTarget( + terminalTabId, + targetPtyId, + canSend, + setCaret + ) - const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } = - useNativeChatComposerAttachments({ - attachmentScopeKey: paneKey, - caret, - resolveTarget, - textareaRef, - setCaret, - setDraft, - setNotice - }) - const sendButtonDisabled = isWorking - ? !hasPty || !onStop - : disabled || (draft.trim() === '' && imageAttachments.length === 0) + const { + imageAttachments, + attachResolvedPaths, + clearImageAttachments, + removeImageAttachment, + restoreFailedMessage + } = useNativeChatComposerAttachments({ + attachmentScopeKey: paneKey, + caret, + resolveTarget, + textareaRef, + setCaret, + setDraft, + setNotice + }) + const composerEmpty = draft.trim() === '' && imageAttachments.length === 0 + const sendButtonDisabled = isWorking ? !hasPty || !onStop : disabled || composerEmpty const { insertTypedText, focus } = useNativeChatTypedInsertion({ textareaRef, @@ -208,8 +203,14 @@ export const NativeChatComposer = forwardRef ({ focus, insertTypedText, handlePasteEvent: handlePaste, pasteFromClipboard }), - [focus, insertTypedText, handlePaste, pasteFromClipboard] + () => ({ + focus, + insertTypedText, + handlePasteEvent: handlePaste, + pasteFromClipboard, + restoreFailedMessage + }), + [focus, insertTypedText, handlePaste, pasteFromClipboard, restoreFailedMessage] ) const { pickAttachment } = useNativeChatFileAttachmentActions(attachExternalPaths) diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.test.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.test.tsx new file mode 100644 index 000000000000..ef5fe94898ca --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.test.tsx @@ -0,0 +1,21 @@ +// @vitest-environment happy-dom + +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { NativeChatDeliveryRecoveryNotice } from './NativeChatDeliveryRecoveryNotice' + +describe('NativeChatDeliveryRecoveryNotice', () => { + it('shows the approved recovery copy and opens the terminal', () => { + const onShowTerminal = vi.fn() + render() + + expect(screen.getByText('Message wasn’t sent')).toBeTruthy() + expect( + screen.getByText( + 'The terminal may be waiting for input. Your message is still in the composer.' + ) + ).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Show terminal' })) + expect(onShowTerminal).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.tsx new file mode 100644 index 000000000000..f16bcaa5f2c8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatDeliveryRecoveryNotice.tsx @@ -0,0 +1,33 @@ +import { AlertTriangle } from 'lucide-react' +import { translate } from '../../i18n/i18n' +import { Button } from '../ui/button' + +export function NativeChatDeliveryRecoveryNotice({ + onShowTerminal +}: { + onShowTerminal?: () => void +}): React.JSX.Element { + return ( +
+
+
+
+
+

+ {translate('components.native-chat.deliveryRecovery.title', 'Message wasn’t sent')} +

+

+ {translate( + 'components.native-chat.deliveryRecovery.description', + 'The terminal may be waiting for input. Your message is still in the composer.' + )} +

+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 2fb458166ecd..6fd7be67c1db 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -20,19 +20,13 @@ import { } from './native-chat-working-suppression' import { applyCommandMarkerBoundaries, - appendPendingSendCache, commandMarkersAsMessages, appendCommandMarkerCache, launchPromptAsMessage, pendingSendsAsMessages, - nextNativeChatPendingSendId, - prunePendingSends, readCommandMarkerCache, - readPendingSendCache, shouldPruneLaunchPrompt, - writePendingSendCache, - type NativeChatCommandMarker, - type NativeChatPendingSend + type NativeChatCommandMarker } from './native-chat-pending' import { deriveNativeChatStreamingText, @@ -53,6 +47,8 @@ import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owne import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import type { NativeChatViewProps } from './native-chat-view-types' +import { NativeChatDeliveryRecoveryNotice } from './NativeChatDeliveryRecoveryNotice' +import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery' export type { NativeChatViewProps } from './native-chat-view-types' @@ -161,6 +157,9 @@ function NativeChatResolvedView({ const hookWorkingEpoch = useAppStore( (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null ) + const promptSubmissions = useAppStore( + (s) => s.agentStatusByPaneKey[paneKey]?.promptSubmissions ?? EMPTY_PROMPT_SUBMISSIONS + ) const canSend = useNativeChatCanSend(targetPtyId) // Reuse the verified composer send path for interactive cards and composer // stop (Stop sends ESC, the agent-TUI interrupt key). @@ -198,10 +197,24 @@ function NativeChatResolvedView({ () => ({ paneKey, agent, sessionId }), [paneKey, agent, sessionId] ) - const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent]) - const [pending, setPending] = useState(() => - readPendingSendCache(pendingScope) - ) + const restoreFailedMessage = useCallback((text: string, imagePaths?: string[]) => { + composerRef.current?.restoreFailedMessage(text, imagePaths) + }, []) + const { + pending, + deliveryFailed, + clearPending, + onOptimisticSend, + onOptimisticSendCanceled, + markDeliverySubmitted + } = useNativeChatPendingDelivery({ + paneKey, + agent, + messages: session.messages, + promptSubmissions, + restoreMessage: restoreFailedMessage, + setWorkingInterrupted + }) // 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. const [commandMarkers, setCommandMarkers] = useState(() => @@ -211,55 +224,18 @@ function NativeChatResolvedView({ // often learns its provider session id after the first send; clearing pending // on that transition briefly flashes the empty state before the transcript // user turn lands. - useEffect(() => { - setPending(readPendingSendCache(pendingScope)) - setWorkingInterrupted(false) - }, [pendingScope]) // Command markers are session-scoped because slash commands like /clear are // local feedback for a specific transcript boundary. useEffect(() => { setCommandMarkers(readCommandMarkerCache(commandMarkerScope)) setWorkingInterrupted(false) }, [commandMarkerScope]) - // Prune echoes whose real user turn is now in the transcript. - useEffect(() => { - setPending((prev) => - writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages)) - ) - }, [session.messages, pendingScope]) useEffect(() => { if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) { return } clearNativeChatLaunchPrompt(terminalTabId) }, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId]) - const onOptimisticSend = useCallback( - (text: string, imagePaths?: string[]) => { - setWorkingInterrupted(false) - const sentAt = Date.now() - const boundary = session.messages.at(-1) - const entry: NativeChatPendingSend = { - id: nextNativeChatPendingSendId(sentAt), - text, - sentAt, - afterMessageId: boundary?.id ?? null, - afterMessageTimestamp: boundary?.timestamp ?? null, - ...(imagePaths ? { imagePaths } : {}) - } - setPending(appendPendingSendCache(pendingScope, entry)) - return entry.id - }, - [pendingScope, session.messages] - ) - const onOptimisticSendCanceled = useCallback( - (pendingId: string) => { - // Why: detach/interrupt cancels the delayed Enter, so its optimistic echo - // must not come back from the pane cache as a prompt that was delivered. - const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId) - setPending(writePendingSendCache(pendingScope, next)) - }, - [pendingScope] - ) const onSlashCommand = useCallback( (command: string) => { setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command)) @@ -357,9 +333,9 @@ function NativeChatResolvedView({ // Why: Stop after a submitted turn drops the delayed-write handle once it // settles, so cancelPendingSends no longer sees the optimistic id. Clear // the echo cache here so a cancelled prompt cannot stick as a ghost bubble. - setPending(writePendingSendCache(pendingScope, [])) + clearPending() interactiveSend.cancel() - }, [interactiveSend, pendingScope]) + }, [clearPending, interactiveSend]) const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so @@ -438,24 +414,32 @@ function NativeChatResolvedView({ the pty, the composer shows its guarded state instead of racing the mobile driver (R8). */} {questionActive ? null : ( - + <> + {deliveryFailed ? ( + + ) : null} + + )} {contextMenu.menu} ) } + +const EMPTY_PROMPT_SUBMISSIONS = [] as const 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 58e97ccd209c..65c00ec0e252 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 @@ -19,6 +19,8 @@ export type NativeChatComposerProps = { onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined /** Remove an optimistic echo when its delayed submit is canceled. */ onOptimisticSendCanceled?: (pendingId: string) => void + /** Arm delivery confirmation once this pending send's Enter write fires. */ + onOptimisticSendSubmitted?: (pendingId: string) => void /** Record a dispatched slash command that does not create a chat turn. */ onSlashCommand?: (command: string) => void /** Picker-only agent commands continue in the hosted TUI after dispatch. */ @@ -42,4 +44,6 @@ export type NativeChatComposerHandle = { }) => void /** Pastes clipboard content when no DOM paste event is available. */ pasteFromClipboard: () => void + /** Restore a failed send without discarding newer composer edits. */ + restoreFailedMessage: (text: string, imagePaths?: string[]) => boolean } 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 466ec16cd8c3..3d4c124f7c9c 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -5,6 +5,7 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' +import type { NativeChatDeliveryCheck } from './native-chat-prompt-delivery' import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' import { advancedNativeChatUserContentCounts, @@ -38,6 +39,8 @@ export type NativeChatPendingSend = { matchingOccurrence?: number /** Shared time boundary when that message boundary is unavailable. */ matchingAfterTimestamp?: number + /** Bounded confirmation state for recovering sends that never became a transcript turn. */ + deliveryCheck?: NativeChatDeliveryCheck } export type NativeChatPendingSendScope = { diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-delivery.test.ts b/src/renderer/src/components/native-chat/native-chat-prompt-delivery.test.ts new file mode 100644 index 000000000000..073c37e796c9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-delivery.test.ts @@ -0,0 +1,110 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + armNativeChatDeliveryCheck, + assignMatchingPromptSubmissions, + hasMatchingPromptSubmission, + nativeChatPromptDigest +} from './native-chat-prompt-delivery' + +describe('native chat prompt delivery', () => { + it('hashes the full exact multiline Unicode prompt', () => { + const prompt = ` ${'long '.repeat(60)}\nsecond line 🚀 ` + const expected = createHash('sha256').update(prompt).digest('hex') + expect(nativeChatPromptDigest(prompt)).toBe(`sha256:${expected}`) + }) + + it('requires an exact digest from a newer occurrence in the same stream', () => { + const expectedDigest = nativeChatPromptDigest('hello') + const check = armNativeChatDeliveryCheck( + { + expectedDigest, + baseline: { streamId: 'stream-a', sequence: 4, digest: 'old', receivedAt: 10 } + }, + 20 + ) + + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-a', sequence: 4, digest: expectedDigest, receivedAt: 21 } + ]) + ).toBe(false) + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-a', sequence: 5, digest: 'sha256:wrong', receivedAt: 21 } + ]) + ).toBe(false) + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-a', sequence: 5, digest: expectedDigest, receivedAt: 21 } + ]) + ).toBe(true) + }) + + it('accepts only a newer matching occurrence from a newly observed stream', () => { + const expectedDigest = nativeChatPromptDigest('hello') + const check = armNativeChatDeliveryCheck( + { + expectedDigest, + baseline: { streamId: 'stream-a', sequence: 9, digest: 'old', receivedAt: 10 } + }, + 20 + ) + + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-b', sequence: 1, digest: expectedDigest, receivedAt: 1 } + ]) + ).toBe(false) + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-b', sequence: 1, digest: expectedDigest, receivedAt: 30 } + ]) + ).toBe(true) + }) + + it('rejects a matching occurrence received before an unbased send was armed', () => { + const expectedDigest = nativeChatPromptDigest('hello') + const check = armNativeChatDeliveryCheck({ expectedDigest }, 20) + + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-a', sequence: 1, digest: expectedDigest, receivedAt: 19 } + ]) + ).toBe(false) + expect( + hasMatchingPromptSubmission(check, [ + { streamId: 'stream-a', sequence: 2, digest: expectedDigest, receivedAt: 20 } + ]) + ).toBe(true) + }) + + it('assigns one occurrence to only one repeated pending send', () => { + const expectedDigest = nativeChatPromptDigest('repeat') + const check = armNativeChatDeliveryCheck({ expectedDigest }, 20) + const occurrence = { + streamId: 'stream-a', + sequence: 1, + digest: expectedDigest, + receivedAt: 21 + } + + const first = assignMatchingPromptSubmissions( + [ + { id: 'first', deliveryCheck: check }, + { id: 'second', deliveryCheck: check } + ], + [occurrence] + ) + expect([...first.keys()]).toEqual(['first']) + + const reserved = assignMatchingPromptSubmissions( + [ + { id: 'first', deliveryCheck: { ...check, acknowledgedBy: occurrence } }, + { id: 'second', deliveryCheck: check } + ], + [occurrence] + ) + expect([...reserved.keys()]).toEqual(['first']) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-delivery.ts b/src/renderer/src/components/native-chat/native-chat-prompt-delivery.ts new file mode 100644 index 000000000000..67cad52eda86 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-delivery.ts @@ -0,0 +1,95 @@ +import type { AgentPromptSubmissionOccurrence } from '../../../../shared/agent-status-types' +import { sha256 } from '../../lib/sha256' + +export const NATIVE_CHAT_DELIVERY_CONFIRMATION_MS = 8_000 + +export type NativeChatDeliveryCheck = { + expectedDigest?: string + baseline?: AgentPromptSubmissionOccurrence + submittedAt?: number + deadline?: number + acknowledgedBy?: AgentPromptSubmissionOccurrence +} + +type NativeChatDeliveryCandidate = { + id: string + deliveryCheck?: NativeChatDeliveryCheck +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function nativeChatPromptDigest(text: string): string { + return `sha256:${bytesToHex(sha256(new TextEncoder().encode(text)))}` +} + +export function armNativeChatDeliveryCheck( + check: NativeChatDeliveryCheck, + submittedAt: number +): NativeChatDeliveryCheck { + return { + ...check, + submittedAt, + deadline: submittedAt + NATIVE_CHAT_DELIVERY_CONFIRMATION_MS + } +} + +function occurrenceIsNewer( + occurrence: AgentPromptSubmissionOccurrence, + check: NativeChatDeliveryCheck +): boolean { + if (check.baseline) { + return check.baseline.streamId === occurrence.streamId + ? occurrence.sequence > check.baseline.sequence + : occurrence.receivedAt > check.baseline.receivedAt + } + return check.submittedAt !== undefined && occurrence.receivedAt >= check.submittedAt +} + +export function hasMatchingPromptSubmission( + check: NativeChatDeliveryCheck, + occurrences: readonly AgentPromptSubmissionOccurrence[] +): boolean { + return ( + check.expectedDigest !== undefined && + occurrences.some( + (occurrence) => + occurrence.digest === check.expectedDigest && occurrenceIsNewer(occurrence, check) + ) + ) +} + +function occurrenceKey(occurrence: AgentPromptSubmissionOccurrence): string { + return `${occurrence.streamId}\0${occurrence.sequence}` +} + +/** Assign each hook occurrence to at most one pending send. */ +export function assignMatchingPromptSubmissions( + candidates: readonly NativeChatDeliveryCandidate[], + occurrences: readonly AgentPromptSubmissionOccurrence[] +): Map { + const assignments = new Map() + const claimed = new Set() + for (const candidate of candidates) { + const acknowledgedBy = candidate.deliveryCheck?.acknowledgedBy + if (acknowledgedBy) { + assignments.set(candidate.id, acknowledgedBy) + claimed.add(occurrenceKey(acknowledgedBy)) + } + } + for (const candidate of candidates) { + const check = candidate.deliveryCheck + if (!check || check.acknowledgedBy) { + continue + } + const occurrence = occurrences.find( + (item) => !claimed.has(occurrenceKey(item)) && hasMatchingPromptSubmission(check, [item]) + ) + if (occurrence) { + assignments.set(candidate.id, occurrence) + claimed.add(occurrenceKey(occurrence)) + } + } + return assignments +} diff --git a/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts index 4ae0ce9fe785..724e3c71dda1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts @@ -10,8 +10,11 @@ export type NativeChatPtySendQueueHandle = { cancel: () => void settleAfterMs: number settled: Promise + /** Resolves at the exact submit write, or false when canceled first. */ + submission: Promise bodyStarted: () => boolean finished: () => boolean + submitted: () => boolean } export type EnqueueNativeChatPtySendOptions = { @@ -95,6 +98,10 @@ export function enqueueNativeChatPtySend( let bodyStarted = false let finished = false let submitted = false + let resolveSubmission!: (submitted: boolean) => void + const submission = new Promise((resolve) => { + resolveSubmission = resolve + }) const timers: ReturnType[] = [] let release: (() => void) | null = null @@ -119,6 +126,7 @@ export function enqueueNativeChatPtySend( const markSubmitted = (): void => { submitted = true + resolveSubmission(true) finishEntry() } @@ -165,6 +173,7 @@ export function enqueueNativeChatPtySend( return } cancelled = true + resolveSubmission(false) for (const timer of timers) { clearTimeout(timer) } @@ -182,8 +191,10 @@ export function enqueueNativeChatPtySend( }, settleAfterMs, settled, + submission, bodyStarted: () => bodyStarted, - finished: () => finished + finished: () => finished, + submitted: () => submitted } state.handles.add(handle) return handle 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 a2974474b2b5..afd6e66c892c 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 @@ -65,6 +65,10 @@ export type NativeChatSendHandle = { settleAfterMs: number /** Actual completion, which can outlive the nominal schedule if the renderer stalls. */ settled?: Promise + /** Resolves at the submit write, before later hook events can race its baseline. */ + submission?: Promise + /** True only after the submit write fired; canceled queued sends remain false. */ + submitted?: () => boolean } type RuntimeSettings = ReturnType diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 60ecf44ff5ad..0f447e1cd4ec 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -1,4 +1,11 @@ -import { useCallback, useRef, useState, type RefObject } from 'react' +import { + useCallback, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction +} from 'react' import { translate } from '@/i18n/i18n' import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' import { @@ -14,7 +21,7 @@ export type UseNativeChatComposerAttachmentsArgs = { caret: number resolveTarget: () => NativeChatResolvedTarget | null textareaRef: RefObject - setCaret: (caret: number) => void + setCaret: Dispatch> setDraft: (updater: (previous: string) => string) => void setNotice: (notice: string | null) => void } @@ -30,6 +37,7 @@ export function useNativeChatComposerAttachments({ }: UseNativeChatComposerAttachmentsArgs): { imageAttachments: NativeChatComposerImageAttachment[] appendImageAttachments: (paths: string[]) => void + restoreFailedMessage: (text: string, imagePaths?: string[]) => boolean attachResolvedPaths: (paths: string[]) => void clearImageAttachments: () => void removeImageAttachment: (id: string) => void @@ -80,6 +88,22 @@ export function useNativeChatComposerAttachments({ [updateImageAttachments] ) + const restoreFailedMessage = useCallback( + (text: string, imagePaths?: string[]): boolean => { + setDraft((current) => { + if (current === text || text.length === 0) { + return current + } + const prefix = current.length === 0 ? text : `${text}\n\n` + setCaret((position) => position + prefix.length) + return `${prefix}${current}` + }) + appendImageAttachments(imagePaths ?? []) + return true + }, + [appendImageAttachments, setCaret, setDraft] + ) + const insertFileReferences = useCallback( (paths: string[]) => { const references = paths.map(formatNativeChatFileReference).join(' ') @@ -134,6 +158,7 @@ export function useNativeChatComposerAttachments({ return { imageAttachments, appendImageAttachments, + restoreFailedMessage, attachResolvedPaths, clearImageAttachments: () => updateImageAttachments(() => []), removeImageAttachment: (id) => diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-target.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-target.ts new file mode 100644 index 000000000000..95cd09f56125 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-target.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react' +import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import type { NativeChatResolvedTarget } from './native-chat-composer-target' + +export function useNativeChatComposerTarget( + terminalTabId: string, + targetPtyId: string | null, + canSend: boolean, + setCaret: (caret: number | ((previous: number) => number)) => void +): { + resolveTarget: () => NativeChatResolvedTarget | null + hasPty: boolean + disabled: boolean + syncCaret: (element: HTMLTextAreaElement) => void +} { + const resolveTarget = useCallback((): NativeChatResolvedTarget | null => { + if (!targetPtyId) { + return null + } + return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) } + }, [targetPtyId, terminalTabId]) + const syncCaret = useCallback( + (element: HTMLTextAreaElement) => setCaret(element.selectionStart ?? element.value.length), + [setCaret] + ) + return { + resolveTarget, + hasPty: targetPtyId !== null, + disabled: targetPtyId === null || !canSend, + syncCaret + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts b/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts new file mode 100644 index 000000000000..43196baab1fa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-delivery-recovery.ts @@ -0,0 +1,189 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type Dispatch, + type SetStateAction +} from 'react' +import type { AgentPromptSubmissionOccurrence } from '../../../../shared/agent-status-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + pendingSendsAsMessages, + readPendingSendCache, + writePendingSendCache, + type NativeChatPendingSend, + type NativeChatPendingSendScope +} from './native-chat-pending' +import { + armNativeChatDeliveryCheck, + assignMatchingPromptSubmissions +} from './native-chat-prompt-delivery' + +type DeliveryRecoveryArgs = { + scope: NativeChatPendingSendScope + pending: NativeChatPendingSend[] + setPending: Dispatch> + messages: NativeChatMessage[] + promptSubmissions: readonly AgentPromptSubmissionOccurrence[] + restoreMessage: (text: string, imagePaths?: string[]) => void +} + +function scheduleDeliveryChecks( + pending: readonly NativeChatPendingSend[], + onDeadline: (entry: NativeChatPendingSend) => void +): () => void { + const timers = pending.flatMap((entry) => { + const deadline = entry.deliveryCheck?.deadline + return deadline === undefined + ? [] + : [setTimeout(() => onDeadline(entry), Math.max(0, deadline - Date.now()))] + }) + return () => { + for (const timer of timers) { + clearTimeout(timer) + } + } +} + +export function useNativeChatDeliveryRecovery({ + scope, + pending, + setPending, + messages, + promptSubmissions, + restoreMessage +}: DeliveryRecoveryArgs): { + failed: boolean + clearFailure: () => void + markSubmitted: (pendingId: string) => void +} { + const [failedScope, setFailedScope] = useState(null) + const messagesRef = useRef(messages) + const submissionsRef = useRef(promptSubmissions) + const restoreMessageRef = useRef(restoreMessage) + + useLayoutEffect(() => { + messagesRef.current = messages + submissionsRef.current = promptSubmissions + restoreMessageRef.current = restoreMessage + }, [messages, promptSubmissions, restoreMessage]) + + const updatePending = useCallback( + (updater: (entries: NativeChatPendingSend[]) => NativeChatPendingSend[]) => { + setPending((current) => writePendingSendCache(scope, updater(current))) + }, + [scope, setPending] + ) + + const markSubmitted = useCallback( + (pendingId: string) => { + const submittedAt = Date.now() + updatePending((entries) => + entries.map((entry) => + entry.id === pendingId && entry.deliveryCheck + ? { + ...entry, + deliveryCheck: armNativeChatDeliveryCheck( + { + ...entry.deliveryCheck, + ...(submissionsRef.current.at(-1) + ? { baseline: submissionsRef.current.at(-1) } + : {}) + }, + submittedAt + ) + } + : entry + ) + ) + }, + [updatePending] + ) + const clearFailure = useCallback(() => setFailedScope(null), []) + const failed = failedScope?.paneKey === scope.paneKey && failedScope.agent === scope.agent + + useEffect(() => { + const assignments = assignMatchingPromptSubmissions(pending, promptSubmissions) + const transcriptConfirmedIds = new Set( + pending + .filter( + (entry) => + entry.deliveryCheck?.deadline !== undefined && + pendingSendsAsMessages([entry], messages).length === 0 + ) + .map((entry) => entry.id) + ) + const newlyAcknowledged = pending.some( + (entry) => !entry.deliveryCheck?.acknowledgedBy && assignments.has(entry.id) + ) + if (transcriptConfirmedIds.size === 0 && !newlyAcknowledged) { + return + } + updatePending((entries) => + entries.map((entry) => { + if (transcriptConfirmedIds.has(entry.id)) { + const { deliveryCheck: _deliveryCheck, ...confirmed } = entry + return confirmed + } + const acknowledgedBy = assignments.get(entry.id) + if (!acknowledgedBy || entry.deliveryCheck?.acknowledgedBy) { + return entry + } + return { + ...entry, + deliveryCheck: { ...entry.deliveryCheck, deadline: undefined, acknowledgedBy } + } + }) + ) + }, [messages, pending, promptSubmissions, updatePending]) + + const handleDeliveryDeadline = useCallback( + (entry: NativeChatPendingSend) => { + const cached = readPendingSendCache(scope).find((candidate) => candidate.id === entry.id) + if (!cached?.deliveryCheck) { + return + } + const cachedEntries = readPendingSendCache(scope) + const acknowledgedBy = assignMatchingPromptSubmissions( + cachedEntries, + submissionsRef.current + ).get(entry.id) + if (acknowledgedBy) { + setPending( + writePendingSendCache( + scope, + cachedEntries.map((candidate) => + candidate.id === entry.id && candidate.deliveryCheck + ? { + ...candidate, + deliveryCheck: { + ...candidate.deliveryCheck, + deadline: undefined, + acknowledgedBy + } + } + : candidate + ) + ) + ) + return + } + if (pendingSendsAsMessages([cached], messagesRef.current).length === 0) { + return + } + const next = readPendingSendCache(scope).filter((candidate) => candidate.id !== entry.id) + setPending(writePendingSendCache(scope, next)) + restoreMessageRef.current(cached.text, cached.imagePaths) + setFailedScope(scope) + }, + [scope, setPending] + ) + + useEffect(() => { + return scheduleDeliveryChecks(pending, handleDeliveryDeadline) + }, [handleDeliveryDeadline, pending]) + + return { failed, clearFailure, markSubmitted } +} 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..bf54e16a9b1d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { clearPendingSendCacheForTests } from './native-chat-pending' +import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery' + +describe('useNativeChatPendingDelivery', () => { + beforeEach(() => clearPendingSendCacheForTests()) + + it('arms exact acknowledgement recovery only for text-only sends', () => { + const messages = [] + const promptSubmissions = [] + const restoreMessage = (): void => {} + const setWorkingInterrupted = (): void => {} + const { result } = renderHook(() => + useNativeChatPendingDelivery({ + paneKey: 'tab:leaf', + agent: 'claude', + messages, + promptSubmissions, + restoreMessage, + setWorkingInterrupted + }) + ) + + act(() => { + result.current.onOptimisticSend('text only') + result.current.onOptimisticSend('with image', ['/tmp/image.png']) + result.current.onOptimisticSend('', ['/tmp/image-only.png']) + }) + + expect(result.current.pending.map((entry) => entry.deliveryCheck !== undefined)).toEqual([ + true, + false, + false + ]) + }) +}) 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..41e21022eb27 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts @@ -0,0 +1,119 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type Dispatch, + type SetStateAction +} from 'react' +import type { + AgentPromptSubmissionOccurrence, + AgentType +} from '../../../../shared/agent-status-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + appendPendingSendCache, + nextNativeChatPendingSendId, + prunePendingSends, + readPendingSendCache, + writePendingSendCache, + type NativeChatPendingSend +} from './native-chat-pending' +import { nativeChatPromptDigest } from './native-chat-prompt-delivery' +import { useNativeChatDeliveryRecovery } from './use-native-chat-delivery-recovery' + +export function useNativeChatPendingDelivery({ + paneKey, + agent, + messages, + promptSubmissions, + restoreMessage, + setWorkingInterrupted +}: { + paneKey: string + agent: AgentType + messages: NativeChatMessage[] + promptSubmissions: readonly AgentPromptSubmissionOccurrence[] + restoreMessage: (text: string, imagePaths?: string[]) => void + setWorkingInterrupted: Dispatch> +}): { + pending: NativeChatPendingSend[] + deliveryFailed: boolean + clearPending: () => void + onOptimisticSend: (text: string, imagePaths?: string[]) => string + onOptimisticSendCanceled: (pendingId: string) => void + markDeliverySubmitted: (pendingId: string) => void +} { + const scope = useMemo(() => ({ paneKey, agent }), [agent, paneKey]) + const [pending, setPending] = useState(() => readPendingSendCache(scope)) + const { + failed: deliveryFailed, + clearFailure, + markSubmitted: markDeliverySubmitted + } = useNativeChatDeliveryRecovery({ + scope, + pending, + setPending, + messages, + promptSubmissions, + restoreMessage + }) + + useEffect(() => { + setPending(readPendingSendCache(scope)) + setWorkingInterrupted(false) + }, [scope, setWorkingInterrupted]) + + useEffect(() => { + setPending((current) => writePendingSendCache(scope, prunePendingSends(current, messages))) + }, [messages, scope]) + + const clearPending = useCallback(() => { + setPending(writePendingSendCache(scope, [])) + }, [scope]) + + const onOptimisticSend = useCallback( + (text: string, imagePaths?: string[]): string => { + clearFailure() + setWorkingInterrupted(false) + 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, + ...(text.trim().length > 0 && !imagePaths?.length + ? { + deliveryCheck: { + expectedDigest: nativeChatPromptDigest(text), + ...(promptSubmissions.at(-1) ? { baseline: promptSubmissions.at(-1) } : {}) + } + } + : {}), + ...(imagePaths ? { imagePaths } : {}) + } + setPending(appendPendingSendCache(scope, entry)) + return entry.id + }, + [clearFailure, messages, promptSubmissions, scope, setWorkingInterrupted] + ) + + const onOptimisticSendCanceled = useCallback( + (pendingId: string) => { + const next = readPendingSendCache(scope).filter((entry) => entry.id !== pendingId) + setPending(writePendingSendCache(scope, next)) + }, + [scope] + ) + + return { + pending, + deliveryFailed, + clearPending, + onOptimisticSend, + onOptimisticSendCanceled, + markDeliverySubmitted + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx index a9bb8c3be33b..66aae2158610 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' function handle(settleAfterMs = 500) { - return { cancel: vi.fn<() => void>(), settleAfterMs } + return { cancel: vi.fn<() => void>(), settleAfterMs, submitted: () => true } } describe('useNativeChatSendLifecycle', () => { @@ -92,4 +92,29 @@ describe('useNativeChatSendLifecycle', () => { resolveSettled() }) }) + + it('arms delivery recovery at submission before the send settles', async () => { + let resolveSubmission!: (submitted: boolean) => void + let resolveSettled!: () => void + const pending = { + ...handle(), + submission: new Promise((resolve) => { + resolveSubmission = resolve + }), + settled: new Promise((resolve) => { + resolveSettled = resolve + }) + } + const onPendingSendSubmitted = vi.fn() + const { result } = renderHook(() => + useNativeChatSendLifecycle('tab-1', 'pty-1', undefined, onPendingSendSubmitted) + ) + + act(() => result.current.trackPendingSend(pending, 'pending-1')) + expect(onPendingSendSubmitted).not.toHaveBeenCalled() + await act(async () => resolveSubmission(true)) + expect(onPendingSendSubmitted).toHaveBeenCalledWith('pending-1') + await act(async () => resolveSettled()) + expect(onPendingSendSubmitted).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts index c325cb2200ea..c064ea2fc1e7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts @@ -9,7 +9,8 @@ export type NativeChatSendLifecycle = { export function useNativeChatSendLifecycle( terminalTabId: string, targetPtyId: string | null, - onPendingSendCanceled?: (pendingId: string) => void + onPendingSendCanceled?: (pendingId: string) => void, + onPendingSendSubmitted?: (pendingId: string) => void ): NativeChatSendLifecycle { const pendingSendHandlesRef = useRef( new Map< @@ -30,24 +31,37 @@ export function useNativeChatSendLifecycle( } pendingSendHandlesRef.current.clear() }, [onPendingSendCanceled]) - const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => { - const entry = { - cleanupTimer: null as ReturnType | null, - ...(pendingId ? { pendingId } : {}) - } - pendingSendHandlesRef.current.set(handle, entry) - if (handle.settled) { - void handle.settled.then(() => { - if (pendingSendHandlesRef.current.get(handle) === entry) { - pendingSendHandlesRef.current.delete(handle) - } - }) - return - } - entry.cleanupTimer = setTimeout(() => { - pendingSendHandlesRef.current.delete(handle) - }, handle.settleAfterMs) - }, []) + const trackPendingSend = useCallback( + (handle: NativeChatSendHandle, pendingId?: string) => { + const entry = { + cleanupTimer: null as ReturnType | null, + ...(pendingId ? { pendingId } : {}) + } + pendingSendHandlesRef.current.set(handle, entry) + if (pendingId && handle.submission) { + void handle.submission.then((submitted) => { + if (submitted) { + onPendingSendSubmitted?.(pendingId) + } + }) + } + if (handle.settled) { + void handle.settled.then(() => { + if (pendingId && !handle.submission && handle.submitted?.() !== false) { + onPendingSendSubmitted?.(pendingId) + } + if (pendingSendHandlesRef.current.get(handle) === entry) { + pendingSendHandlesRef.current.delete(handle) + } + }) + return + } + entry.cleanupTimer = setTimeout(() => { + pendingSendHandlesRef.current.delete(handle) + }, handle.settleAfterMs) + }, + [onPendingSendSubmitted] + ) // Why: delayed Enter/image writes belong to the exact PTY target. A pane // swap or unmount must cancel them before that PTY can close or be reused. diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index c13cf20057a2..a194da6c70b3 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -4952,6 +4952,12 @@ describe('useIpcEvents agent status snapshot integration', () => { terminalHandle?: string launchToken?: string providerSession?: { key: 'session_id'; id: string } + promptSubmission?: { + streamId: string + sequence: number + digest: string + receivedAt: number + } orchestration?: { taskId?: string dispatchId?: string @@ -6073,6 +6079,12 @@ describe('useIpcEvents agent status snapshot integration', () => { state: 'working' as const, prompt: 'p', agentType: 'claude', + promptSubmission: { + streamId: 'stream-1', + sequence: 7, + digest: `sha256:${'a'.repeat(64)}`, + receivedAt: 1_700_000_000_000 + }, receivedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 } @@ -6155,7 +6167,17 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatus).toHaveBeenCalledTimes(1) expect(setAgentStatus).toHaveBeenCalledWith( FUTURE_PANE_KEY, - expect.objectContaining({ state: 'working', prompt: 'p', agentType: 'claude' }), + expect.objectContaining({ + state: 'working', + prompt: 'p', + agentType: 'claude', + promptSubmission: { + streamId: 'stream-1', + sequence: 7, + digest: `sha256:${'a'.repeat(64)}`, + receivedAt: 1_700_000_000_000 + } + }), 'Future Tab', { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, expectWorktreeRouting('wt-1'), diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index bed3e4e86c89..578771d3d443 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3213,11 +3213,14 @@ export function useIpcEvents(): void { const statusPayloadWithTurnBoundary = data.promptInteractionKey ? { ...statusPayload, promptInteractionKey: data.promptInteractionKey } : statusPayload + const statusPayloadWithPromptSubmission = data.promptSubmission + ? { ...statusPayloadWithTurnBoundary, promptSubmission: data.promptSubmission } + : statusPayloadWithTurnBoundary // Why: hydrated-unconfirmed provenance is envelope data the payload whitelist above drops; re-thread it or freshness gates confirm restored rows. const statusPayloadWithProvenance = data.restoredUnconfirmed === true - ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } - : statusPayloadWithTurnBoundary + ? { ...statusPayloadWithPromptSubmission, restoredUnconfirmed: true } + : statusPayloadWithPromptSubmission const identity = resolveAgentStatusIdentity({ existing: existingStatus ? { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3f9400767367..78514e642fe8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14823,6 +14823,10 @@ "status": { "responding": "Agent is responding" }, + "deliveryRecovery": { + "title": "Message wasn’t sent", + "description": "The terminal may be waiting for input. Your message is still in the composer." + }, "jumpToLatest": "Jump to latest", "toggle": { "showTerminal": "Show terminal", diff --git a/src/renderer/src/store/slices/agent-prompt-submission-history.ts b/src/renderer/src/store/slices/agent-prompt-submission-history.ts new file mode 100644 index 000000000000..bee70d5d6e36 --- /dev/null +++ b/src/renderer/src/store/slices/agent-prompt-submission-history.ts @@ -0,0 +1,23 @@ +import { + AGENT_PROMPT_SUBMISSION_HISTORY_MAX, + type AgentPromptSubmissionOccurrence +} from '../../../../shared/agent-status-types' + +export function appendAgentPromptSubmission( + existing: AgentPromptSubmissionOccurrence[] | undefined, + occurrence: AgentPromptSubmissionOccurrence | undefined +): AgentPromptSubmissionOccurrence[] | undefined { + if (!occurrence) { + return existing + } + const previous = existing ?? [] + if ( + previous.some( + (candidate) => + candidate.streamId === occurrence.streamId && candidate.sequence === occurrence.sequence + ) + ) { + return existing + } + return [...previous, occurrence].slice(-AGENT_PROMPT_SUBMISSION_HISTORY_MAX) +} diff --git a/src/renderer/src/store/slices/agent-status-prompt-submission.test.ts b/src/renderer/src/store/slices/agent-status-prompt-submission.test.ts new file mode 100644 index 000000000000..0508f76865cc --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-prompt-submission.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_PROMPT_SUBMISSION_HISTORY_MAX } from '../../../../shared/agent-status-types' +import { createTestStore } from './store-test-helpers' + +describe('agent prompt submission occurrences', () => { + it('preserves a bounded deduped history through later status updates', () => { + const store = createTestStore() + const paneKey = 'tab-1:1' + + for (let sequence = 1; sequence <= AGENT_PROMPT_SUBMISSION_HISTORY_MAX + 2; sequence += 1) { + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: `prompt ${sequence}`, + agentType: 'codex', + promptSubmission: { + streamId: 'stream-1', + sequence, + digest: `sha256:${String(sequence).padStart(64, '0')}`, + receivedAt: sequence + } + }) + } + + const bounded = store.getState().agentStatusByPaneKey[paneKey].promptSubmissions + expect(bounded?.map((occurrence) => occurrence.sequence)).toEqual( + Array.from({ length: AGENT_PROMPT_SUBMISSION_HISTORY_MAX }, (_, index) => index + 3) + ) + + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: 'tool update', + agentType: 'codex', + toolName: 'Read' + }) + expect(store.getState().agentStatusByPaneKey[paneKey].promptSubmissions).toBe(bounded) + + const duplicate = bounded?.at(-1) + if (!duplicate) { + throw new Error('expected bounded prompt submission history') + } + store.getState().setAgentStatus(paneKey, { + state: 'working', + prompt: 'duplicate delivery', + agentType: 'codex', + promptSubmission: duplicate + }) + expect(store.getState().agentStatusByPaneKey[paneKey].promptSubmissions).toBe(bounded) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index fcf39188136f..d42524bb938b 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -5,6 +5,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, AGENT_STATE_HISTORY_MAX, agentSubagentsEqual, + type AgentPromptSubmissionOccurrence, type AgentStateHistoryEntry, type AgentStatusEntry, type AgentStatusOrchestrationContext, @@ -46,6 +47,7 @@ import { transferAgentPaneAuthorityAlias } from './agent-pane-authority' import { createFreshnessScheduler } from './agent-status-freshness-scheduler' +import { appendAgentPromptSubmission } from './agent-prompt-submission-history' /** Snapshot of a finished/vanished agent status entry, kept so the dashboard and sidebar hover * keep showing the completion until the user clicks the worktree. `worktreeId` is stamped at @@ -155,6 +157,7 @@ export type AgentStatusSlice = { payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext promptInteractionKey?: string + promptSubmission?: AgentPromptSubmissionOccurrence restoredUnconfirmed?: boolean }, terminalTitle?: string, @@ -1889,6 +1892,10 @@ export const createAgentStatusSlice: StateCreator { expect(event!.payload.agentType).toBe('claude') }) + it.each(['claude', 'codex'] as const)( + 'hashes the full raw %s prompt only on UserPromptSubmit', + (source) => { + const prompt = ` ${'multiline prompt '.repeat(20)}\nemoji: 🐋\n${'tail'.repeat(80)} ` + const submitted = normalizeHookPayload( + state, + source, + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt } + }, + 'production' + ) + const tool = normalizeHookPayload( + state, + source, + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'PreToolUse', tool_name: 'Read' } + }, + 'production' + ) + + expect(submitted?.payload.prompt.length).toBe(200) + expect(submitted?.submittedPromptDigest).toBe( + `sha256:${createHash('sha256').update(prompt).digest('hex')}` + ) + expect(tool?.submittedPromptDigest).toBeUndefined() + } + ) + it('normalizes Gemini BeforeTool to working with tool fields', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 985f95b1876a..6460ebe8706b 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -325,6 +325,8 @@ export type AgentHookEventPayload = { promptInteractionKey?: string /** Raw agent hook event name, used by main-process transition guards. */ hookEventName?: string + /** SHA-256 of a live UserPromptSubmit's full raw prompt before preview truncation. */ + submittedPromptDigest?: string /** Claude's provider-owned user-prompt UUID. */ providerPromptId?: string /** Active Claude compact generation, keyed by provider prompt identity. */ @@ -4161,6 +4163,14 @@ export function normalizeHookPayload( } const extractedPrompt = extractPromptText(hookPayload as Record) const promptText = extractedPrompt.text + const rawSubmittedPrompt = + extractedPrompt.source && typeof hookPayloadRecord[extractedPrompt.source] === 'string' + ? (hookPayloadRecord[extractedPrompt.source] as string) + : promptText + const submittedPromptDigest = + (source === 'codex' || source === 'claude') && eventName === 'UserPromptSubmit' + ? `sha256:${createHash('sha256').update(rawSubmittedPrompt).digest('hex')}` + : undefined let resolvedPromptText = promptText let hasTranscriptPromptEvidence = false // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently misrouting. @@ -4310,6 +4320,7 @@ export function normalizeHookPayload( ), promptInteractionKey, hookEventName: typeof eventName === 'string' ? eventName : undefined, + submittedPromptDigest, providerPromptId, compactTrigger, toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 128397551bc2..ab4ba34c449e 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -84,6 +84,8 @@ export type AgentHookRelayEnvelope = { promptInteractionKey?: string /** Hook discriminator preserved for main-process transition rules. */ hookEventName?: string + /** Full raw prompt digest for live delivery acknowledgement. */ + submittedPromptDigest?: string /** Claude's provider-owned user-prompt UUID. */ providerPromptId?: string /** Active Claude compact generation, keyed by provider prompt identity. */ diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 0d902ecb0fc1..6d9a0bbb3bc1 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -86,6 +86,20 @@ export type AgentSubagentSnapshot = { startedAt: number } +/** Exact, transient evidence that a pane accepted a user prompt through a provider hook. */ +export type AgentPromptSubmissionOccurrence = { + /** Identifies one main-process hook-server lifetime. */ + streamId: string + /** Monotonic within `streamId`; globally ordered across panes in that stream. */ + sequence: number + /** SHA-256 of the full raw prompt before status-preview normalization. */ + digest: string + /** Main-process receive time for restart-safe ordering. */ + receivedAt: number +} + +export const AGENT_PROMPT_SUBMISSION_HISTORY_MAX = 8 + export type AgentStatusEntry = { state: AgentStatusState /** The user's most recent prompt. Cached across the turn — later tool-use events @@ -137,6 +151,8 @@ export type AgentStatusEntry = { providerSession?: AgentProviderSessionMetadata /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string + /** Recent live prompt-submit occurrences for native-chat delivery acknowledgement. */ + promptSubmissions?: AgentPromptSubmissionOccurrence[] /** True for a nonterminal state hydrated from last-status.json with no live hook since: * the transition may have been missed while no receiver was up, so freshness gates * treat the row as stale immediately. Cleared by any accepted live event. */ @@ -229,6 +245,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { providerSessionOnly?: boolean /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string + /** Present only on a live accepted UserPromptSubmit event, never restored from disk. */ + promptSubmission?: AgentPromptSubmissionOccurrence /** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */ restoredUnconfirmed?: boolean }