mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
* 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>
185 lines
6.1 KiB
TypeScript
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[])
|
|
}
|