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 }