Files
orca/src/shared/native-chat-image-transcript-markers.ts
T
Brennan BensonandHarshul Rathod 707d3dc96b fix(chat): decode Claude pastes and report terminal delivery uncertainty (#23788)
* fix(chat): decode Claude pastes and track terminal delivery uncertainty

Keep queued prompts pending while the existing agent status reports work, and check fresh history after a later idle fact. Preserve draft text and distinguish write rejection from unconfirmed delivery.

Co-authored-by: Harshul Rathod <harshulrathod1640@gmail.com>

* fix(native-chat): break the observed-send import cycle and keep renderer tests out of main

The observed-send path imported the clear helpers from native-chat-runtime-send,
which imports it back. Move the input-clear layer into its own module both use.
The Claude paste decoder test imported the renderer pending module from
src/main, which the node typecheck project cannot see; the echo-retirement
assertions now live in a renderer test.

* fix(native-chat): still submit a Claude chat send whose write acknowledgment was lost

A remote write whose acknowledgment is lost (timeout, dropped link) is not a
refusal, but the observed path stopped there and never sent Enter, leaving a
body that did land sitting unsubmitted in Claude's input line until the next
send's clear wiped it. Continue to the next write without re-sending the bytes,
as the unobserved path always did.

* perf(native-chat): keep terminal Chat pending delivery from re-rendering every row

The delivery notices were merged into a new Map on every render, which
invalidated the transcript row context and re-rendered every memoized row on
each stream update. The pending hook also wrote a fresh array on every status
ping and prune pass even when nothing changed, and the phone mapped its pending
list on every render, rebuilding the chat list data. Memoize the merged
notices, skip no-op pending writes, and memoize the phone's rendered pending
list. The phone also skips a transcript read when no send is due.

* fix(native-chat): never flag a queued Claude send, and flag one an idle Claude never starts

Two gaps in when terminal Chat calls a Claude send "Delivery unconfirmed":

A prompt sent while Claude is mid-turn is queued, and Claude folds it into the
running turn as a queued-command record. The transcript reader drops those
records, so once the turn ended the prompt Claude did run read as unconfirmed,
inviting a duplicate resend. A send made while the agent is busy is now never
checked; it keeps the pending behaviour it had before.

A prompt sent to an idle Claude that never starts a turn (Claude exited to the
shell, or the paste went nowhere) left the status at the same idle fact
forever, so the check never ran and the bubble stayed pending. An idle agent
starts a turn on a delivered prompt at once, so a send whose idle status is
unchanged after the existing 20 s bound is now checked against a fresh
transcript read.

* fix(native-chat): add the delivery notice strings to the English catalog

The Dismiss action's translate key was missing from en.json, which fails the
localization catalog and extraction gates. The desktop "Message not sent" and
"Delivery unconfirmed" notices were hard-coded English; route them through
translate with the same wording.

* fix(mobile): sync the held-send refs after commit instead of during render

Moving the acknowledgment-loss hold into its own hook made its render-time ref
writes new lines, which the React Doctor changed-lines gate blocks. Held sends
report after commit, so syncing those refs in a layout effect keeps them
current where they are read.

* fix(native-chat): report only definite terminal Chat send outcomes

The delivery rule inferred "Delivery unconfirmed" from "the turn ended and the
transcript has no matching row". Claude records a prompt sent mid-turn only as a
queued-command attachment, which the transcript reader drops, so that rule
flagged prompts Claude had answered. It also never fired for an idle Claude that
lost the write, because no newer turn arrives.

Keep only facts the transport reports:
- a refused write reads "Message not sent", keeps its text, and can be dismissed;
- a lost write acknowledgment holds the echo for 20 s, the phone's existing
  rule, then reads "Delivery unconfirmed" unless its row has landed.
An ordinary send, including one Claude queues mid-turn, stays pending as before.

Remove the agent-status subscription, the status-epoch origin, the fresh
500-row transcript read, the confirmed state and the no-status clock. The phone
already implements this rule, so its changes revert to main; only a test for
old-host paste envelopes remains.

* fix(i18n): translate the terminal Chat delivery notices

Add the Dismiss, "Message not sent" and "Delivery unconfirmed" strings to the
es, fr, ja, ko and zh catalogs, reusing each catalog's existing Dismiss wording.

* fix(native-chat): let a resend replace its failed terminal Chat echo

A "Message not sent" or "Delivery unconfirmed" echo kept its transcript
occurrence, so resending the same text numbered the resend as the second
copy: the one landed row retired the failed echo and pinned the resend
below the reply forever. Appending a send now drops a failed echo with the
same content first.

* fix(native-chat): unwrap a Claude paste that quotes pasted_content tags

The envelope parser refused any body containing a pasted_content tag, so a
pasted prompt that itself quotes one (a transcript excerpt, or code that
handles these tags) kept its wrapper and its echo stayed pinned below the
reply. Claude's per-paste id exists to disambiguate exactly that; only a
same-id tag inside the body is now ambiguous. Wrappers without an id keep
the strict rule.

* test(native-chat): pin which terminal Chat sends observe write outcomes

Only a Claude chat send (text or images) reports a refused or unacknowledged
write to its pending echo; other agents and slash commands keep the
unobserved write path exactly as before.

* fix(native-chat): keep failed terminal Chat sends through Stop

Stop cleared every optimistic echo, including a "Message not sent" or
"Delivery unconfirmed" bubble whose send had already settled. Stop cannot
affect that send, and the bubble is the only place its text stays copyable,
so it now survives until the user dismisses or resends it. Also moves the
observed-send import below the file header comment.

---------

Co-authored-by: Harshul Rathod <harshulrathod1640@gmail.com>
2026-09-30 01:04:21 -07:00

185 lines
6.1 KiB
TypeScript

import { unwrapClaudePastedContent } from './claude-pasted-content'
import {
stripAnsiEscapeSequences,
TERMINAL_CONTROL_CHARACTER_PATTERN
} from './ansi-escape-sequences'
import { isTextBlock, type NativeChatBlock, type NativeChatMessage } from './native-chat-types'
const IMAGE_SOURCE_MARKER = /^\[Image:\s*source:\s*(.+?)\]\s*$/
const IMAGE_PROMPT_MARKER = /\[Image #\d+\]/
const IMAGE_PROMPT_MARKERS = /\[Image #\d+\]/g
const IMAGE_PROMPT_MARKER_AT_START = /^[^\S\r\n]*\[Image #\d+\]/
const IMAGE_PROMPT_MARKER_AT_END = /\[Image #\d+\][^\S\r\n]*$/
const HORIZONTAL_WHITESPACE_START = /^[^\S\r\n]+/
const HORIZONTAL_WHITESPACE_END = /[^\S\r\n]+$/
export function imageSourcePathFromText(text: string): string | null {
return text.match(IMAGE_SOURCE_MARKER)?.[1]?.trim() ?? null
}
/** Every image-source path a user turn carries, or [] when it is not a pure
* image-source turn.
*
* Why not `soleText`: Claude records a multi-image paste as ONE companion message
* holding one `[Image: source: ...]` text block per image, so requiring a single
* block missed every multi-image turn. A turn qualifies only when it is all text
* and every block is a marker, so a real prompt is never mistaken for one. */
export function imageSourcePathsFromMessage(message: NativeChatMessage): string[] {
if (message.role !== 'user' || message.blocks.length === 0) {
return []
}
const paths: string[] = []
for (const block of message.blocks) {
if (!isTextBlock(block)) {
return []
}
const path = imageSourcePathFromText(block.text)
if (path === null) {
return []
}
paths.push(path)
}
return paths
}
export function isImageSourceUserTurn(message: NativeChatMessage): boolean {
return imageSourcePathsFromMessage(message).length > 0
}
export function stripImagePromptMarker(text: string): string {
const stripped = text.replace(IMAGE_PROMPT_MARKERS, '')
if (stripped === text) {
return text
}
let result = IMAGE_PROMPT_MARKER_AT_START.test(text)
? stripped.replace(HORIZONTAL_WHITESPACE_START, '')
: stripped
if (IMAGE_PROMPT_MARKER_AT_END.test(text)) {
result = result.replace(HORIZONTAL_WHITESPACE_END, '')
}
return result
}
/** Normalizes PTY-backed user text into the pending-echo comparison key. */
export function normalizeNativeChatUserText(text: string): string {
// Strip sequences first so their printable tails cannot survive a lone-control pass.
return stripImagePromptMarker(
stripAnsiEscapeSequences(unwrapClaudePastedContent(text)).replace(
TERMINAL_CONTROL_CHARACTER_PATTERN,
''
)
)
.trim()
.replace(/\s+/g, ' ')
}
export function normalizedNativeChatUserMessageText(message: NativeChatMessage): string | null {
if (message.role !== 'user') {
return null
}
const normalized = normalizeNativeChatUserText(
message.blocks
.filter(isTextBlock)
.map((block) => unwrapClaudePastedContent(block.text))
.join(' ')
)
return normalized || null
}
function stripImagePromptMarkersFromTextBlocks(
blocks: readonly NativeChatBlock[]
): NativeChatBlock[] {
let sawText = false
let next: NativeChatBlock[] | null = null
for (let index = 0; index < blocks.length; index += 1) {
const block = blocks[index]!
if (!isTextBlock(block)) {
next?.push(block)
continue
}
const isFirstText = !sawText
sawText = true
const text = stripImagePromptMarker(block.text)
if (!text.trim() && (text !== block.text || isFirstText)) {
next ??= blocks.slice(0, index)
continue
}
if (text !== block.text) {
next ??= blocks.slice(0, index)
next.push({ ...block, text })
continue
}
next?.push(block)
}
return next ?? (blocks as NativeChatBlock[])
}
export function hasImagePromptMarker(message: NativeChatMessage): boolean {
return message.blocks.some((block) => isTextBlock(block) && IMAGE_PROMPT_MARKER.test(block.text))
}
/** Claude records image paths as source turns followed by a prompt carrying
* image markers. Merge the whole run back into one native user turn. */
export function normalizeImageTranscriptMessages(
messages: readonly NativeChatMessage[]
): NativeChatMessage[] {
let normalized: NativeChatMessage[] | null = null
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index]!
if (message.role !== 'user') {
normalized?.push(message)
continue
}
const messageImagePaths = imageSourcePathsFromMessage(message)
if (messageImagePaths.length > 0) {
normalized ??= messages.slice(0, index)
const imagePaths = [...messageImagePaths]
let nextIndex = index + 1
while (nextIndex < messages.length) {
const candidate = messages[nextIndex]!
const candidatePaths = imageSourcePathsFromMessage(candidate)
if (
candidate.role !== 'user' ||
candidate.source !== message.source ||
candidatePaths.length === 0
) {
break
}
imagePaths.push(...candidatePaths)
nextIndex += 1
}
const prompt = messages[nextIndex]
if (
prompt?.role === 'user' &&
prompt.source === message.source &&
hasImagePromptMarker(prompt)
) {
normalized.push({
...prompt,
blocks: [
...imagePaths.map((path) => ({ type: 'image-ref' as const, path })),
...stripImagePromptMarkersFromTextBlocks(prompt.blocks)
]
})
index = nextIndex
continue
}
// Only THIS turn's paths: `imagePaths` also holds the following source turns the
// fold scan looked at, and without a prompt to fold into they stay separate turns.
normalized.push({
...message,
blocks: messageImagePaths.map((path) => ({ type: 'image-ref' as const, path }))
})
continue
}
const blocks = stripImagePromptMarkersFromTextBlocks(message.blocks)
if (blocks === message.blocks) {
normalized?.push(message)
} else {
normalized ??= messages.slice(0, index)
normalized.push({ ...message, blocks })
}
}
return normalized ?? (messages as NativeChatMessage[])
}