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.
This commit is contained in:
Kelvin Amoaba
2026-10-15 11:04:34 +00:00
committed by GitHub
parent cdf4b3805c
commit f3551dba57
26 changed files with 680 additions and 133 deletions
@@ -112,6 +112,31 @@ describe('MobileNativeChatMessage on Android', () => {
expect(byType('MessageActionsSheet')).toHaveLength(1)
})
it("opens the actions sheet from a long press on a plan's link", () => {
act(() => {
renderer = create(
createElement(MobileNativeChatMessage, {
message: {
...message,
role: 'system',
blocks: [
{
type: 'text',
text: '1. Read [the guide](https://example.com)',
presentation: 'plan-document'
}
]
}
})
)
})
const [bubble] = byType('Pressable')
const [plan] = byType('MobileMarkdown')
expect(plan!.props.onLongPress).toBe(bubble!.props.onLongPress)
act(() => plan!.props.onLongPress())
expect(byType('MessageActionsSheet')).toHaveLength(1)
})
it('opens the actions sheet from a long press on an expanded reasoning row, not on its headline', () => {
const reasoning: NativeChatMessage = {
...message,
@@ -99,6 +99,9 @@ describe('mobile transcript roster row', () => {
const markdown = (tree: ReactTestRenderer): unknown[] =>
tree.root.findAll((node) => String(node.type) === 'MobileMarkdown').map((n) => n.props.content)
// A system row's own words draw as a faint aside, never as the agent's prose.
const aside = (tree: ReactTestRenderer): string[] =>
tree.root.findAll((node) => String(node.type) === 'Text').map(textOf)
const group = (tree: ReactTestRenderer): ReactTestInstance[] =>
tree.root.findAll((node) => byTestId(node, 'subagent-group'))
@@ -136,16 +139,16 @@ describe('mobile transcript roster row', () => {
timestamp: null,
source: 'transcript'
}
expect(markdown(render(textOnly))).toEqual(['Ran 2 subagents'])
expect(aside(render(textOnly))).toEqual(['Ran 2 subagents'])
const childless = render(roster([], 'Ran 2 subagents'))
expect(markdown(childless)).toEqual(['Ran 2 subagents'])
expect(aside(childless)).toEqual(['Ran 2 subagents'])
expect(group(childless)).toEqual([])
})
it('keeps real text beside a group', () => {
expect(markdown(render(roster(WORKING, 'Delegating the review.')))).toEqual([
'Delegating the review.'
])
const tree = render(roster(WORKING, 'Delegating the review.'))
expect(aside(tree)[0]).toBe('Delegating the review.')
expect(group(tree)).toHaveLength(1)
})
it('stays visible once its turn settles, outside the tool-run collapse', () => {
@@ -1,11 +1,22 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalToolCallItem } from '../../../src/shared/agent-session-journal-types'
import type {
AgentJournalStatusItem,
AgentJournalToolCallItem
} from '../../../src/shared/agent-session-journal-types'
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
import { projectStructuredItemToNativeChat } from '../../../src/shared/structured-agent-session-projection'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatMessage
} from '../../../src/shared/native-chat-types'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
import {
agentSessionFailureFact,
readProviderRetry
} from '../../../src/shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-message-styles'
@@ -37,6 +48,8 @@ vi.mock('react-native', async () => {
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
AlertCircle: 'AlertCircle',
AlertTriangle: 'AlertTriangle',
ArrowUp: 'ArrowUp',
Brain: 'Brain',
ChevronDown: 'ChevronDown',
@@ -114,10 +127,86 @@ describe('MobileNativeChatMessage', () => {
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
expect(
tree.root.findAll(
(node) => String(node.type) === 'View' && node.props.style === styles.contextBoundary
(node) =>
String(node.type) === 'View' && node.props.accessibilityLabel === 'Context cleared'
)
).toHaveLength(1)
})
// Each row as its host writes it: Claude's API retry and sign-in rows use these shared words.
const statusRow = (body: AgentJournalStatusItem): NativeChatMessage =>
projectStructuredItemToNativeChat({
itemId: 'status',
revision: 1,
sequence: 2,
observedAt: 2,
body
})!
it.each([
[
'an API retry as a warning',
statusRow({
kind: 'status',
tone: 'warning',
...agentSessionFailureWords(
agentSessionFailureFact('providerRetrying', {
retry: readProviderRetry({
error: 'overloaded',
status: 529,
attempt: 2,
maxRetries: 10
})
}),
{ surface: 'row', agentName: 'Claude' }
)
}),
colors.statusAmber
],
[
'a sign-in failure as an error',
statusRow({
kind: 'status',
tone: 'error',
...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), {
agentName: 'Claude',
provider: 'claude',
surface: 'row'
})
}),
colors.statusRed
],
[
'an interruption as a faint aside',
{
id: 'interrupted',
role: 'system',
blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }],
timestamp: 1,
source: 'transcript'
} satisfies NativeChatMessage,
colors.textMuted
]
])('draws %s, never as the agent speaking', (_, message, color) => {
const tree = render(message)
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
const text = tree.root.find(
(node) => String(node.type) === 'Text' && node.props.selectable === true
)
expect(text.props.children).toBe(message.blocks.find((block) => block.type === 'text')?.text)
expect(Object.assign({}, ...text.props.style)).toMatchObject({ color })
})
it('draws a compaction as a divider, not as a reply', () => {
const tree = render(
statusRow({ kind: 'status', text: 'Context compacted', presentation: 'compaction' })
)
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
expect(
tree.root.findAll(
(node) =>
String(node.type) === 'View' && node.props.accessibilityLabel === 'Context compacted'
)
).toHaveLength(1)
expect(styles.contextBoundary.borderTopColor).toBe(colors.borderSubtle)
})
it.each(['system', 'user'] as const)(
@@ -116,9 +116,9 @@ describe('mobile tool result availability', () => {
output: `preview ${index}\ncomplete output ${index}`
}))
)
const [row] = foldMobileNativeChatMessages([raw])
const [row] = foldMobileNativeChatMessages([raw], 'claude')
expect(row?.role).toBe('tool')
expect(foldMobileNativeChatMessages([row!])).toEqual([row])
expect(foldMobileNativeChatMessages([row!], 'claude')).toEqual([row])
const tree = render(row!)
expect(textIn(tree.root)).toContain('8×')
expect(
@@ -151,7 +151,7 @@ describe('mobile tool result availability', () => {
...calls,
{ type: 'tool-result', callId: 'call-17', output: 'late named output' }
])
const [row] = foldMobileNativeChatMessages([raw])
const [row] = foldMobileNativeChatMessages([raw], 'claude')
const pairs = toolFold.pairToolBlocks(row!.blocks)
expect(pairs.slice(0, 17).every((pair) => pair.result === undefined)).toBe(true)
expect(pairs[17]?.result?.output).toBe('late named output')
@@ -254,7 +254,7 @@ describe('mobile tool result availability', () => {
const raw = projectStructuredItemsToNativeChat(journal).map((row) =>
row.id === 'queued' ? { ...row, queued: true as const } : row
)
const rows = foldMobileNativeChatMessages(raw)
const rows = foldMobileNativeChatMessages(raw, 'claude')
const disclosure = useMobileNativeChatTurnDisclosure({
messages: rows,
enabled: true,
@@ -300,7 +300,7 @@ describe('mobile tool result availability', () => {
expect(drawn.find((row) => row.role === 'tool')?.id).toBe(orphan.id)
expect(waiting).toEqual(['queued'])
expect(button(tree.root, 'output one')).toBe(firstOutput)
expect(foldMobileNativeChatMessages(drawn)).toEqual(drawn)
expect(foldMobileNativeChatMessages(drawn, 'claude')).toEqual(drawn)
}
)
+21 -21
View File
@@ -1,4 +1,3 @@
import { isAgentSessionProviderContextBoundary } from '../../../src/shared/agent-session-provider-context'
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
import { memo, useCallback, useContext, useState } from 'react'
import { Image, Text as NativeText, View } from 'react-native'
@@ -16,17 +15,18 @@ import {
isRenderableSubagentGroup,
withoutSubagentGroupTwins
} from '../../../src/shared/native-chat-subagent-summary'
import {
AGENT_SESSION_HOST_STATUS_COPY,
isAgentSessionHostStatusPresentation
} from '../../../src/shared/agent-session-host-status-rows'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { MobileSourceCopyButton } from '../components/MobileSourceCopyButton'
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
import { deriveNativeChatRowContent } from '../../../src/shared/native-chat-row-content'
import { MobileNativeChatReasoningRow } from './MobileNativeChatReasoningRow'
import {
AGENT_SESSION_HOST_STATUS_COPY,
isAgentSessionHostStatusPresentation
} from '../../../src/shared/agent-session-host-status-rows'
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
import { MobileNativeChatNoticeRow } from './MobileNativeChatNoticeRow'
import { MobileNativeChatSubagentGroup } from './MobileNativeChatSubagentGroup'
import { ToolRun } from './MobileNativeChatToolRun'
import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status'
@@ -60,18 +60,7 @@ function Prose({
onLongPress?: () => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
if (isAgentSessionProviderContextBoundary(block.contextClear)) {
return (
<View style={styles.contextBoundary}>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}
>
{block.text}
</Text>
</View>
)
}
// A host line standing in for a message too large to send, on the person's own row.
if (isAgentSessionHostStatusPresentation(block.presentation)) {
return (
<Text
@@ -192,6 +181,10 @@ function MobileNativeChatMessageImpl({
// tool calls fold into a collapsible run beneath. The user's own messages get
// an inverted (filled accent) bubble so they stand apart from agent prose.
const { prose, tools } = splitNativeChatBlocks(message.blocks)
// A roster draws as its group row, which replaces its frozen sentence.
const drawnProse = withoutSubagentGroupTwins(prose)
// The agent did not write a system row: its words draw as a notice or a faint aside.
const isSystem = message.role === 'system'
const activeCall = structuredActivityUi
? selectActiveToolCall(tools, { activeTurnIsWorking })
: null
@@ -282,9 +275,16 @@ function MobileNativeChatMessageImpl({
{attribution}
</Text>
) : null}
{/* A roster draws as its group row, which replaces its frozen sentence; it is not tool
activity, so it stays out of the settled-tools collapse. */}
{withoutSubagentGroupTwins(prose).map((block, index) =>
{/* A roster is not tool activity, so it stays out of the settled-tools collapse. */}
{isSystem ? (
<MobileNativeChatNoticeRow
blocks={drawnProse}
fontScale={fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
) : null}
{drawnProse.map((block, index) =>
isSubagentGroupBlock(block) && isRenderableSubagentGroup(block) ? (
<MobileNativeChatSubagentGroup
key={`subagent-group:${block.groupId}`}
@@ -292,7 +292,7 @@ function MobileNativeChatMessageImpl({
open={subagentGroupsOpen?.has(block.groupId) === true}
onToggle={onToggleSubagentGroup}
/>
) : (
) : isSystem ? null : (
<Prose
key={index}
block={block}
@@ -0,0 +1,186 @@
import { StyleSheet, View } from 'react-native'
import { AlertCircle, AlertTriangle, Info } from 'lucide-react-native'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
import {
NATIVE_CHAT_NOTICE_COPY,
nativeChatNoticeBlock,
nativeChatNoticeLook,
type NativeChatNoticeTone
} from '../../../src/shared/native-chat-notice'
import { isTextBlock, type NativeChatBlock } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { MONO_SIZE, TEXT_SIZE } from './mobile-native-chat-message-styles'
const TONE_COLOR: Record<NativeChatNoticeTone, string> = {
warning: colors.statusAmber,
error: colors.statusRed,
notice: colors.textMuted
}
/** Pinch-zoomed type keeps its leading, so zoomed lines never overlap. */
function scaledType(size: number, fontScale: number, leading = 6) {
return { fontSize: size * fontScale, lineHeight: (size + leading) * fontScale }
}
/** A row the agent did not write in its own words, drawn as desktop's `NativeChatNoticeRow` and
* its faint system asides are, so it never reads as the agent speaking. */
export function MobileNativeChatNoticeRow({
blocks,
fontScale,
onOpenFile,
onLongPress
}: {
blocks: readonly NativeChatBlock[]
fontScale: number
onOpenFile?: (relativePath: string) => void
/** Android only: a long press on a plan's link opens the row's actions sheet. */
onLongPress?: () => void
}): React.JSX.Element | null {
const notice = nativeChatNoticeBlock(blocks)
// A system row with no notice of its own is an aside, a size under the agent's prose.
const aside = [styles.faint, scaledType(typography.bodySize, fontScale)]
if (!notice) {
const text = blocks
.filter(isTextBlock)
.map((block) => block.text)
.join('\n')
return text ? (
<Text selectable={INLINE_TEXT_SELECTION} style={aside}>
{text}
</Text>
) : null
}
const look = nativeChatNoticeLook(notice)
switch (look.kind) {
case 'divider': {
const label = NATIVE_CHAT_NOTICE_COPY[look.label]
return (
<View style={styles.divider} accessibilityRole="text" accessibilityLabel={label}>
<View style={styles.rule} />
<Text style={styles.dividerLabel}>{label}</Text>
<View style={styles.rule} />
</View>
)
}
case 'command-output':
return (
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.commandOutput, scaledType(MONO_SIZE, fontScale, 5)]}
>
{notice.text}
</Text>
)
case 'host-status':
return (
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.faint, scaledType(TEXT_SIZE, fontScale)]}
>
{AGENT_SESSION_HOST_STATUS_COPY[look.presentation]}
</Text>
)
case 'plan':
return (
<View style={styles.card}>
<Text style={styles.planTitle}>{NATIVE_CHAT_NOTICE_COPY.plan}</Text>
<MobileMarkdown
content={notice.text}
rangeSelectable
textScale={fontScale}
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
</View>
)
case 'aside':
return (
<Text selectable={INLINE_TEXT_SELECTION} style={aside}>
{notice.text}
</Text>
)
case 'line': {
const textStyle = [
styles.line,
scaledType(TEXT_SIZE, fontScale),
look.tone && { color: TONE_COLOR[look.tone] }
]
if (!look.tone) {
return (
<Text selectable={INLINE_TEXT_SELECTION} style={textStyle}>
{notice.text}
</Text>
)
}
const Icon =
look.tone === 'warning' ? AlertTriangle : look.tone === 'error' ? AlertCircle : Info
return (
<View style={[styles.card, styles.toneCard]}>
<Icon
size={16 * fontScale}
color={TONE_COLOR[look.tone]}
strokeWidth={2}
style={styles.toneIcon}
/>
<Text selectable={INLINE_TEXT_SELECTION} style={[...textStyle, styles.toneText]}>
{notice.text}
</Text>
</View>
)
}
}
}
const styles = StyleSheet.create({
divider: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm
},
rule: {
flex: 1,
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle
},
dividerLabel: {
color: colors.textMuted,
fontSize: typography.metaSize
},
faint: {
color: colors.textMuted
},
commandOutput: {
color: colors.textPrimary,
fontFamily: typography.monoFamily
},
card: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.row,
backgroundColor: colors.bgPanel,
padding: spacing.md,
gap: spacing.sm
},
planTitle: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
toneCard: {
flexDirection: 'row',
alignItems: 'flex-start'
},
toneIcon: {
marginTop: 2
},
line: {
color: colors.textPrimary
},
toneText: {
flex: 1
}
})
@@ -55,7 +55,12 @@ export function MobileNativeChatOverlay({
keyboardInset
}: Props): React.JSX.Element | null {
const session = controller.nativeChatSession
const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages])
// The chat is drawn only with a resolved agent; the fallback never reaches the screen.
const agent = controller.nativeChatAgent ?? ''
const folded = useMemo(
() => foldMobileNativeChatMessages(session.messages, agent),
[session.messages, agent]
)
const streaming = useMobileNativeChatStreamingBubble(
folded,
controller.nativeChatStreamingText,
@@ -67,7 +67,7 @@ function phoneTranscript(state: StructuredAgentSessionState) {
/** What the phone's list draws: each row in draw order, its content, its turn and that turn's bar. */
function drawn(state: StructuredAgentSessionState): Drawn {
const { items, messages } = phoneTranscript(state)
const folded = foldMobileNativeChatMessages(messages)
const folded = foldMobileNativeChatMessages(messages, 'claude')
const { data } = buildMobileNativeChatTransientData({
messages,
folded,
@@ -96,7 +96,7 @@ function drawn(state: StructuredAgentSessionState): Drawn {
/** The ids the phone's list shows: its rows through the list's own turn disclosure. */
function listed(state: StructuredAgentSessionState): string[] {
const { items, messages } = phoneTranscript(state)
const folded = foldMobileNativeChatMessages(messages)
const folded = foldMobileNativeChatMessages(messages, 'claude')
const { data } = buildMobileNativeChatTransientData({
messages,
folded,
@@ -29,11 +29,6 @@ export const styles = StyleSheet.create({
lineHeight: TEXT_SIZE + 6,
fontWeight: '500'
},
contextBoundary: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
paddingTop: spacing.sm
},
hostNotice: {
color: colors.textMuted,
fontSize: TEXT_SIZE,
@@ -148,7 +148,20 @@ describe('MobileNativeChatMessage visuals', () => {
})
it('never renders visuals in system rows', () => {
const props = markdownProps(message('s1', 'system', '::orca-visual{file="a.html"}'))
expect(props.renderVisual).toBeUndefined()
act(() => {
renderer = create(
createElement(
MobileNativeChatVisualContext.Provider,
{ value: renderVisual },
createElement(MobileNativeChatMessage, {
message: message('s1', 'system', '::orca-visual{file="a.html"}')
})
)
)
})
expect(renderer!.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toEqual([])
expect(
renderer!.root.findAll((node) => String(node.type) === 'Text').map((node) => node.children)
).toContainEqual(['::orca-visual{file="a.html"}'])
})
})
@@ -20,6 +20,8 @@ function user(id: string, text: string): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 0, source: 'transcript' }
}
const fold = (messages: NativeChatMessage[]) => foldMobileNativeChatMessages(messages, 'claude')
describe('mobileNativeChatEmptyState', () => {
it('invites a first message naming the agent, matching desktop copy', () => {
// waiting-session (live agent, no transcript) and ready (loaded, empty) both
@@ -66,7 +68,7 @@ function build(
): NativeChatMessage[] {
return buildMobileNativeChatTransientData({
messages,
folded: foldMobileNativeChatMessages(messages),
folded: fold(messages),
streaming,
pending
}).data
@@ -157,7 +159,7 @@ describe('buildMobileNativeChatTransientData', () => {
}
]
})
const folded = foldMobileNativeChatMessages([
const folded = fold([
frameRow('wordless', 'notification:future/event', 'codex · notification:future/event'),
frameRow(
'failure',
@@ -182,12 +184,7 @@ describe('buildMobileNativeChatTransientData', () => {
timestamp: 0,
source: 'transcript'
})
const folded = foldMobileNativeChatMessages([
user('u1', 'audit'),
own('a'),
assistant('a1', 'started'),
own('b')
])
const folded = fold([user('u1', 'audit'), own('a'), assistant('a1', 'started'), own('b')])
expect(folded.map((message) => message.id)).toEqual(['u1', 'subagent:a', 'a1'])
expect(folded[1]?.blocks).toEqual([
{
@@ -207,7 +204,7 @@ describe('buildMobileNativeChatTransientData', () => {
})
it('keeps the phone-local image visible when the transcript replaces its optimistic echo', () => {
const folded = foldMobileNativeChatMessages([
const folded = fold([
user('source', '[Image: source: /tmp/a.png]'),
user('prompt', '[Image #1] look at this')
])
@@ -229,7 +226,7 @@ describe('buildMobileNativeChatTransientData', () => {
it('restores the local preview onto a marker-only transcript turn', () => {
const result = buildMobileNativeChatTransientData({
messages: [user('prompt', '[Image #1]')],
folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]),
folded: fold([user('prompt', '[Image #1]')]),
streaming: null,
pending: [],
imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] }
@@ -277,7 +274,7 @@ describe('foldMobileNativeChatMessages', () => {
// whose `tool_use` is older than the window. It used to render as its own
// bubble of raw shell output with no owning call.
it('drops a leading tool result whose call is older than the read window', () => {
const folded = foldMobileNativeChatMessages([
const folded = fold([
toolResult('orphan', 'Exit code 1\norca-ide not found'),
assistant('a1', 'Falling back to the installed binary.')
])
@@ -304,7 +301,7 @@ describe('foldMobileNativeChatMessages', () => {
}
]
}
const folded = foldMobileNativeChatMessages([
const folded = fold([
assistant('a1', 'Working.'),
toolCall('c1'),
toolResult('r1', 'ok'),
@@ -317,7 +314,7 @@ describe('foldMobileNativeChatMessages', () => {
})
it('still folds a result whose call is inside the window', () => {
const folded = foldMobileNativeChatMessages([
const folded = fold([
assistant('a1', 'Checking which binary is on PATH.'),
toolCall('c1'),
toolResult('r1', 'orca-ide not found')
@@ -332,7 +329,7 @@ describe('foldMobileNativeChatMessages', () => {
})
it('keeps a mixed Claude tool result with a harness sidecar paired', () => {
const folded = foldMobileNativeChatMessages([
const folded = fold([
toolCall('c1'),
{
id: 'mixed',
@@ -351,6 +348,25 @@ describe('foldMobileNativeChatMessages', () => {
{ type: 'tool-result', output: 'important output' }
])
})
it('draws a skill command the user ran as their message, as desktop does', () => {
// The rows the host reader decoded from a Claude Code session where `/hello Kelvin` ran a skill.
const folded = fold([
{
...user(
'd7306d00',
'<command-message>hello</command-message>\n<command-name>/hello</command-name>\n<command-args>Kelvin</command-args>'
),
parentId: 'f615a2bf'
},
{ ...assistant('0d3481c0', 'Hello, Kelvin!'), parentId: 'fa9d5ee6' }
])
expect(folded.map((message) => [message.role, message.blocks])).toEqual([
['user', [{ type: 'text', text: '/hello Kelvin' }]],
['assistant', [{ type: 'text', text: 'Hello, Kelvin!' }]]
])
})
})
// Claude consumes a mid-turn send through a `queued_command` attachment and writes
@@ -443,7 +459,7 @@ describe('buildMobileNativeChatTransientData anchoring', () => {
row('noise', 'user', '<system-reminder>hidden boundary'),
row('a1', 'assistant', 'arrived later')
]
const folded = foldMobileNativeChatMessages(messages)
const folded = fold(messages)
expect(folded.map((message) => message.id)).toEqual(['a1'])
const { data } = buildMobileNativeChatTransientData({
@@ -479,7 +495,7 @@ describe('buildMobileNativeChatTransientData anchoring', () => {
},
row('a2', 'assistant', 'done')
]
const folded = foldMobileNativeChatMessages(messages)
const folded = fold(messages)
expect(folded.map((message) => message.id)).toEqual(['a1', 'a2'])
const { data } = buildMobileNativeChatTransientData({
@@ -498,7 +514,7 @@ describe('buildMobileNativeChatTransientData anchoring', () => {
user('prompt', '[Image #1] earlier image'),
row('a2', 'assistant', 'done')
]
const folded = foldMobileNativeChatMessages(messages)
const folded = fold(messages)
expect(folded.map((message) => message.id)).toEqual(['a1', 'prompt', 'a2'])
const { data } = buildMobileNativeChatTransientData({
@@ -514,7 +530,7 @@ describe('buildMobileNativeChatTransientData anchoring', () => {
describe("mobile shows the conversation, never a subagent's rows", () => {
it("keeps the spawn's one line and drops what the subagent said and did", () => {
const child = { agentId: 'task-1', producerKind: 'agent' as const }
const folded = foldMobileNativeChatMessages([
const folded = fold([
user('ask', 'review the PR'),
{
id: 'spawn',
@@ -8,11 +8,13 @@ import { stripNoiseMessages } from '../../../src/shared/native-chat-noise'
import { isWordlessProviderFrameMessage } from '../../../src/shared/native-chat-provider-frame-summary'
import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold'
import { foldSubagentItems } from '../../../src/shared/native-chat-subagent-items'
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
import { prepareNativeChatMessages } from '../../../src/shared/native-chat-message-preparation'
import {
isImageSourceUserTurn,
normalizeImageTranscriptMessages
} from './mobile-native-chat-image-transcript-markers'
isImageRefBlock,
type AgentType,
type NativeChatMessage
} from '../../../src/shared/native-chat-types'
import { isImageSourceUserTurn } from './mobile-native-chat-image-transcript-markers'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** The centered empty-state copy for a chat with no messages, mirroring the
@@ -55,21 +57,23 @@ export type MobileNativeChatPendingItem = {
}
export function foldMobileNativeChatMessages(
messages: readonly NativeChatMessage[]
messages: readonly NativeChatMessage[],
agent: AgentType
): NativeChatMessage[] {
// The conversation only: a subagent's rows are that subagent's, and mobile shows
// each spawn as its roster row (one line per child) rather than the child's own rows.
// Normalize first (desktop assembler parity): image marker turns fold into
// image-ref blocks instead of rendering as raw `[Image: …]` text. An unrecognised event
// with no words of its own is stored, not drawn; mobile draws no task list, so a plan update
// goes too, before folding, so it never splits a tool run.
// Prepared as desktop prepares them, so a skill turn and an image marker turn draw as the
// user's message. An unrecognised event with no words of its own is stored, not drawn;
// mobile draws no task list, so a plan update goes too, before folding, so it never splits
// a tool run.
return stripNoiseMessages(
foldToolMessages(
foldSubagentItems(
normalizeImageTranscriptMessages(
prepareNativeChatMessages(
messages.filter(
(message) => isRootAgentJournalItem(message) && !isWordlessProviderFrameMessage(message)
)
),
agent
)
)
)
@@ -171,8 +171,9 @@ describe('a send a Stop took back before the agent started it, on the phone', ()
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message: messages[3]! }))
})
expect(renderer!.root.findAllByType(MobileMarkdown).map((node) => node.props.content)).toEqual([
'Stopped manually'
])
expect(renderer!.root.findAllByType(MobileMarkdown)).toEqual([])
expect(
renderer!.root.findAll((node) => String(node.type) === 'Text').map((node) => node.children)
).toEqual([['Stopped manually']])
})
})
@@ -91,6 +91,33 @@ describe('deriveMobileNativeChatStreaming', () => {
expect(results).toEqual([null, 'fresh answer', null])
})
it('keeps the bubble hidden once a thought lands below the streamed reply', () => {
const asked: NativeChatMessage[] = [
{
id: 'u1',
role: 'user',
blocks: [{ type: 'text', text: 'look' }],
timestamp: 0,
source: 'transcript'
}
]
const landed = [...asked, assistant('a1', 'Checking the notes file now.')]
const thought: NativeChatMessage = {
id: 'a1:reasoning-2',
role: 'reasoning',
blocks: [{ type: 'text', text: 'The file is short.' }],
timestamp: 0,
source: 'transcript'
}
const { results } = run([
{ folded: asked },
{ folded: asked, text: 'Checking the notes file now.', live: true },
{ folded: landed, text: 'Checking the notes file now.', live: true },
{ folded: [...landed, thought], text: 'Checking the notes file now.', live: true }
])
expect(results).toEqual([null, 'Checking the notes file now.', null, null])
})
it('suppresses an identical repeated reply once its own turn lands', () => {
const prior = [assistant('a1', 'Done.')]
const landed = [...prior, assistant('a2', 'Done.')]
@@ -1,3 +1,4 @@
import { latestNativeChatReplyText } from '../../../src/shared/native-chat-streaming'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
/** Decides whether the live streaming preview should render as a synthetic
@@ -5,7 +6,7 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
* from "a new reply happens to repeat the previous turn's prefix" — the old
* prefix test swallowed genuine repeated-prefix replies. The gate keeps the
* transcript tail observed when the current stream segment began: the bubble
* hides only when the tail MOVED during the segment and leads with the
* hides only when a reply that landed during the segment leads with the
* streamed text (the real turn landed), never for an older identical turn. */
export type MobileNativeChatStreamingGate = {
/** Chat/session identity this baseline belongs to. */
@@ -45,17 +46,6 @@ export function createMobileNativeChatStreamingGate(
return { scopeKey, prevText: '', baselineTailId: null }
}
function assistantTailText(tail: NativeChatMessage | undefined): string {
if (!tail || tail.role !== 'assistant') {
return ''
}
return tail.blocks
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
}
// Reuses the incoming gate object when nothing moved, so a caller can detect
// "no change" by reference (and a render-time state adjustment can settle).
function advanceGate(
@@ -103,10 +93,11 @@ export function deriveMobileNativeChatStreaming(
// (next reply part); re-anchor to the tail that predates it.
const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText)
const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId
const tailLeadsWithStream = assistantTailText(tail).startsWith(text)
// A null baseline (text on the very first tick, no tail ever seen) is unequal
// to every real tail id, so this degrades to the legacy suppress-on-prefix rule.
const caughtUp = tailLeadsWithStream && tailId !== baselineTailId
// Only a reply that landed after the segment began can be the streamed one. A null baseline
// (text on the very first tick, no tail ever seen) reads the whole turn: suppress-on-prefix.
const baselineIndex =
baselineTailId === null ? -1 : folded.findIndex((message) => message.id === baselineTailId)
const caughtUp = latestNativeChatReplyText(folded, baselineIndex).startsWith(text)
return {
gate: advanceGate(scopedGate, text, baselineTailId),
streaming: caughtUp ? null : text
@@ -125,7 +125,7 @@ describe('mobile transcript tool result paging', () => {
renderer = create(createElement(Harness, { client }))
})
const windowed = foldMobileNativeChatMessages(current().messages)
const windowed = foldMobileNativeChatMessages(current().messages, 'claude')
expect(pairOutputs(windowed)).toEqual([
['silent-2047', 'late named output'],
[undefined, 'output A'],
@@ -137,7 +137,7 @@ describe('mobile transcript tool result paging', () => {
transcriptOffset: 800,
blocks: [{ output: 'output A' }, { output: 'output B', isError: true }]
})
expect(foldMobileNativeChatMessages(windowed)).toEqual(windowed)
expect(foldMobileNativeChatMessages(windowed, 'claude')).toEqual(windowed)
expect(current().messages).toEqual(tail)
expect(current().messages.every((row) => !Object.hasOwn(row, 'unpairedToolResults'))).toBe(
true
@@ -159,7 +159,7 @@ describe('mobile transcript tool result paging', () => {
'backlog',
'source-results'
])
const whole = foldMobileNativeChatMessages(current().messages)
const whole = foldMobileNativeChatMessages(current().messages, 'claude')
expect(pairOutputs(whole)).toEqual([
['outside-a', 'output A'],
['outside-b', 'output B'],
@@ -167,12 +167,12 @@ describe('mobile transcript tool result paging', () => {
])
expect(whole.some((row) => row.role === 'tool')).toBe(false)
expect(nativeChatTurnMembership(whole).turnKeys).toEqual(['prompt', 'prompt'])
expect(foldMobileNativeChatMessages(whole)).toEqual(whole)
expect(foldMobileNativeChatMessages(whole, 'claude')).toEqual(whole)
await act(async () => {
emit({ type: 'snapshot', messages: structuredClone(tail), hasMore: true })
})
expect(foldMobileNativeChatMessages(current().messages)).toEqual(whole)
expect(foldMobileNativeChatMessages(current().messages, 'claude')).toEqual(whole)
expect(current().hasMore).toBe(false)
expect(current().messages.every((row) => !Object.hasOwn(row, 'unpairedToolResults'))).toBe(
true
@@ -112,7 +112,7 @@ function Harness(props: {
)
const { data } = buildMobileNativeChatTransientData({
messages,
folded: foldMobileNativeChatMessages(messages),
folded: foldMobileNativeChatMessages(messages, 'claude'),
streaming: null,
pending: []
})
@@ -10,6 +10,7 @@ import type {
import { deriveNativeChatRowContent } from '../../../../shared/native-chat-row-content'
import { NativeChatReasoningRow } from './NativeChatReasoningRow'
import { NativeChatNoticeRow } from './NativeChatNoticeRow'
import { nativeChatNoticeBlock } from '../../../../shared/native-chat-notice'
import { nativeChatBlocksInOwnWords } from './native-chat-stopped-before-start-row'
import { ProviderFrameRow } from './NativeChatTranscriptChrome'
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
@@ -114,13 +115,8 @@ export const MessageRow = memo(function MessageRow(
return null
}
const notice = isSystem
? blocks.find(
(block) =>
block.type === 'text' && (block.presentation !== undefined || block.tone !== undefined)
)
: undefined
if (notice?.type === 'text') {
const notice = isSystem ? nativeChatNoticeBlock(blocks) : undefined
if (notice) {
return (
<div ref={rowRef}>
<NativeChatNoticeRow
@@ -8,14 +8,16 @@ import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import {
AGENT_SESSION_HOST_STATUS_COPY as HOST_STATUS_COPY,
isAgentSessionHostStatusPresentation,
type AgentSessionHostStatusPresentation
} from '../../../../shared/agent-session-host-status-rows'
import type { NativeChatTextBlock } from '../../../../shared/native-chat-types'
import {
NATIVE_CHAT_NOTICE_COPY,
nativeChatNoticeLook
} from '../../../../shared/native-chat-notice'
import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context'
import { NativeChatInterruptedContinueButton } from './NativeChatInterruptedContinue'
import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words'
import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop'
import { ProviderFrameRow } from './NativeChatTranscriptChrome'
import { Button } from '@/components/ui/button'
import {
@@ -57,11 +59,15 @@ export function NativeChatNoticeRow({
useTranslation()
const orcaStopView = useNativeChatOrcaStopView()
const claudeSignIn = useNativeChatClaudeSignInView()
if (block.presentation === 'compaction' || block.presentation === 'context-cleared') {
const look = nativeChatNoticeLook(block)
if (look.kind === 'divider') {
const label =
block.presentation === 'context-cleared'
? translate('components.native-chat.notices.contextCleared', 'Context cleared')
: translate('components.native-chat.notices.compaction', 'Context compacted')
look.label === 'contextCleared'
? translate(
'components.native-chat.notices.contextCleared',
NATIVE_CHAT_NOTICE_COPY.contextCleared
)
: translate('components.native-chat.notices.compaction', NATIVE_CHAT_NOTICE_COPY.compaction)
return (
<div
role="separator"
@@ -74,7 +80,7 @@ export function NativeChatNoticeRow({
</div>
)
}
if (block.presentation === 'command-output') {
if (look.kind === 'command-output') {
// Why: command output is laid out in columns; proportional type breaks its grid.
return (
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-chat-foreground">
@@ -82,20 +88,20 @@ export function NativeChatNoticeRow({
</pre>
)
}
if (isAgentSessionHostStatusPresentation(block.presentation)) {
if (look.kind === 'host-status') {
// The look of any other host status line; only the words are the reader's.
return (
<p className="min-w-0 max-w-full select-text text-sm text-chat-foreground-faint [overflow-wrap:anywhere]">
{HOST_STATUS_WORDS[block.presentation]()}
{HOST_STATUS_WORDS[look.presentation]()}
</p>
)
}
if (block.presentation === 'plan-document') {
if (look.kind === 'plan') {
return (
<Card className="gap-3 py-3 shadow-xs">
<CardHeader className="px-4">
<CardTitle className="text-sm">
{translate('components.native-chat.notices.plan', 'Plan')}
{translate('components.native-chat.notices.plan', NATIVE_CHAT_NOTICE_COPY.plan)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 text-sm leading-relaxed text-foreground">
@@ -144,8 +150,7 @@ export function NativeChatNoticeRow({
sayAgentSessionFailureTranslated
)
: block.text
const tone =
named || block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION ? 'notice' : block.tone
const tone = named ? 'notice' : look.kind === 'line' ? look.tone : null
const Icon =
tone === 'warning'
? AlertTriangle
@@ -1,7 +1,7 @@
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
import { surfaceSkillInvocationUserTurns } from '../../../../shared/native-chat-command-envelope'
import { surfaceNativeChatCommandOutputs } from '../../../../shared/native-chat-command-output'
import { normalizeImageTranscriptMessages } from '../../../../shared/native-chat-image-transcript-markers'
import {
prepareNativeChatMessages,
surfaceNativeChatHarnessTurns
} from '../../../../shared/native-chat-message-preparation'
import type { AgentType, NativeChatMessage } from '../../../../shared/native-chat-types'
import { assembleNativeChatSession } from './native-chat-session-assembler'
@@ -9,18 +9,13 @@ export function prepareNativeChatLiveMessages(
messages: NativeChatMessage[],
agent: AgentType
): NativeChatMessage[] {
const commandNames = new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name))
const surfaced = surfaceNativeChatCommandOutputs(
surfaceSkillInvocationUserTurns(messages, commandNames),
agent
)
const normalized = normalizeImageTranscriptMessages(surfaced)
if (!hasMixedSources(normalized)) {
return normalized
const prepared = prepareNativeChatMessages(messages, agent)
if (!hasMixedSources(prepared)) {
return prepared
}
// A second pass preserves legacy cross-source winners after sorting or presentation transforms.
return assembleNativeChatSession({
sources: { transcript: surfaced },
sources: { transcript: surfaceNativeChatHarnessTurns(messages, agent) },
sessionId: null,
agent
}).messages
+4 -1
View File
@@ -2939,8 +2939,11 @@
"writeFailed": "Orca couldn't hand this message to the agent, so it was not sent."
},
"notices": {
"compaction": "Context compacted",
"contextCleared": "Context cleared",
"historyItemTooLarge": "This part of the chat was too large to show.",
"historyRepaired": "Part of this chat's history couldn't be loaded."
"historyRepaired": "Part of this chat's history couldn't be loaded.",
"plan": "Plan"
},
"question": {
"other": "Other…"
@@ -0,0 +1,30 @@
// What a chat reads from a terminal agent's transcript before folding it into rows. Desktop and
// mobile both prepare through here, so a turn one of them draws is never dropped by the other.
import type { AgentType } from './agent-status-types'
import { getVerifiedNativeChatCommands } from './native-chat-agent-profiles'
import { surfaceSkillInvocationUserTurns } from './native-chat-command-envelope'
import { surfaceNativeChatCommandOutputs } from './native-chat-command-output'
import { normalizeImageTranscriptMessages } from './native-chat-image-transcript-markers'
import type { NativeChatMessage } from './native-chat-types'
/** A skill invocation as the `/name args` the user sent, and a reporting command's reply as its
* output; the harness records both as envelopes the noise filter would otherwise hide. */
export function surfaceNativeChatHarnessTurns(
messages: readonly NativeChatMessage[],
agent: AgentType
): NativeChatMessage[] {
const commandNames = new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name))
return surfaceNativeChatCommandOutputs(
surfaceSkillInvocationUserTurns(messages, commandNames),
agent
)
}
/** The harness turns surfaced, then image marker turns folded into the prompt they belong to. */
export function prepareNativeChatMessages(
messages: readonly NativeChatMessage[],
agent: AgentType
): NativeChatMessage[] {
return normalizeImageTranscriptMessages(surfaceNativeChatHarnessTurns(messages, agent))
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { nativeChatNoticeBlock, nativeChatNoticeLook } from './native-chat-notice'
import type { NativeChatTextBlock } from './native-chat-types'
const block = (fields: Partial<NativeChatTextBlock>): NativeChatTextBlock => ({
type: 'text',
text: 'words',
...fields
})
describe('nativeChatNoticeLook', () => {
it.each([
[{ presentation: 'compaction' }, { kind: 'divider', label: 'compaction' }],
[{ presentation: 'context-cleared' }, { kind: 'divider', label: 'contextCleared' }],
[{ presentation: 'command-output' }, { kind: 'command-output' }],
[
{ presentation: 'history-repaired' },
{ kind: 'host-status', presentation: 'history-repaired' }
],
[{ presentation: 'plan-document' }, { kind: 'plan' }],
[{ presentation: 'stopped-before-start' }, { kind: 'aside' }],
// Stored red for clients that predate the orca-stop row; Orca stopped it, nothing failed.
[
{ presentation: 'orca-stop', tone: 'error' },
{ kind: 'line', tone: 'notice' }
],
[
{ presentation: 'compaction-skipped', tone: 'warning' },
{ kind: 'line', tone: 'warning' }
],
[{ tone: 'error' }, { kind: 'line', tone: 'error' }],
[{ tone: 'loud' }, { kind: 'line', tone: null }]
] as const)('reads %j as %j', (fields, look) => {
expect(nativeChatNoticeLook(block(fields))).toEqual(look)
})
})
describe('nativeChatNoticeBlock', () => {
it('finds the block with a presentation or tone, and none in plain words', () => {
const notice = block({ tone: 'warning' })
expect(nativeChatNoticeBlock([block({}), notice])).toBe(notice)
expect(nativeChatNoticeBlock([block({})])).toBeUndefined()
})
})
+73
View File
@@ -0,0 +1,73 @@
// How a chat draws a row the agent did not write in its own words: compaction and clear markers,
// command output, host status, plans, and warnings or errors. Desktop translates these words (they
// are its fallbacks); the phone shows them as they are.
import {
isAgentSessionHostStatusPresentation,
type AgentSessionHostStatusPresentation
} from './agent-session-host-status-rows'
import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from './agent-session-orca-stop'
import { NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION } from './native-chat-stopped-before-start'
import type { NativeChatBlock, NativeChatTextBlock } from './native-chat-types'
export const NATIVE_CHAT_NOTICE_COPY = {
compaction: 'Context compacted',
contextCleared: 'Context cleared',
plan: 'Plan'
} as const
export type NativeChatNoticeTone = 'warning' | 'error' | 'notice'
export type NativeChatNoticeLook =
/** A rule across the chat with its label. */
| { kind: 'divider'; label: 'compaction' | 'contextCleared' }
/** Laid out in columns, so drawn in monospace. */
| { kind: 'command-output' }
/** The host's line in the reader's words, drawn faint. */
| { kind: 'host-status'; presentation: AgentSessionHostStatusPresentation }
| { kind: 'plan' }
/** Faint, as a system row with no notice of its own is. */
| { kind: 'aside' }
/** A boxed line with its icon when it has a tone; plain text when it has none. */
| { kind: 'line'; tone: NativeChatNoticeTone | null }
/** The block a system row draws as its notice. A system row without one reads as a faint aside. */
export function nativeChatNoticeBlock(
blocks: readonly NativeChatBlock[]
): NativeChatTextBlock | undefined {
return blocks.find(
(block): block is NativeChatTextBlock =>
block.type === 'text' && (block.presentation !== undefined || block.tone !== undefined)
)
}
export function nativeChatNoticeLook(block: NativeChatTextBlock): NativeChatNoticeLook {
const { presentation } = block
if (presentation === 'compaction') {
return { kind: 'divider', label: 'compaction' }
}
if (presentation === 'context-cleared') {
return { kind: 'divider', label: 'contextCleared' }
}
if (presentation === 'command-output') {
return { kind: 'command-output' }
}
if (isAgentSessionHostStatusPresentation(presentation)) {
return { kind: 'host-status', presentation }
}
if (presentation === 'plan-document') {
return { kind: 'plan' }
}
if (presentation === NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION) {
return { kind: 'aside' }
}
// Orca stopped the reply, not the agent: stored red for clients that predate it, read as a notice.
if (presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION) {
return { kind: 'line', tone: 'notice' }
}
const { tone } = block
return {
kind: 'line',
tone: tone === 'warning' || tone === 'error' || tone === 'notice' ? tone : null
}
}
+22
View File
@@ -118,6 +118,28 @@ describe('deriveNativeChatStreamingText', () => {
})
).toBe('Partial answer that is now much longer than before')
})
it('hides the sentence it streamed while a tool runs, but not a new reply after the tool', () => {
// A Claude transcript writes the sentence and the tool call it led into as separate rows.
const sentence = 'Checking the notes file now, then I will compare it with the settings.'
const toolCall: NativeChatMessage = {
id: 'f4470ba3',
role: 'assistant',
blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'sleep 12' } }],
timestamp: null,
source: 'transcript'
}
const messages = [user('Say it, then run sleep 12.'), assistant(sentence), toolCall]
expect(
deriveNativeChatStreamingText({ messages, previewText: sentence, working: true })
).toBeNull()
expect(
deriveNativeChatStreamingText({
messages,
previewText: 'The settings look correct.',
working: true
})
).toBe('The settings look correct.')
})
})
describe('nativeChatStreamingMessage', () => {
+24
View File
@@ -22,6 +22,25 @@ function assistantText(message: NativeChatMessage | undefined): string {
.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
@@ -54,6 +73,11 @@ export function deriveNativeChatStreamingText(args: {
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
}