Files
orca/src/shared/native-chat-image-transcript-markers.ts
T
Brennan BensonandMerge Sim 51bc2ec343 fix(native-chat): keep the attachments on a Claude turn that pasted images (#17801)
* fix(native-chat): keep the attachments on a Claude turn that pasted images

A Claude turn carrying pasted images reached native chat with no images at all —
no thumbnails on mobile, and not even an attachment chip on desktop. Nothing
showed that the message had any.

Both carriers were being dropped:

- Claude records the paths in a companion turn marked `isMeta`, holding one
  `[Image: source: <path>]` text block per image. The decoder treats an `isMeta`
  user row as injected, filters it down to tool-result blocks, and returns null
  when none remain — so the whole row went away.
- The prompt row's own `image` blocks are `{source: {type: 'base64'}}`, which
  carry no url or path, so `imageRefBlock` drops them too.

With the companion gone, `isImageSourceUserTurn` could never fire and the fold in
`normalizeImageTranscriptMessages` was unreachable on the Claude path.

Surveying every transcript under `~/.claude/projects`: 238 of 241 image-source
rows are `isMeta`, across every versioned release (2.1.220 through 2.1.237); the
3 that are not carry no version field at all. 38 of those rows hold more than one
content block, which also defeated the single-block rule in
`isImageSourceUserTurn`.

Let image-source text survive the injected-turn filter, and recognize a turn
whose blocks are *all* markers rather than only a lone one. An ordinary injected
turn (a skill preamble, a compact summary) is still dropped, and a turn that
mixes prose with a marker is still not an image-source turn.

Carrying the paths keeps the payload small; decoding the base64 instead would put
hundreds of KB per image on the wire to mobile.

* fix(native-chat): preserve image companion ordering

* fix(native-chat): keep image companions turn-local

---------

Co-authored-by: Merge Sim <sim@local>
2026-08-31 23:27:56 -07:00

181 lines
6.0 KiB
TypeScript

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(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) => 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[])
}