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] ? `` : '