From 707d3dc96b8a5f8588535491349e6eb5cc587acb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:04:21 -0700 Subject: [PATCH] fix(chat): decode Claude pastes and report terminal delivery uncertainty (#23788) * fix(chat): decode Claude pastes and track terminal delivery uncertainty Keep queued prompts pending while the existing agent status reports work, and check fresh history after a later idle fact. Preserve draft text and distinguish write rejection from unconfirmed delivery. Co-authored-by: Harshul Rathod * fix(native-chat): break the observed-send import cycle and keep renderer tests out of main The observed-send path imported the clear helpers from native-chat-runtime-send, which imports it back. Move the input-clear layer into its own module both use. The Claude paste decoder test imported the renderer pending module from src/main, which the node typecheck project cannot see; the echo-retirement assertions now live in a renderer test. * fix(native-chat): still submit a Claude chat send whose write acknowledgment was lost A remote write whose acknowledgment is lost (timeout, dropped link) is not a refusal, but the observed path stopped there and never sent Enter, leaving a body that did land sitting unsubmitted in Claude's input line until the next send's clear wiped it. Continue to the next write without re-sending the bytes, as the unobserved path always did. * perf(native-chat): keep terminal Chat pending delivery from re-rendering every row The delivery notices were merged into a new Map on every render, which invalidated the transcript row context and re-rendered every memoized row on each stream update. The pending hook also wrote a fresh array on every status ping and prune pass even when nothing changed, and the phone mapped its pending list on every render, rebuilding the chat list data. Memoize the merged notices, skip no-op pending writes, and memoize the phone's rendered pending list. The phone also skips a transcript read when no send is due. * fix(native-chat): never flag a queued Claude send, and flag one an idle Claude never starts Two gaps in when terminal Chat calls a Claude send "Delivery unconfirmed": A prompt sent while Claude is mid-turn is queued, and Claude folds it into the running turn as a queued-command record. The transcript reader drops those records, so once the turn ended the prompt Claude did run read as unconfirmed, inviting a duplicate resend. A send made while the agent is busy is now never checked; it keeps the pending behaviour it had before. A prompt sent to an idle Claude that never starts a turn (Claude exited to the shell, or the paste went nowhere) left the status at the same idle fact forever, so the check never ran and the bubble stayed pending. An idle agent starts a turn on a delivered prompt at once, so a send whose idle status is unchanged after the existing 20 s bound is now checked against a fresh transcript read. * fix(native-chat): add the delivery notice strings to the English catalog The Dismiss action's translate key was missing from en.json, which fails the localization catalog and extraction gates. The desktop "Message not sent" and "Delivery unconfirmed" notices were hard-coded English; route them through translate with the same wording. * fix(mobile): sync the held-send refs after commit instead of during render Moving the acknowledgment-loss hold into its own hook made its render-time ref writes new lines, which the React Doctor changed-lines gate blocks. Held sends report after commit, so syncing those refs in a layout effect keeps them current where they are read. * fix(native-chat): report only definite terminal Chat send outcomes The delivery rule inferred "Delivery unconfirmed" from "the turn ended and the transcript has no matching row". Claude records a prompt sent mid-turn only as a queued-command attachment, which the transcript reader drops, so that rule flagged prompts Claude had answered. It also never fired for an idle Claude that lost the write, because no newer turn arrives. Keep only facts the transport reports: - a refused write reads "Message not sent", keeps its text, and can be dismissed; - a lost write acknowledgment holds the echo for 20 s, the phone's existing rule, then reads "Delivery unconfirmed" unless its row has landed. An ordinary send, including one Claude queues mid-turn, stays pending as before. Remove the agent-status subscription, the status-epoch origin, the fresh 500-row transcript read, the confirmed state and the no-status clock. The phone already implements this rule, so its changes revert to main; only a test for old-host paste envelopes remains. * fix(i18n): translate the terminal Chat delivery notices Add the Dismiss, "Message not sent" and "Delivery unconfirmed" strings to the es, fr, ja, ko and zh catalogs, reusing each catalog's existing Dismiss wording. * fix(native-chat): let a resend replace its failed terminal Chat echo A "Message not sent" or "Delivery unconfirmed" echo kept its transcript occurrence, so resending the same text numbered the resend as the second copy: the one landed row retired the failed echo and pinned the resend below the reply forever. Appending a send now drops a failed echo with the same content first. * fix(native-chat): unwrap a Claude paste that quotes pasted_content tags The envelope parser refused any body containing a pasted_content tag, so a pasted prompt that itself quotes one (a transcript excerpt, or code that handles these tags) kept its wrapper and its echo stayed pinned below the reply. Claude's per-paste id exists to disambiguate exactly that; only a same-id tag inside the body is now ambiguous. Wrappers without an id keep the strict rule. * test(native-chat): pin which terminal Chat sends observe write outcomes Only a Claude chat send (text or images) reports a refused or unacknowledged write to its pending echo; other agents and slash commands keep the unobserved write path exactly as before. * fix(native-chat): keep failed terminal Chat sends through Stop Stop cleared every optimistic echo, including a "Message not sent" or "Delivery unconfirmed" bubble whose send had already settled. Stop cannot affect that send, and the bubble is the only place its text stays copyable, so it now survives until the user dismisses or resends it. Also moves the observed-send import below the file header comment. --------- Co-authored-by: Harshul Rathod --- ...native-chat-pending-pasted-content.test.ts | 37 +++++ ...ine-decoders-claude-pasted-content.test.ts | 53 ++++++ .../transcript-line-decoders-claude.ts | 3 +- .../native-chat/NativeChatComposer.tsx | 2 + .../native-chat/NativeChatMessageRow.tsx | 11 +- .../native-chat/NativeChatResolvedView.tsx | 70 +++----- .../native-chat/native-chat-composer-types.ts | 9 + .../native-chat/native-chat-input-clear.ts | 89 ++++++++++ .../native-chat-observed-send.test.ts | 69 ++++++++ .../native-chat/native-chat-observed-send.ts | 69 ++++++++ ...native-chat-pending-pasted-content.test.ts | 29 ++++ .../native-chat/native-chat-pending.ts | 10 +- .../native-chat-runtime-image-send.ts | 21 ++- ...ive-chat-runtime-send-launch-draft.test.ts | 6 +- .../native-chat-runtime-send.test.ts | 4 +- .../native-chat/native-chat-runtime-send.ts | 98 ++--------- .../use-native-chat-pending-delivery.test.tsx | 137 +++++++++++++++ .../use-native-chat-pending-delivery.ts | 157 ++++++++++++++++++ ...use-native-chat-pty-composer-send.test.tsx | 84 ++++++++++ .../use-native-chat-pty-composer-send.ts | 23 ++- src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + src/shared/claude-pasted-content.ts | 31 ++++ .../native-chat-image-transcript-markers.ts | 8 +- 28 files changed, 893 insertions(+), 145 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts create mode 100644 src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-input-clear.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-observed-send.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-observed-send.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx create mode 100644 src/shared/claude-pasted-content.ts diff --git a/mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts b/mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts new file mode 100644 index 00000000000..3ea7171c6bb --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement' + +const boundary: NativeChatMessage = { + id: 'boundary', + role: 'assistant', + blocks: [{ type: 'text', text: 'before' }], + timestamp: null, + source: 'transcript' +} + +// An old host still sends Claude's raw paste envelope; the shared normalizer must match it. +it('retires an echo against an old-host wrapped row and keeps the second identical send', () => { + const text = 'one\ntwo' + const echo: NativeChatMessage = { + ...boundary, + id: 'user', + role: 'user', + blocks: [{ type: 'text', text: `\n${text}\n` }] + } + const reply: NativeChatMessage = { ...boundary, id: 'reply' } + const entry = { + id: 'one', + text, + expectedOccurrence: 1, + baselineTailMessageId: 'boundary', + baselineResolved: true + } + expect( + retireLandedMobileNativeChatPending( + [boundary, echo, reply], + [entry, { ...entry, id: 'two', expectedOccurrence: 2 }], + new Set() + ).map((item) => item.id) + ).toEqual(['two']) +}) diff --git a/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts new file mode 100644 index 00000000000..3711bdf038c --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' +import { normalizeNativeChatUserText } from '../../shared/native-chat-image-transcript-markers' + +const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.' +const wrapped = `\n\n\n${prompt}\n\n` +function decode(text: string, role = 'user', array = false) { + return decodeClaudeTranscriptLine( + JSON.stringify({ + type: role, + uuid: 'user', + message: { content: array ? [{ type: 'text', text }] : text } + }), + 'fallback' + )! +} + +describe('Claude whole-block paste envelope', () => { + it.each([false, true])('decodes host user content (array=%s)', (array) => { + expect(decode(wrapped, 'user', array).blocks).toEqual([{ type: 'text', text: prompt }]) + }) + it('accepts CRLF and a wrapper without ids', () => { + expect(decode(`\r\n${prompt}\r\n`).blocks).toEqual([ + { type: 'text', text: prompt } + ]) + }) + it('decodes a paste whose own text quotes differently identified or bare tags', () => { + const quoted = `\nx\n\n` + expect( + decode(`\n${quoted}\n`).blocks + ).toEqual([{ type: 'text', text: quoted }]) + }) + it.each([ + `Explain this:\n${wrapped}`, + `\n\nx\n`, + `\n\nx\n\n`, + wrapped.replace('id="7e64">\n', 'id="other">\n'), + wrapped.replace('', ''), + wrapped.replace('', ''), + `${wrapped}\n${wrapped}` + ])('preserves prose and nonmatching envelopes', (text) => { + expect(decode(text).blocks).toEqual([{ type: 'text', text }]) + }) + it('leaves assistant text untouched', () => { + expect(decode(wrapped, 'assistant').blocks).toEqual([{ type: 'text', text: wrapped }]) + }) + it('handles a large prompt in linear passes', () => { + const text = 'line\n'.repeat(50_000) + expect( + normalizeNativeChatUserText(`\n${text}\n`) + ).toBe(text.trim().replace(/\s+/g, ' ')) + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index 5202035bbc6..bc7a988e79e 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -15,6 +15,7 @@ import { } from '../ai-vault/session-scanner-values' import { imageSourcePathFromText } from '../../shared/native-chat-image-transcript-markers' import { claudeContentBlocks } from './transcript-record-blocks' +import { unwrapClaudePastedContentBlock } from '../../shared/claude-pasted-content' import { claudeInterruptedMessageId } from './transcript-turn-markers' const MAX_EDIT_PATCH_HUNKS = 40 @@ -124,7 +125,7 @@ export function decodeClaudeTranscriptLine( return { id: messageId ?? fallbackId, role: claudeMessageRole(role, blocks), - blocks, + blocks: role === 'user' ? blocks.map(unwrapClaudePastedContentBlock) : blocks, timestamp, source: 'transcript' } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 87ad862f5ec..d9fb9f1ec2d 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -60,6 +60,7 @@ const NativeChatComposerPane = forwardRef void } +export type NativeChatDeliveryNotice = { + text: string + onRetry?: () => void + onDismiss?: () => void +} /** One message: its prose first, then a collapsible run folding all of the * turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a @@ -185,6 +189,11 @@ export const MessageRow = memo(function MessageRow({ {deliveryNotice ? (
{deliveryNotice.text} + {deliveryNotice.onDismiss ? ( + + ) : null} {deliveryNotice.onRetry ? (
@@ -426,7 +393,8 @@ export function NativeChatResolvedView({ isWorking={isWorking} onStop={stopAgent} onOptimisticSend={onOptimisticSend} - onOptimisticSendCanceled={onOptimisticSendCanceled} + onOptimisticSendCanceled={delivery.cancel} + optimisticSendOutcome={delivery} onSlashCommand={onSlashCommand} onSwitchToTerminal={onSwitchToTerminal} readTerminalScreen={readTerminalScreen} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 1123570f830..922d16924f3 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 @@ -38,6 +38,13 @@ export type NativeChatStructuredComposerTransport = { runtimeEnvironmentId: string | null } +export type NativeChatOptimisticSendOutcome = { + /** The host refused the write: mark the echo "Message not sent". */ + reject: (pendingId: string) => void + /** The write acknowledgment was lost: hold the echo, then flag it unconfirmed. */ + holdUnconfirmed: (pendingId: string) => void +} + export type NativeChatComposerProps = { /** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */ terminalTabId: string @@ -54,6 +61,8 @@ export type NativeChatComposerProps = { onStop?: () => void /** Render an optimistic echo until the real transcript turn lands. */ onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined + /** Settle an optimistic echo whose write was refused or never acknowledged. */ + optimisticSendOutcome?: NativeChatOptimisticSendOutcome /** Remove an optimistic echo when its delayed submit is canceled. */ onOptimisticSendCanceled?: (pendingId: string) => void /** Record a dispatched slash command that does not create a chat turn. */ diff --git a/src/renderer/src/components/native-chat/native-chat-input-clear.ts b/src/renderer/src/components/native-chat/native-chat-input-clear.ts new file mode 100644 index 00000000000..0f15c98b54e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-input-clear.ts @@ -0,0 +1,89 @@ +// Clearing the agent's unsubmitted input line before a chat send writes its body. +import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear' + +// Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends +// start from an empty line so a prior cancelled paste cannot glue onto the next +// prompt. Not used on verified option commands — model-switch confirmation +// observes the PTY and Ctrl+U can miss confirmation markers. +// +// One Ctrl+U only ever clears ONE logical line. When the line may hold an +// injected multi-line launch draft, callers pass `clearInput` built by +// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured +// 2N-1 law and the sequences that do NOT work. +export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15' + +/** Gap before re-reading the agent's input line to confirm a clear landed. */ +export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140 + +export type NativeChatSendOptions = { + /** The host refused a write; nothing after it was sent. */ + onWriteRejected?: () => void + /** A write's acknowledgment was lost; it may or may not have landed. */ + onWriteUnconfirmed?: () => void + /** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */ + clearInput?: string + /** + * Observed check that the input line is now empty. + * Supplied only for launch-draft replacement; when it reports "not cleared" + * the send widens to a maximal burst before writing the body rather than + * pasting on top of residue. + */ + confirmCleared?: () => boolean +} + +type RuntimeSettings = ReturnType + +export function clearUnsubmittedAgentInput( + settings: RuntimeSettings, + ptyId: string, + options?: NativeChatSendOptions +): void { + sendRuntimePtyInput( + settings, + ptyId, + options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + 'driving' + ) +} + +/** + * Run `writeBody` once the input line is clear. With no `confirmCleared` the + * clear is a plain in-order write on the same byte stream, so the TUI consumes + * it before the body and the body follows immediately. With one, we pause to + * actually look at the agent's input line, and widen to a maximal burst when the + * draft is still visible — the injected line count is only a lower bound on what + * the buffer holds, since the user can type into the TUI directly. + */ +export function clearThenWrite( + settings: RuntimeSettings, + ptyId: string, + options: NativeChatSendOptions | undefined, + delay: (ms: number, fn: () => void) => void, + writeBody: () => void +): void { + clearUnsubmittedAgentInput(settings, ptyId, options) + const confirmCleared = options?.confirmCleared + if (!confirmCleared) { + writeBody() + return + } + delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => { + let cleared = false + try { + cleared = confirmCleared() + } catch { + // An unreadable terminal is unconfirmed; the maximal clear remains safe. + } + if (!cleared) { + sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving') + } + writeBody() + }) +} + +/** Extra time a send needs when it stops to confirm the clear before the body. */ +export function clearConfirmDurationMs(options?: NativeChatSendOptions): number { + return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0 +} diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts new file mode 100644 index 00000000000..666e695ac4c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +const io = vi.hoisted(() => ({ write: vi.fn(), verified: vi.fn() })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + sendRuntimePtyInput: io.write, + sendRuntimePtyInputVerified: io.verified +})) +import { + sendNativeChatMessage, + resetNativeChatPtySendQueuesForTests +} from './native-chat-runtime-send' +import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +beforeEach(() => { + vi.useFakeTimers() + resetNativeChatPtySendQueuesForTests() + io.write.mockReset().mockReturnValue(true) + io.verified.mockReset().mockResolvedValue(true) +}) +afterEach(() => { + resetNativeChatPtySendQueuesForTests() + vi.useRealTimers() +}) +it('observes a refused write, skips Enter, and releases the queue for the next user action', async () => { + const rejected = vi.fn() + io.verified.mockResolvedValueOnce(false) + sendNativeChatMessage(null, 'pane', 'refused', { onWriteRejected: rejected }) + await vi.advanceTimersByTimeAsync(1000) + expect(rejected).toHaveBeenCalledOnce() + expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + buildNativeChatPasteBytes('refused') + ]) + sendNativeChatMessage(null, 'pane', 'next', { onWriteRejected: rejected }) + await vi.advanceTimersByTimeAsync(1000) + expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + buildNativeChatPasteBytes('refused'), + buildNativeChatPasteBytes('next'), + NATIVE_CHAT_SUBMIT + ]) +}) +it('reports a lost acknowledgment once as unconfirmed, never as rejection, and still submits', async () => { + const rejected = vi.fn() + const unconfirmed = vi.fn() + io.verified.mockRejectedValueOnce(new Error('lost acknowledgment')) + io.verified.mockRejectedValueOnce(new Error('lost acknowledgment')) + sendNativeChatMessage(null, 'pane', 'uncertain', { + onWriteRejected: rejected, + onWriteUnconfirmed: unconfirmed + }) + await vi.advanceTimersByTimeAsync(120000) + expect(rejected).not.toHaveBeenCalled() + expect(unconfirmed).toHaveBeenCalledOnce() + expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + buildNativeChatPasteBytes('uncertain'), + NATIVE_CHAT_SUBMIT + ]) +}) +it('serializes rapid sends through their acknowledged Enter and preserves the paste delay', async () => { + const rejected = vi.fn() + sendNativeChatMessage(null, 'pane', 'one', { onWriteRejected: rejected }) + sendNativeChatMessage(null, 'pane', 'two', { onWriteRejected: rejected }) + await vi.advanceTimersByTimeAsync(499) + expect(io.verified).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(501) + expect(io.verified.mock.calls.map((call) => call[2])).toEqual([ + buildNativeChatPasteBytes('one'), + NATIVE_CHAT_SUBMIT, + buildNativeChatPasteBytes('two'), + NATIVE_CHAT_SUBMIT + ]) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.ts new file mode 100644 index 00000000000..2de4b557c80 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.ts @@ -0,0 +1,69 @@ +import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue' +import { + clearConfirmDurationMs, + clearThenWrite, + clearUnsubmittedAgentInput, + type NativeChatSendOptions +} from './native-chat-input-clear' + +/** Observe write refusals without mistaking transport success for provider acceptance. */ +export function sendNativeChatObservedWrites( + settings: ReturnType, + ptyId: string, + writes: readonly { data: string; delayBeforeMs: number }[], + options: NativeChatSendOptions +) { + return enqueueNativeChatPtySend( + ptyId, + writes.reduce((total, write) => total + write.delayBeforeMs, 0) + + clearConfirmDurationMs(options), + ({ isCancelled, delay, markSubmitted }) => { + let reportedUnconfirmed = false + const writeAt = (index: number): void => { + if (isCancelled()) { + return + } + const write = writes[index] + if (!write) { + markSubmitted() + return + } + const send = (): void => { + if (isCancelled()) { + return + } + void sendRuntimePtyInputVerified(settings, ptyId, write.data, 'driving') + .then((accepted) => { + if (isCancelled()) { + return + } + if (!accepted) { + options.onWriteRejected?.() + markSubmitted() + return + } + writeAt(index + 1) + }) + // A lost acknowledgment is not a refusal: never re-send these bytes, but still submit + // a body that may have landed, as the unobserved path does. + .catch(() => { + if (!reportedUnconfirmed) { + reportedUnconfirmed = true + options.onWriteUnconfirmed?.() + } + writeAt(index + 1) + }) + } + if (write.delayBeforeMs > 0) { + delay(write.delayBeforeMs, send) + } else { + send() + } + } + clearThenWrite(settings, ptyId, options, delay, () => writeAt(0)) + }, + { onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) } + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts new file mode 100644 index 00000000000..a1b1bbe5f5d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { pendingSendsAsMessages, prunePendingSends } from './native-chat-pending' + +const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.' +const wrapped = `\n\n\n${prompt}\n\n` +function message(id: string, role: NativeChatMessage['role'], text: string): NativeChatMessage { + return { id, role, source: 'transcript', timestamp: null, blocks: [{ type: 'text', text }] } +} + +describe('pending echoes against Claude pasted-content rows', () => { + // A new host delivers the decoded prompt; an old host still delivers the raw envelope. + it.each([ + ['new host', prompt], + ['old host', wrapped] + ])('retires the echo with %s rows and keeps a second identical send', (_host, row) => { + const history = [ + message('boundary', 'assistant', 'earlier'), + message('user', 'user', row), + message('reply', 'assistant', 'answer') + ] + const pending = [{ id: 'p1', text: prompt, sentAt: 999_000, afterMessageId: 'boundary' }] + expect(pendingSendsAsMessages(pending, history)).toEqual([]) + expect(prunePendingSends(pending, history)).toEqual([]) + expect( + prunePendingSends([...pending, { ...pending[0]!, id: 'p2', matchingOccurrence: 2 }], history) + ).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 406fee3b579..680ca687e77 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -25,6 +25,10 @@ import { export type NativeChatPendingSend = { /** Renderer-minted id, unique per send, used as the list key. */ id: string + /** Definite send outcome; absent while the send is simply awaiting its transcript row. */ + delivery?: 'unconfirmed' | 'rejected' + /** When a write's acknowledgment was lost, so a remount keeps the original hold deadline. */ + writeUnconfirmedAt?: number /** The exact draft text the user submitted. */ text: string /** Image paths that were sent through the TUI image attachment paste path. */ @@ -80,7 +84,11 @@ export function appendPendingSendCache( scope: NativeChatPendingSendScope, entry: NativeChatPendingSend ): NativeChatPendingSend[] { - const existing = readPendingSendCache(scope) + const contentKey = nativeChatPendingContentKey(entry) + // Why: a resend replaces its failed copy; kept, that copy would claim the resend's row and pin it. + const existing = readPendingSendCache(scope).filter( + (candidate) => !candidate.delivery || nativeChatPendingContentKey(candidate) !== contentKey + ) const next = assignNativeChatPendingOccurrence(existing, entry) return writePendingSendCache(scope, [...existing, next]) } diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index aba65acf8db..283a437e449 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -1,3 +1,4 @@ +import { sendNativeChatObservedWrites } from './native-chat-observed-send' import { agentImagePasteWrites, formatAgentImagePath } from '../../../../shared/agent-image-paste' import type { AgentType } from '../../../../shared/agent-status-types' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' @@ -13,10 +14,9 @@ import { clearConfirmDurationMs, clearThenWrite, clearUnsubmittedAgentInput, - sendNativeChatMessage, - type NativeChatSendHandle, type NativeChatSendOptions -} from './native-chat-runtime-send' +} from './native-chat-input-clear' +import { sendNativeChatMessage, type NativeChatSendHandle } from './native-chat-runtime-send' export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 @@ -34,6 +34,21 @@ export function sendNativeChatMessageWithImageAttachments( return sendNativeChatMessage(settings, ptyId, text, options) } const trimmedText = text.trim() + if (options?.onWriteRejected) { + const writes = agentImagePasteWrites( + agent, + imagePaths.map((path) => buildNativeChatImagePasteBytes(formatAgentImagePath(agent, path))), + trimmedText.length > 0 + ).map((data) => ({ data, delayBeforeMs: 0 })) + if (trimmedText) { + writes.push({ + data: buildNativeChatPasteBytes(text), + delayBeforeMs: NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + }) + } + writes.push({ data: NATIVE_CHAT_SUBMIT, delayBeforeMs: NATIVE_CHAT_SUBMIT_DELAY_MS }) + return sendNativeChatObservedWrites(settings, ptyId, writes, options) + } const durationMs = (trimmedText.length > 0 ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts index 1016b466ccb..a10bb87cd3b 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -11,12 +11,14 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ })) import { - NATIVE_CHAT_CLEAR_CONFIRM_MS, - NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, NATIVE_CHAT_SUBMIT_DELAY_MS, resetNativeChatPtySendQueuesForTests, sendNativeChatMessage } from './native-chat-runtime-send' +import { + NATIVE_CHAT_CLEAR_CONFIRM_MS, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT +} from './native-chat-input-clear' import { NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, sendNativeChatMessageWithImageAttachments diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 79c28599858..9e6a8ff2b0f 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -19,9 +19,9 @@ import { resetNativeChatPtySendQueuesForTests, NATIVE_CHAT_SUBMIT_DELAY_MS, NATIVE_CHAT_QUESTION_STEP_MS, - NATIVE_CHAT_ADVANCE_BUFFER_MS, - NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT + NATIVE_CHAT_ADVANCE_BUFFER_MS } from './native-chat-runtime-send' +import { NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT } from './native-chat-input-clear' import { NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, sendNativeChatMessageWithImageAttachments diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 1eb7a676103..7187148ed41 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 @@ -2,13 +2,19 @@ // body, then Enter as a SEPARATE delayed pty write. Kept apart from the pure // byte builders in native-chat-send.ts so those stay IO-free and unit-testable. +import { sendNativeChatObservedWrites } from './native-chat-observed-send' import { sendRuntimePtyInput, sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' -import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear' +import { + clearConfirmDurationMs, + clearThenWrite, + clearUnsubmittedAgentInput, + type NativeChatSendOptions +} from './native-chat-input-clear' import { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, @@ -29,32 +35,6 @@ import { export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } export { resetNativeChatPtySendQueuesForTests } -// Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends -// start from an empty line so a prior cancelled paste cannot glue onto the next -// prompt. Not used on verified option commands — model-switch confirmation -// observes the PTY and Ctrl+U can miss confirmation markers. -// -// One Ctrl+U only ever clears ONE logical line. When the line may hold an -// injected multi-line launch draft, callers pass `clearInput` built by -// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured -// 2N-1 law and the sequences that do NOT work. -export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15' - -/** Gap before re-reading the agent's input line to confirm a clear landed. */ -export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140 - -export type NativeChatSendOptions = { - /** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */ - clearInput?: string - /** - * Observed check that the input line is now empty. - * Supplied only for launch-draft replacement; when it reports "not cleared" - * the send widens to a maximal burst before writing the body rather than - * pasting on top of residue. - */ - confirmCleared?: () => boolean -} - /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ export type NativeChatSendHandle = { @@ -67,59 +47,6 @@ export type NativeChatSendHandle = { type RuntimeSettings = ReturnType -export function clearUnsubmittedAgentInput( - settings: RuntimeSettings, - ptyId: string, - options?: NativeChatSendOptions -): void { - sendRuntimePtyInput( - settings, - ptyId, - options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, - 'driving' - ) -} - -/** - * Run `writeBody` once the input line is clear. With no `confirmCleared` the - * clear is a plain in-order write on the same byte stream, so the TUI consumes - * it before the body and the body follows immediately. With one, we pause to - * actually look at the agent's input line, and widen to a maximal burst when the - * draft is still visible — the injected line count is only a lower bound on what - * the buffer holds, since the user can type into the TUI directly. - */ -export function clearThenWrite( - settings: RuntimeSettings, - ptyId: string, - options: NativeChatSendOptions | undefined, - delay: (ms: number, fn: () => void) => void, - writeBody: () => void -): void { - clearUnsubmittedAgentInput(settings, ptyId, options) - const confirmCleared = options?.confirmCleared - if (!confirmCleared) { - writeBody() - return - } - delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => { - let cleared = false - try { - cleared = confirmCleared() - } catch { - // An unreadable terminal is unconfirmed; the maximal clear remains safe. - } - if (!cleared) { - sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving') - } - writeBody() - }) -} - -/** Extra time a send needs when it stops to confirm the clear before the body. */ -export function clearConfirmDurationMs(options?: NativeChatSendOptions): number { - return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0 -} - /** * Chat message path: * 1. clear any unsubmitted TUI line @@ -134,6 +61,17 @@ export function sendNativeChatMessage( text: string, options?: NativeChatSendOptions ): NativeChatSendHandle { + if (options?.onWriteRejected) { + return sendNativeChatObservedWrites( + settings, + ptyId, + [ + { data: buildNativeChatPasteBytes(text), delayBeforeMs: 0 }, + { data: NATIVE_CHAT_SUBMIT, delayBeforeMs: NATIVE_CHAT_SUBMIT_DELAY_MS } + ], + options + ) + } return enqueueNativeChatPtySend( ptyId, NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options), diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx new file mode 100644 index 00000000000..fa601e93835 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom +import { act, renderHook, cleanup } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS, + useNativeChatPendingDelivery +} from './use-native-chat-pending-delivery' +import { clearPendingSendCacheForTests } from './native-chat-pending' + +const boundary: NativeChatMessage = { + id: 'boundary', + role: 'assistant', + blocks: [{ type: 'text', text: 'before' }], + timestamp: null, + source: 'transcript' +} +const args = { paneKey: 'pane', agent: 'claude' as const, messages: [boundary] } +function userRow(text: string): NativeChatMessage { + return { ...boundary, id: `user:${text}`, role: 'user', blocks: [{ type: 'text', text }] } +} +function render() { + return renderHook(({ messages }) => useNativeChatPendingDelivery({ ...args, messages }), { + initialProps: { messages: [boundary] } + }) +} +async function tick(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} +beforeEach(() => { + vi.useFakeTimers() + clearPendingSendCacheForTests() +}) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('terminal Chat pending delivery', () => { + it('never flags an ordinary send, such as one Claude queues mid-turn', async () => { + const { result } = render() + act(() => result.current.record('queued follow up')) + await tick(10 * 60_000) + expect(result.current.notices.size).toBe(0) + expect(result.current.pending[0]?.delivery).toBeUndefined() + }) + it('marks a refused write as not sent, keeps its text, and dismisses it', () => { + const { result } = render() + act(() => { + result.current.reject(result.current.record('refused')) + }) + expect(result.current.pending[0]?.text).toBe('refused') + expect([...result.current.notices.values()][0]?.text).toBe('Message not sent') + act(() => [...result.current.notices.values()][0]?.onDismiss?.()) + expect(result.current.pending).toEqual([]) + }) + it('reports a lost acknowledgment as unconfirmed only after the hold passes with no row', async () => { + const { result } = render() + act(() => { + result.current.holdUnconfirmed(result.current.record('lost ack')) + }) + await tick(NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS - 1) + expect(result.current.notices.size).toBe(0) + await tick(1) + expect([...result.current.notices.values()][0]?.text).toMatch(/Delivery unconfirmed/) + expect(result.current.pending[0]?.text).toBe('lost ack') + }) + it('clears a held send whose echo lands, before or after the hold ends', async () => { + const { result, rerender } = render() + act(() => { + result.current.holdUnconfirmed(result.current.record('early')) + result.current.holdUnconfirmed(result.current.record('late')) + }) + rerender({ messages: [boundary, userRow('early')] }) + await tick(NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS) + expect(result.current.pending.map((entry) => [entry.text, entry.delivery])).toEqual([ + ['early', undefined], + ['late', 'unconfirmed'] + ]) + rerender({ + messages: [boundary, userRow('early'), userRow('late'), { ...boundary, id: 'answer' }] + }) + expect(result.current.pending).toEqual([]) + }) + it.each(['rejected', 'unconfirmed'] as const)( + 'retires a resend of a %s message once its row lands', + async (outcome) => { + const { result, rerender } = render() + act(() => { + const id = result.current.record('try again') + if (outcome === 'rejected') { + result.current.reject(id) + } else { + result.current.holdUnconfirmed(id) + } + }) + await tick(NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS) + expect(result.current.pending[0]?.delivery).toBe(outcome) + act(() => result.current.record('try again')) + expect(result.current.pending.map((entry) => entry.delivery)).toEqual([undefined]) + rerender({ messages: [boundary, userRow('try again'), { ...boundary, id: 'answer' }] }) + expect(result.current.pending).toEqual([]) + } + ) + it('keeps a failed send through Stop while dropping sends Stop may have cancelled', () => { + const { result } = render() + act(() => { + result.current.reject(result.current.record('refused')) + result.current.record('in flight') + }) + act(() => result.current.clear()) + expect(result.current.pending.map((entry) => [entry.text, entry.delivery])).toEqual([ + ['refused', 'rejected'] + ]) + }) + it('keeps the hold deadline across a remount', async () => { + const first = render() + act(() => { + first.result.current.holdUnconfirmed(first.result.current.record('remounted')) + }) + await tick(NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS / 2) + first.unmount() + const next = render() + await tick(NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS / 2) + expect(next.result.current.pending[0]?.delivery).toBe('unconfirmed') + }) + it('keeps pending and notices referentially stable across stream updates', () => { + const { result, rerender } = render() + act(() => result.current.record('stable')) + const { pending, notices } = result.current + rerender({ messages: [boundary, { ...boundary, id: 'streamed' }] }) + expect(result.current.pending).toBe(pending) + expect(result.current.notices).toBe(notices) + }) +}) 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 00000000000..a15a05fcb4d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { translate } from '@/i18n/i18n' +import type { AgentType, NativeChatMessage } from '../../../../shared/native-chat-types' +import { + appendPendingSendCache, + nextNativeChatPendingSendId, + pendingSendsAsMessages, + prunePendingSends, + readPendingSendCache, + writePendingSendCache, + type NativeChatPendingSend +} from './native-chat-pending' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' + +/** How long a send whose write acknowledgment was lost waits for its row; the phone's hold matches. */ +export const NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS = 20_000 + +const NO_NOTICES: ReadonlyMap = new Map() + +/** Optimistic terminal-Chat echoes plus the definite send outcomes the transport reports. */ +export function useNativeChatPendingDelivery(args: { + paneKey: string + agent: AgentType + messages: NativeChatMessage[] +}) { + const { paneKey, agent, messages } = args + const scope = useMemo(() => ({ paneKey, agent }), [paneKey, agent]) + const [pending, setPending] = useState(() => readPendingSendCache(scope)) + useEffect(() => setPending(readPendingSendCache(scope)), [scope]) + const save = useCallback( + (update: (entries: NativeChatPendingSend[]) => NativeChatPendingSend[]) => { + const current = readPendingSendCache(scope) + const next = update(current) + // Why: pruning runs on every stream update; a no-op must not re-render the list. + if (next !== current) { + setPending(writePendingSendCache(scope, next)) + } + }, + [scope] + ) + useEffect(() => { + save((entries) => prunePendingSends(entries, messages)) + }, [messages, save]) + const record = useCallback( + (text: string, imagePaths?: string[]) => { + const sentAt = Date.now() + const boundary = messages.at(-1) + const entry: NativeChatPendingSend = { + id: nextNativeChatPendingSendId(sentAt), + text, + sentAt, + afterMessageId: boundary?.id ?? null, + afterMessageTimestamp: boundary?.timestamp ?? null, + ...(imagePaths ? { imagePaths } : {}) + } + setPending(appendPendingSendCache(scope, entry)) + return entry.id + }, + [messages, scope] + ) + const cancel = useCallback( + (id: string) => save((entries) => entries.filter((entry) => entry.id !== id)), + [save] + ) + const reject = useCallback( + (id: string) => + save((entries) => + entries.map((entry) => (entry.id === id ? { ...entry, delivery: 'rejected' } : entry)) + ), + [save] + ) + const holdUnconfirmed = useCallback( + (id: string) => + save((entries) => + entries.map((entry) => + entry.id === id && !entry.delivery && entry.writeUnconfirmedAt === undefined + ? { ...entry, writeUnconfirmedAt: Date.now() } + : entry + ) + ), + [save] + ) + // Why keep outcomes: Stop cannot affect a settled failure, and its bubble holds the only copy. + const clear = useCallback( + () => save((entries) => entries.filter((entry) => entry.delivery)), + [save] + ) + + // A lost acknowledgment is the only unconfirmed trigger: an ordinary send, including one Claude + // queues mid-turn, has no transport doubt and stays pending until its row lands. + const nextHoldDeadline = useMemo(() => { + const deadlines = pending.flatMap((entry) => + entry.writeUnconfirmedAt !== undefined && !entry.delivery + ? [entry.writeUnconfirmedAt + NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS] + : [] + ) + return deadlines.length > 0 ? Math.min(...deadlines) : null + }, [pending]) + useEffect(() => { + if (nextHoldDeadline === null) { + return + } + const timer = setTimeout( + () => { + const now = Date.now() + save((entries) => { + const due = entries.filter( + (entry) => + entry.writeUnconfirmedAt !== undefined && + !entry.delivery && + entry.writeUnconfirmedAt + NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS <= now + ) + if (due.length === 0) { + return entries + } + const unmatched = new Set( + pendingSendsAsMessages(due, messages).map((message) => message.id) + ) + if (unmatched.size === 0) { + return entries + } + return entries.map((entry) => + due.includes(entry) && unmatched.has(`pending:${entry.id}`) + ? { ...entry, delivery: 'unconfirmed' as const } + : entry + ) + }) + }, + Math.max(0, nextHoldDeadline - Date.now()) + ) + return () => clearTimeout(timer) + }, [nextHoldDeadline, messages, save]) + + const notices = useMemo(() => { + if (!pending.some((entry) => entry.delivery)) { + return NO_NOTICES + } + const result = new Map() + for (const entry of pending) { + if (!entry.delivery) { + continue + } + result.set(`pending:${entry.id}`, { + text: + entry.delivery === 'rejected' + ? translate('components.native-chat.messageNotSent', 'Message not sent') + : translate( + 'components.native-chat.deliveryUnconfirmed', + 'Delivery unconfirmed — check chat before retrying' + ), + onDismiss: () => cancel(entry.id) + }) + } + return result + }, [pending, cancel]) + return { pending, record, cancel, reject, holdUnconfirmed, clear, notices } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx new file mode 100644 index 00000000000..b480c065e91 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { NativeChatSendClassification } from '../../../../shared/native-chat-slash-commands' +import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-send' +import { sendNativeChatMessage } from './native-chat-runtime-send' +import { sendNativeChatMessageWithImageAttachments } from './native-chat-runtime-image-send' + +const handle = vi.hoisted(() => ({ cancel: () => {}, settleAfterMs: 0 })) +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: vi.fn(() => handle), + sendNativeChatTypedCommand: vi.fn(() => handle), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('./native-chat-runtime-image-send', () => ({ + sendNativeChatMessageWithImageAttachments: vi.fn(() => handle) +})) +vi.mock('../../store', () => ({ + useAppStore: { getState: () => ({ clearNativeChatLaunchDraft: vi.fn() }) } +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) + +function send( + agent: AgentType, + classification: NativeChatSendClassification, + draft: string, + imagePaths: string[] = [] +) { + const callbacks = { rejected: vi.fn(), unconfirmed: vi.fn() } + const { result } = renderHook(() => + useNativeChatPtyComposerSend({ + agent, + draft, + imageAttachments: imagePaths.map((path) => ({ path })), + disabled: false, + isDispatchingSessionOption: false, + launchDraftResolved: true, + resolveTarget: () => ({ ptyId: 'pty', settings: null }), + classifySend: () => classification, + onOptimisticSend: () => 'pending-1', + optimisticSendOutcome: { reject: callbacks.rejected, holdUnconfirmed: callbacks.unconfirmed }, + sessionOptionsSurface: null, + terminalTabId: 'tab', + trackPendingSend: vi.fn(), + setHistory: vi.fn(), + setDraft: vi.fn(), + setCaret: vi.fn(), + clearSkillOrigin: vi.fn(), + clearImageAttachments: vi.fn(), + setNotice: vi.fn() + }) + ) + result.current() + return callbacks +} + +beforeEach(() => { + vi.mocked(sendNativeChatMessage).mockClear() + vi.mocked(sendNativeChatMessageWithImageAttachments).mockClear() +}) + +it('routes a Claude chat send outcome to its own pending echo', () => { + const callbacks = send('claude', 'chat', 'hello') + const options = vi.mocked(sendNativeChatMessage).mock.calls[0]?.[3] + options?.onWriteRejected?.() + options?.onWriteUnconfirmed?.() + expect(callbacks.rejected).toHaveBeenCalledWith('pending-1') + expect(callbacks.unconfirmed).toHaveBeenCalledWith('pending-1') +}) + +it('routes a Claude image send outcome to its own pending echo', () => { + const callbacks = send('claude', 'chat', 'look', ['/tmp/shot.png']) + vi.mocked(sendNativeChatMessageWithImageAttachments).mock.calls[0]?.[5]?.onWriteRejected?.() + expect(callbacks.rejected).toHaveBeenCalledWith('pending-1') +}) + +it.each([ + ['codex', 'chat', 'hello'], + ['claude', 'command', '/compact'] +] as const)('leaves a %s %s send on the unobserved write path', (agent, classification, draft) => { + send(agent, classification, draft) + expect(vi.mocked(sendNativeChatMessage).mock.calls[0]?.[3]?.onWriteRejected).toBeUndefined() +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts index 1cc912f4250..385396190e4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts @@ -18,6 +18,7 @@ import { isSlashCommandDraft } from '../../../../shared/native-chat-slash-comman import type { NativeChatPickerState } from './use-native-chat-picker-state' import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' +import type { NativeChatOptimisticSendOutcome } from './native-chat-composer-types' export function useNativeChatPtyComposerSend(args: { agent: AgentType @@ -31,6 +32,7 @@ export function useNativeChatPtyComposerSend(args: { resolveTarget: () => NativeChatResolvedTarget | null classifySend: NativeChatPickerState['classifySend'] onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined + optimisticSendOutcome?: NativeChatOptimisticSendOutcome onSlashCommand?: (command: string) => void sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null terminalTabId: string @@ -57,12 +59,29 @@ export function useNativeChatPtyComposerSend(args: { return } const classification = args.classifySend(text) - const { sendOptions } = resolveNativeChatLaunchDraftSend({ + const { sendOptions: launchSendOptions } = resolveNativeChatLaunchDraftSend({ launchDraft: args.launchDraft, launchDraftResolved: args.launchDraftResolved, agent: args.agent, readScreen: () => args.readTerminalScreen?.() }) + let pendingId: string | undefined + const sendOptions = + args.agent === 'claude' && classification === 'chat' + ? { + ...launchSendOptions, + onWriteRejected: () => { + if (pendingId) { + args.optimisticSendOutcome?.reject(pendingId) + } + }, + onWriteUnconfirmed: () => { + if (pendingId) { + args.optimisticSendOutcome?.holdUnconfirmed(pendingId) + } + } + } + : launchSendOptions let pendingHandle: NativeChatSendHandle | null = null // Why: slash-like text must not silently drop its attached images. if (classification !== 'chat' && imagePaths.length === 0) { @@ -93,7 +112,7 @@ export function useNativeChatPtyComposerSend(args: { args.sessionOptionsSurface?.recordOutgoingCommand(text.trim()) } } else { - const pendingId = args.onOptimisticSend?.(text, imagePaths) + pendingId = args.onOptimisticSend?.(text, imagePaths) if (pendingHandle) { args.trackPendingSend(pendingHandle, pendingId) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e837504ad5f..1859cb84d74 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17856,6 +17856,9 @@ "title": "Context", "estimated": "Estimated from the last response." }, + "dismissDeliveryNotice": "Dismiss", + "messageNotSent": "Message not sent", + "deliveryUnconfirmed": "Delivery unconfirmed — check chat before retrying", "queuedMessages": { "behindReturnedHold": "Waiting — a message ahead needs attention", "awaitingAnswerHold": "Waiting for your answer", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 67612c7dade..731806faa3b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15083,6 +15083,9 @@ "title": "Contexto", "estimated": "Estimado a partir de la última respuesta." }, + "dismissDeliveryNotice": "Descartar", + "messageNotSent": "Mensaje no enviado", + "deliveryUnconfirmed": "Entrega sin confirmar — revisa el chat antes de reintentar", "subagents": { "unnamed": "Subagente" }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index eec089ec1b1..65acfdf49e9 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17777,6 +17777,9 @@ "title": "Contexte", "estimated": "Estimation basée sur la dernière réponse." }, + "dismissDeliveryNotice": "Ignorer", + "messageNotSent": "Message non envoyé", + "deliveryUnconfirmed": "Livraison non confirmée — vérifiez le chat avant de réessayer", "queuedMessages": { "alreadySent": "Ce message a déjà été envoyé.", "editAlreadySent": "Déjà envoyé — votre texte reste dans la zone de saisie.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ef6f7aa589c..85491478e07 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17713,6 +17713,9 @@ "title": "コンテキスト", "estimated": "最後の応答から推定。" }, + "dismissDeliveryNotice": "閉じる", + "messageNotSent": "メッセージは送信されませんでした", + "deliveryUnconfirmed": "配信を確認できません — 再試行する前にチャットを確認してください", "queuedMessages": { "alreadySent": "このメッセージはすでに送信されています。", "editAlreadySent": "すでに送信されています — テキストは入力欄に残っています。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index edb6cd17660..180ed46c24f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17713,6 +17713,9 @@ "title": "컨텍스트", "estimated": "마지막 응답 기준 추정치입니다." }, + "dismissDeliveryNotice": "닫기", + "messageNotSent": "메시지가 전송되지 않음", + "deliveryUnconfirmed": "전달 확인 안 됨 — 다시 시도하기 전에 채팅을 확인하세요", "queuedMessages": { "alreadySent": "이 메시지는 이미 전송되었습니다.", "editAlreadySent": "이미 전송되었습니다 — 텍스트는 입력창에 남아 있습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8f5b3a79551..42d4d19309b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17678,6 +17678,9 @@ "title": "上下文", "estimated": "根据上一次响应的估算值。" }, + "dismissDeliveryNotice": "关闭", + "messageNotSent": "消息未发送", + "deliveryUnconfirmed": "无法确认送达 — 重试前请检查聊天", "queuedMessages": { "alreadySent": "此消息已发送。", "editAlreadySent": "已发送 — 你的文本仍在输入框中。", diff --git a/src/shared/claude-pasted-content.ts b/src/shared/claude-pasted-content.ts new file mode 100644 index 00000000000..5f9f074a340 --- /dev/null +++ b/src/shared/claude-pasted-content.ts @@ -0,0 +1,31 @@ +import type { NativeChatBlock } from './native-chat-types' + +// Claude wraps a whole pasted text block; prose around it is still literal user text. +export function unwrapClaudePastedContent(text: string): string { + const trimmed = text.trim() + const open = /^\r\n]{1,64})")?>\r?\n/.exec(trimmed) + if (!open) { + return text + } + const close = open[1] ? `
` : '
' + if (!trimmed.endsWith(close)) { + return text + } + const body = trimmed.slice(open[0].length, -close.length) + // Why: the id is what lets pasted text carry literal tags; only a same-id tag is ambiguous. + const ambiguous = open[1] + ? body.includes(close) || body.includes(``) + : body.includes(' block.text) + .map((block) => unwrapClaudePastedContent(block.text)) .join(' ') ) return normalized || null