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