Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0a0caf0
fix(chat): decode Claude pastes and track terminal delivery uncertainty
brennanb2025 Sep 29, 2026
90fd2e9
fix(native-chat): break the observed-send import cycle and keep rende…
brennanb2025 Sep 29, 2026
cca3716
fix(native-chat): still submit a Claude chat send whose write acknowl…
brennanb2025 Sep 29, 2026
b868e92
perf(native-chat): keep terminal Chat pending delivery from re-render…
brennanb2025 Sep 29, 2026
058d413
fix(native-chat): never flag a queued Claude send, and flag one an id…
brennanb2025 Sep 29, 2026
e8cab72
fix(native-chat): add the delivery notice strings to the English catalog
brennanb2025 Sep 29, 2026
367f4ec
fix(mobile): sync the held-send refs after commit instead of during r…
brennanb2025 Sep 29, 2026
eaee929
fix(native-chat): report only definite terminal Chat send outcomes
brennanb2025 Sep 29, 2026
a32b337
fix(i18n): translate the terminal Chat delivery notices
brennanb2025 Sep 29, 2026
22ad4cd
fix(native-chat): let a resend replace its failed terminal Chat echo
brennanb2025 Sep 29, 2026
702a845
fix(native-chat): unwrap a Claude paste that quotes pasted_content tags
brennanb2025 Sep 29, 2026
cbbf29b
test(native-chat): pin which terminal Chat sends observe write outcomes
brennanb2025 Sep 29, 2026
72b1ede
fix(native-chat): keep failed terminal Chat sends through Stop
brennanb2025 Sep 29, 2026
9768024
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-ech…
brennanb2025 Sep 29, 2026
ae46e05
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-ech…
brennanb2025 Sep 29, 2026
8a4859c
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-ech…
brennanb2025 Sep 29, 2026
34f0636
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-ech…
brennanb2025 Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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: `<pasted_content id="a">\n${text}\n</pasted_content id="a">` }]
}
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'])
})
Original file line number Diff line number Diff line change
@@ -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<pasted_content id="7e64">\n${prompt}\n</pasted_content id="7e64">\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(`<pasted_content>\r\n${prompt}\r\n</pasted_content>`).blocks).toEqual([
{ type: 'text', text: prompt }
])
})
it('decodes a paste whose own text quotes differently identified or bare tags', () => {
const quoted = `<pasted_content id="aa">\nx\n</pasted_content id="aa">\n</pasted_content>`
expect(
decode(`<pasted_content id="7e64">\n${quoted}\n</pasted_content id="7e64">`).blocks
).toEqual([{ type: 'text', text: quoted }])
})
it.each([
`Explain this:\n${wrapped}`,
`<pasted_content id="7e64">\n<pasted_content id="7e64">\nx\n</pasted_content id="7e64">`,
`<pasted_content>\n<pasted_content id="aa">\nx\n</pasted_content id="aa">\n</pasted_content>`,
wrapped.replace('id="7e64">\n', 'id="other">\n'),
wrapped.replace('</pasted_content id="7e64">', '</pasted_content>'),
wrapped.replace('<pasted_content id="7e64">', '<pasted_content>'),
`${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(`<pasted_content id="a">\n${text}\n</pasted_content id="a">`)
).toBe(text.trim().replace(/\s+/g, ' '))
})
})
3 changes: 2 additions & 1 deletion src/main/native-chat/transcript-line-decoders-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
isWorking = false,
onStop,
onOptimisticSend,
optimisticSendOutcome,
onOptimisticSendCanceled,
onSlashCommand,
onSwitchToTerminal,
Expand Down Expand Up @@ -277,6 +278,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
resolveTarget,
classifySend,
onOptimisticSend,
optimisticSendOutcome,
onSlashCommand,
sessionOptionsSurface: ptySessionOptionsSurface,
terminalTabId,
Expand Down
11 changes: 10 additions & 1 deletion src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'

/** What a user message says under it when it did not go through, with its own Retry when the
* surface can send it again. */
export type NativeChatDeliveryNotice = { text: string; onRetry?: () => 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
Expand Down Expand Up @@ -178,6 +182,11 @@ export const MessageRow = memo(function MessageRow({
{deliveryNotice ? (
<div className="flex max-w-[85%] items-center gap-2 text-[11px] text-destructive/80">
<span className="min-w-0 break-words">{deliveryNotice.text}</span>
{deliveryNotice.onDismiss ? (
<Button type="button" variant="ghost" size="xs" onClick={deliveryNotice.onDismiss}>
{translate('components.native-chat.dismissDeliveryNotice', 'Dismiss')}
</Button>
) : null}
{deliveryNotice.onRetry ? (
<Button type="button" variant="ghost" size="xs" onClick={deliveryNotice.onRetry}>
<RotateCcw className="size-3" />
Expand Down
70 changes: 19 additions & 51 deletions src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,11 @@ import { shouldClearNativeChatWorkingSuppression } from './native-chat-working-s
import { resolveNativeChatTerminalTurn } from './native-chat-terminal-turn'
import { useNativeChatTerminalTurnTiming } from './use-native-chat-terminal-turn-timing'
import {
appendPendingSendCache,
launchPromptAsMessage,
pendingSendsAsMessages,
nextNativeChatPendingSendId,
prunePendingSends,
readPendingSendCache,
shouldPruneLaunchPrompt,
writePendingSendCache,
type NativeChatPendingSend
shouldPruneLaunchPrompt
} from './native-chat-pending'
import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery'
import {
appendCommandMarkerCache,
applyCommandMarkerBoundaries,
Expand Down Expand Up @@ -161,35 +156,19 @@ export function NativeChatResolvedView({
() => ({ paneKey, agent, sessionId }),
[paneKey, agent, sessionId]
)
const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent])
const [pending, setPending] = useState<NativeChatPendingSend[]>(() =>
readPendingSendCache(pendingScope)
)
const delivery = useNativeChatPendingDelivery({ paneKey, agent, messages: session.messages })
const { pending, record, clear } = delivery
// Slash commands aren't chat turns, so they get a small local "Ran /clear"
// system line instead of a user bubble. Capped + cached per conversation.
const [commandMarkers, setCommandMarkers] = useState<NativeChatCommandMarker[]>(() =>
readCommandMarkerCache(commandMarkerScope)
)
// Reset the optimistic queue only when the pane/agent changes. A fresh launch
// often learns its provider session id after the first send; clearing pending
// on that transition briefly flashes the empty state before the transcript
// user turn lands.
useEffect(() => {
setPending(readPendingSendCache(pendingScope))
setWorkingInterrupted(false)
}, [pendingScope])
// Command markers are session-scoped because slash commands like /clear are
// local feedback for a specific transcript boundary.
useEffect(() => {
setCommandMarkers(readCommandMarkerCache(commandMarkerScope))
setWorkingInterrupted(false)
}, [commandMarkerScope])
// Prune echoes whose real user turn is now in the transcript.
useEffect(() => {
setPending((prev) =>
writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages))
)
}, [session.messages, pendingScope])
useEffect(() => {
if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) {
return
Expand All @@ -199,29 +178,9 @@ export function NativeChatResolvedView({
const onOptimisticSend = useCallback(
(text: string, imagePaths?: string[]) => {
setWorkingInterrupted(false)
const sentAt = Date.now()
const boundary = session.messages.at(-1)
const entry: NativeChatPendingSend = {
id: nextNativeChatPendingSendId(sentAt),
text,
sentAt,
afterMessageId: boundary?.id ?? null,
afterMessageTimestamp: boundary?.timestamp ?? null,
...(imagePaths ? { imagePaths } : {})
}
setPending(appendPendingSendCache(pendingScope, entry))
return entry.id
return record(text, imagePaths)
},
[pendingScope, session.messages]
)
const onOptimisticSendCanceled = useCallback(
(pendingId: string) => {
// Why: detach/interrupt cancels the delayed Enter, so its optimistic echo
// must not come back from the pane cache as a prompt that was delivered.
const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId)
setPending(writePendingSendCache(pendingScope, next))
},
[pendingScope]
[record]
)
const onSlashCommand = useCallback(
(command: string) => {
Expand Down Expand Up @@ -251,6 +210,14 @@ export function NativeChatResolvedView({
paneLaunchPrompt?.failed ? launchPromptMessage?.id : null,
sessionAfterCommandBoundaries.messages
)
// Why memoized: a fresh map each render would re-render every memoized transcript row.
const deliveryNotices = useMemo(
() =>
delivery.notices.size === 0
? launchPromptDeliveryNotices
: new Map([...(launchPromptDeliveryNotices ?? []), ...delivery.notices]),
[launchPromptDeliveryNotices, delivery.notices]
)
const promptCard = useNativeChatInteractivePromptCard({
paneKey,
messages: sessionAfterCommandBoundaries.messages,
Expand Down Expand Up @@ -332,9 +299,9 @@ export function NativeChatResolvedView({
// Why: Stop after a submitted turn drops the delayed-write handle once it
// settles, so cancelPendingSends no longer sees the optimistic id. Clear
// the echo cache here so a cancelled prompt cannot stick as a ghost bubble.
setPending(writePendingSendCache(pendingScope, []))
clear()
interactiveSend.cancel()
}, [interactiveSend, pendingScope])
}, [interactiveSend, clear])
const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions(
fileLinkContext,
rootRef,
Expand Down Expand Up @@ -414,7 +381,7 @@ export function NativeChatResolvedView({
awaitingInput={awaitingInput}
onLinkClick={onLinkClick}
allowFileUriLinks={fileLinkContext !== null}
deliveryNotices={launchPromptDeliveryNotices}
deliveryNotices={deliveryNotices}
/>
)}
</div>
Expand Down Expand Up @@ -442,7 +409,8 @@ export function NativeChatResolvedView({
isWorking={isWorking}
onStop={stopAgent}
onOptimisticSend={onOptimisticSend}
onOptimisticSendCanceled={onOptimisticSendCanceled}
onOptimisticSendCanceled={delivery.cancel}
optimisticSendOutcome={delivery}
onSlashCommand={onSlashCommand}
onSwitchToTerminal={onSwitchToTerminal}
readTerminalScreen={readTerminalScreen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand Down
89 changes: 89 additions & 0 deletions src/renderer/src/components/native-chat/native-chat-input-clear.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getSettingsForAgentTabRuntimeOwner>

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
}
Loading
Loading