Files
orca/src/shared/native-chat-streaming.ts
T
Kelvin Amoaba f3551dba57 fix(mobile): show skill commands you ran and draw warnings, errors and status lines as notices (#28899)
* fix(mobile): show skill commands you ran in the chat

Claude records a skill run (/simplify, /review-pr 123) as a command
envelope, which the noise filter hides. Desktop surfaces it as the typed
`/name args` before folding; the phone only normalised image markers, so
the turn vanished and the reply answered nothing.

Both apps now prepare a terminal agent's transcript through one shared
prepareNativeChatMessages: skill envelopes, reporting-command output and
image marker turns.

* fix(native-chat): keep a streamed sentence from coming back after a tool or thought row

Both chats hide the live preview once the transcript shows it, but each
only compared the preview with the last row. When a tool call or thought
landed below the sentence, the last row had no words and the earlier
sentence came back at the bottom as a live message.

latestNativeChatReplyText reads back past those rows to the newest
assistant words in the turn; desktop's preview check and the phone's
streaming gate both use it.

* fix(mobile): draw errors, warnings and status notices as notices, not agent replies

The phone drew every system row through the agent's markdown, so an API
retry warning, a sign-in failure, "Context compacted" or "Stopped manually"
read as the agent's own words. Only the clear boundary and two host
notices were special-cased.

Desktop's notice rules move to shared native-chat-notice: which block of a
system row is its notice, and the look it gets (divider, command output,
host status, plan, aside, or a toned line). Desktop's notice row switches
on it with no change in what it draws; the phone's new notice row draws
the same looks, with words in faint type when a row has no notice.

* fix(native-chat): show a new reply that follows a tool call on desktop

The earlier fix read back past a tool call or thought to the newest
assistant words, then kept desktop's length rule. Commentary written
before the tool is often longer than the reply after it, so a new,
shorter reply stayed hidden until the transcript caught up.

The last-row rule is back as it was. Reading back now only hides the
preview when the earlier words already contain it, which is the sentence
still on screen while the tool runs. The phone's gate reads only rows
that landed after the current reply began, so it was not affected.

* fix(mobile): open the actions sheet from a long press on a plan's link

A plan card's Markdown was drawn without the row's long-press handler, so
on Android holding a link or file path inside a plan no longer opened Copy
message / Select text, as it did before plans were drawn as notices. The
notice row now passes the handler to the plan's Markdown.
2026-10-15 11:04:34 +00:00

94 lines
3.4 KiB
TypeScript

// Shared derivation for the in-flight "streaming" assistant bubble. While an
// agent works, its hook preview (lastAssistantMessage) is shown as a synthetic
// assistant message so the user sees the reply build in real time, before the
// completed turn is flushed to the transcript. Desktop and mobile both use this
// so the show/hide rule can't drift between platforms.
import type { NativeChatMessage } from './native-chat-types'
/** The synthetic streaming bubble's stable id (kept stable so the list keys it
* consistently across ticks and the real turn can replace it cleanly). */
export const NATIVE_CHAT_STREAMING_ID = 'streaming'
/** Concatenated text of an assistant message's text blocks, trimmed. */
function assistantText(message: NativeChatMessage | undefined): string {
if (!message || message.role !== 'assistant') {
return ''
}
return message.blocks
.filter((b) => b.type === 'text')
.map((b) => (b.type === 'text' ? b.text : ''))
.join('')
.trim()
}
/** The newest assistant words after row `after`: read back past the tool calls and thoughts that
* landed after them, but never past the user's message, which starts a different reply. */
export function latestNativeChatReplyText(
messages: readonly NativeChatMessage[],
after = -1
): string {
for (let index = messages.length - 1; index > after; index -= 1) {
const message = messages[index]!
if (message.role === 'user') {
return ''
}
const text = assistantText(message)
if (text) {
return text
}
}
return ''
}
/**
* Decide the streaming text to show, or null to show nothing. Returns the
* preview only while it leads the transcript — i.e. it's longer than (and not
* already contained in) the last assistant turn. Once the real turn lands with
* the same (or more) text, the preview is suppressed so the bubble doesn't
* duplicate or flicker as the transcript catches up.
*
* `working` gates it: a stale preview from a finished turn never shows.
*
* `previewIsToolOutput` hard-gates it: several providers publish a tool's stdout or
* error as `lastAssistantMessage` so status cards can preview it. That text is not the
* reply, and it never appears in a transcript assistant block — so the catch-up rules
* below can never retire it and it would sit in the chat until the turn ended.
*/
export function deriveNativeChatStreamingText(args: {
messages: readonly NativeChatMessage[]
previewText: string | null | undefined
working: boolean
previewIsToolOutput?: boolean
}): string | null {
const { messages, previewText, working, previewIsToolOutput } = args
if (!working || previewIsToolOutput) {
return null
}
const text = previewText?.trim()
if (!text) {
return null
}
const lastText = assistantText(messages.at(-1))
if (lastText.includes(text) || text.length <= lastText.length) {
return null
}
// A tool call or thought after the sentence leaves the last row wordless. Hide only that same
// sentence: a new, shorter reply after the tool is not the earlier, longer commentary.
if (!lastText && latestNativeChatReplyText(messages).includes(text)) {
return null
}
return text
}
/** Build the synthetic streaming assistant message for the given text. */
export function nativeChatStreamingMessage(text: string): NativeChatMessage {
return {
id: NATIVE_CHAT_STREAMING_ID,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'hook'
}
}