From f3551dba5757a7dbbb5410c3c05447d9943b95e6 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:04:34 +0000 Subject: [PATCH] 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. --- .../MobileNativeChatMessage.android.test.ts | 25 +++ ...eNativeChatMessage.subagent-group.test.tsx | 13 +- .../session/MobileNativeChatMessage.test.ts | 97 ++++++++- ...tMessage.tool-result-availability.test.tsx | 10 +- .../src/session/MobileNativeChatMessage.tsx | 42 ++-- .../src/session/MobileNativeChatNoticeRow.tsx | 186 ++++++++++++++++++ .../src/session/MobileNativeChatOverlay.tsx | 7 +- ...native-chat-merged-snapshot-parity.test.ts | 4 +- .../mobile-native-chat-message-styles.ts | 5 - ...mobile-native-chat-message-visuals.test.ts | 17 +- .../mobile-native-chat-render-data.test.ts | 52 +++-- .../session/mobile-native-chat-render-data.ts | 26 +-- ...-native-chat-stopped-before-start.test.tsx | 7 +- .../mobile-native-chat-streaming-gate.test.ts | 27 +++ .../mobile-native-chat-streaming-gate.ts | 23 +-- ...e-chat-session-tool-result-paging.test.tsx | 10 +- ...chat-turn-disclosure-opening-send.test.tsx | 2 +- .../native-chat/NativeChatMessageRow.tsx | 10 +- .../native-chat/NativeChatNoticeRow.tsx | 31 +-- .../native-chat-live-message-preparation.ts | 21 +- .../src/i18n/en-runtime-required.json | 5 +- src/shared/native-chat-message-preparation.ts | 30 +++ src/shared/native-chat-notice.test.ts | 44 +++++ src/shared/native-chat-notice.ts | 73 +++++++ src/shared/native-chat-streaming.spec.ts | 22 +++ src/shared/native-chat-streaming.ts | 24 +++ 26 files changed, 680 insertions(+), 133 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatNoticeRow.tsx create mode 100644 src/shared/native-chat-message-preparation.ts create mode 100644 src/shared/native-chat-notice.test.ts create mode 100644 src/shared/native-chat-notice.ts diff --git a/mobile/src/session/MobileNativeChatMessage.android.test.ts b/mobile/src/session/MobileNativeChatMessage.android.test.ts index 24ef4bfb698..b89e6e82ffb 100644 --- a/mobile/src/session/MobileNativeChatMessage.android.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.android.test.ts @@ -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, diff --git a/mobile/src/session/MobileNativeChatMessage.subagent-group.test.tsx b/mobile/src/session/MobileNativeChatMessage.subagent-group.test.tsx index c4a4e48b508..0869f90773a 100644 --- a/mobile/src/session/MobileNativeChatMessage.subagent-group.test.tsx +++ b/mobile/src/session/MobileNativeChatMessage.subagent-group.test.tsx @@ -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', () => { diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 85be9e85fde..1d74654139d 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -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)( diff --git a/mobile/src/session/MobileNativeChatMessage.tool-result-availability.test.tsx b/mobile/src/session/MobileNativeChatMessage.tool-result-availability.test.tsx index 141301a36ab..d56514c29e9 100644 --- a/mobile/src/session/MobileNativeChatMessage.tool-result-availability.test.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tool-result-availability.test.tsx @@ -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) } ) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 3dac76c5db2..e718d354871 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -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 ( - - - {block.text} - - - ) - } + // A host line standing in for a message too large to send, on the person's own row. if (isAgentSessionHostStatusPresentation(block.presentation)) { return ( ) : 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 ? ( + + ) : null} + {drawnProse.map((block, index) => isSubagentGroupBlock(block) && isRenderableSubagentGroup(block) ? ( - ) : ( + ) : isSystem ? null : ( = { + 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} + + ) : null + } + const look = nativeChatNoticeLook(notice) + switch (look.kind) { + case 'divider': { + const label = NATIVE_CHAT_NOTICE_COPY[look.label] + return ( + + + {label} + + + ) + } + case 'command-output': + return ( + + {notice.text} + + ) + case 'host-status': + return ( + + {AGENT_SESSION_HOST_STATUS_COPY[look.presentation]} + + ) + case 'plan': + return ( + + {NATIVE_CHAT_NOTICE_COPY.plan} + + + ) + case 'aside': + return ( + + {notice.text} + + ) + case 'line': { + const textStyle = [ + styles.line, + scaledType(TEXT_SIZE, fontScale), + look.tone && { color: TONE_COLOR[look.tone] } + ] + if (!look.tone) { + return ( + + {notice.text} + + ) + } + const Icon = + look.tone === 'warning' ? AlertTriangle : look.tone === 'error' ? AlertCircle : Info + return ( + + + + {notice.text} + + + ) + } + } +} + +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 + } +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index e38c2b88bcd..3161e4e235e 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -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, diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts index 985fd2da711..d1c5f66e30c 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -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, diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 591f8e6c49a..1dc08ed892d 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -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, diff --git a/mobile/src/session/mobile-native-chat-message-visuals.test.ts b/mobile/src/session/mobile-native-chat-message-visuals.test.ts index b0eda2cdfa5..2ad5a013299 100644 --- a/mobile/src/session/mobile-native-chat-message-visuals.test.ts +++ b/mobile/src/session/mobile-native-chat-message-visuals.test.ts @@ -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"}']) }) }) diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index a5c7a838cfd..1f70dc98ff9 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -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', + 'hello\n/hello\nKelvin' + ), + 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', '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', diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 66ff533520e..2ff9d993b97 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -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 ) ) ) diff --git a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx index 3b640a8317a..78820a10084 100644 --- a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx +++ b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx @@ -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']]) }) }) diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts index 401aaee14ca..7149ece184a 100644 --- a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts +++ b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts @@ -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.')] diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.ts b/mobile/src/session/mobile-native-chat-streaming-gate.ts index c13a2f1a3b2..ca1063996e5 100644 --- a/mobile/src/session/mobile-native-chat-streaming-gate.ts +++ b/mobile/src/session/mobile-native-chat-streaming-gate.ts @@ -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 diff --git a/mobile/src/session/use-mobile-native-chat-session-tool-result-paging.test.tsx b/mobile/src/session/use-mobile-native-chat-session-tool-result-paging.test.tsx index 38a9459d47e..f876ee85aa4 100644 --- a/mobile/src/session/use-mobile-native-chat-session-tool-result-paging.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-session-tool-result-paging.test.tsx @@ -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 diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx index 60019bf2c3a..50d245b9772 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure-opening-send.test.tsx @@ -112,7 +112,7 @@ function Harness(props: { ) const { data } = buildMobileNativeChatTransientData({ messages, - folded: foldMobileNativeChatMessages(messages), + folded: foldMobileNativeChatMessages(messages, 'claude'), streaming: null, pending: [] }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 3c8be2c92ae..898b1c9f159 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -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 (
) } - if (block.presentation === 'command-output') { + if (look.kind === 'command-output') { // Why: command output is laid out in columns; proportional type breaks its grid. return (
@@ -82,20 +88,20 @@ export function NativeChatNoticeRow({
       
) } - 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 (

- {HOST_STATUS_WORDS[block.presentation]()} + {HOST_STATUS_WORDS[look.presentation]()}

) } - if (block.presentation === 'plan-document') { + if (look.kind === 'plan') { return ( - {translate('components.native-chat.notices.plan', 'Plan')} + {translate('components.native-chat.notices.plan', NATIVE_CHAT_NOTICE_COPY.plan)} @@ -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 diff --git a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts index bed16d90663..0639ba7d4bf 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts @@ -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 diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 5a3863f6759..32ff25cbead 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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…" diff --git a/src/shared/native-chat-message-preparation.ts b/src/shared/native-chat-message-preparation.ts new file mode 100644 index 00000000000..c61350a305c --- /dev/null +++ b/src/shared/native-chat-message-preparation.ts @@ -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)) +} diff --git a/src/shared/native-chat-notice.test.ts b/src/shared/native-chat-notice.test.ts new file mode 100644 index 00000000000..065f640aea3 --- /dev/null +++ b/src/shared/native-chat-notice.test.ts @@ -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 => ({ + 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() + }) +}) diff --git a/src/shared/native-chat-notice.ts b/src/shared/native-chat-notice.ts new file mode 100644 index 00000000000..a6745279594 --- /dev/null +++ b/src/shared/native-chat-notice.ts @@ -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 + } +} diff --git a/src/shared/native-chat-streaming.spec.ts b/src/shared/native-chat-streaming.spec.ts index 12ff5c7dfac..36646a1af2d 100644 --- a/src/shared/native-chat-streaming.spec.ts +++ b/src/shared/native-chat-streaming.spec.ts @@ -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', () => { diff --git a/src/shared/native-chat-streaming.ts b/src/shared/native-chat-streaming.ts index eacce8dd2cb..b489abd0217 100644 --- a/src/shared/native-chat-streaming.ts +++ b/src/shared/native-chat-streaming.ts @@ -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 }