diff --git a/mobile/src/session/MobileNativeChatDiffCard.tsx b/mobile/src/session/MobileNativeChatDiffCard.tsx new file mode 100644 index 00000000000..080e32fe24a --- /dev/null +++ b/mobile/src/session/MobileNativeChatDiffCard.tsx @@ -0,0 +1,207 @@ +import { useMemo } from 'react' +import { Pressable, ScrollView, Text, View } from 'react-native' +import { ChevronRight, FileMinus2, FilePen, FilePlus2 } from 'lucide-react-native' +import { + NATIVE_CHAT_DIFF_CARD_COPY, + nativeChatEditFilePatchText +} from '../../../src/shared/native-chat-edit-cards' +import { + unifiedLineNumber, + type NativeChatEditFile, + type NativeChatEditLine +} from '../../../src/shared/native-chat-edit-model' +import { NATIVE_CHAT_TOOL_ROW_VERB_COPY } from '../../../src/shared/native-chat-tool-line-label' +import { MobileSourceCopyButton } from '../components/MobileSourceCopyButton' +import { colors } from '../theme/mobile-theme' +import { useMobileNativeChatOpenedDetail } from './mobile-native-chat-opened-detail' +import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store' +import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles' + +function baseName(path: string): string { + return path.split(/[\\/]/).at(-1) || path +} + +function VerbIcon({ kind, size }: { kind: NativeChatEditFile['changeKind']; size: number }) { + const Icon = kind === 'added' ? FilePlus2 : kind === 'deleted' ? FileMinus2 : FilePen + return +} + +function DiffRow({ + line, + gutterChars, + type +}: { + line: NativeChatEditLine + gutterChars: number + type: ReturnType['code'] +}): React.JSX.Element { + if (line.kind === 'gap') { + // The break between two regions of the file, so the gutter's jump is accounted for. + return ( + + ⋯ + + ) + } + return ( + + {gutterChars > 0 ? ( + + {unifiedLineNumber(line) ?? ''} + + ) : null} + + {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} + + {line.text} + + ) +} + +/** The card's rows in their own bounded box. Its own component so a collapsed card + * builds none of them. */ +function DiffCardRows({ file, fontScale }: { file: NativeChatEditFile; fontScale: number }) { + const onLayout = useMobileNativeChatOpenedDetail() + const type = toolType(fontScale).code + // A snippet-relative number would read as a file position, so the gutter is blank + // unless the provider resolved the ranges. + const widest = file.lineNumbersKnown + ? file.lines.reduce((max, line) => Math.max(max, unifiedLineNumber(line) ?? 0), 0) + : 0 + const gutterChars = file.lineNumbersKnown ? Math.max(3, String(widest).length + 1) : 0 + // Desktop's row identity: the line itself, and which repeat of it this is. + const seen = new Map() + return ( + + {file.lines.map((line) => { + const signature = `${line.kind}:${line.oldLineNumber}:${line.newLineNumber}:${line.text}` + const occurrence = seen.get(signature) ?? 0 + seen.set(signature, occurrence + 1) + return ( + + ) + })} + + ) +} + +/** Desktop parity: `NativeChatDiffCard`. One file an agent changed: its verb, name and + * counts, opening to the unified rows. A change with no body keeps only its header. */ +export function MobileNativeChatDiffCard({ + file, + fontScale, + defaultExpanded = false, + disclosureKey, + onOpenFile +}: { + file: NativeChatEditFile + fontScale: number + defaultExpanded?: boolean + /** Identity this card's open state is remembered under while it is unmounted. */ + disclosureKey?: string + onOpenFile?: (path: string) => void +}): React.JSX.Element { + const [expanded, setExpanded] = useMobileNativeChatDisclosure(disclosureKey, defaultExpanded) + const hasBody = file.lines.length > 0 + const copyText = useMemo(() => nativeChatEditFilePatchText(file.lines), [file.lines]) + const type = toolType(fontScale) + const iconSize = 15 * fontScale + // A deleted file has nothing left to open. + const openFile = file.changeKind === 'deleted' ? undefined : onOpenFile + return ( + + + hasBody && setExpanded(!expanded)} + disabled={!hasBody} + accessibilityRole="button" + accessibilityState={hasBody ? { expanded } : undefined} + > + + + + + {NATIVE_CHAT_TOOL_ROW_VERB_COPY[file.changeKind]} + + {file.oldPath ? ( + <> + + {baseName(file.oldPath)} + + → + + ) : null} + openFile(file.path) : undefined} + suppressHighlighting={!openFile} + accessibilityRole={openFile ? 'link' : undefined} + > + {baseName(file.path)} + + + {file.added > 0 ? {`+${file.added}`} : null} + {file.added > 0 && file.removed > 0 ? ' ' : null} + {file.removed > 0 ? ( + {`-${file.removed}`} + ) : null} + + {file.truncated ? ( + + {NATIVE_CHAT_DIFF_CARD_COPY.diffTruncated} + + ) : null} + {hasBody ? ( + + + + ) : null} + + + + {hasBody && expanded ? : null} + + ) +} diff --git a/mobile/src/session/MobileNativeChatMessage.android.test.ts b/mobile/src/session/MobileNativeChatMessage.android.test.ts index cc7826d8fa3..d39f7edcc1c 100644 --- a/mobile/src/session/MobileNativeChatMessage.android.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.android.test.ts @@ -34,13 +34,26 @@ vi.mock('react-native', async () => { vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', + Bot: 'Bot', Brain: 'Brain', + Check: 'Check', ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight', Copy: 'Copy', + Eye: 'Eye', + FileMinus2: 'FileMinus2', + FilePen: 'FilePen', + FilePlus2: 'FilePlus2', + Folder: 'Folder', + Globe: 'Globe', + ListChecks: 'ListChecks', + MessageSquareMore: 'MessageSquareMore', + Pencil: 'Pencil', + Plug: 'Plug', + Search: 'Search', SquareChevronRight: 'SquareChevronRight', SquareTerminal: 'SquareTerminal', - Wrench: 'Wrench', - ChevronRight: 'ChevronRight' + Wrench: 'Wrench' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ diff --git a/mobile/src/session/MobileNativeChatMessage.subagent-group.test.tsx b/mobile/src/session/MobileNativeChatMessage.subagent-group.test.tsx index 0869f90773a..a8a159a7a86 100644 --- a/mobile/src/session/MobileNativeChatMessage.subagent-group.test.tsx +++ b/mobile/src/session/MobileNativeChatMessage.subagent-group.test.tsx @@ -31,8 +31,20 @@ vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) vi.mock('lucide-react-native', () => ({ Bot: 'Bot', Brain: 'Brain', + Check: 'Check', ChevronDown: 'ChevronDown', ChevronRight: 'ChevronRight', + Eye: 'Eye', + FileMinus2: 'FileMinus2', + FilePen: 'FilePen', + FilePlus2: 'FilePlus2', + Folder: 'Folder', + Globe: 'Globe', + ListChecks: 'ListChecks', + MessageSquareMore: 'MessageSquareMore', + Pencil: 'Pencil', + Plug: 'Plug', + Search: 'Search', SquareChevronRight: 'SquareChevronRight', SquareTerminal: 'SquareTerminal', Wrench: 'Wrench' diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 2c602ee2287..2de5095fece 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -1,11 +1,7 @@ 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 { - AgentJournalStatusItem, - AgentJournalToolCallItem -} from '../../../src/shared/agent-session-journal-types' -import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary' +import type { AgentJournalStatusItem } from '../../../src/shared/agent-session-journal-types' import { projectStructuredItemToNativeChat } from '../../../src/shared/structured-agent-session-projection' import { NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, @@ -47,22 +43,35 @@ vi.mock('react-native', async () => { Text, View: ({ children, ...props }: { children?: unknown }) => React.createElement('View', props, children), - StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + useWindowDimensions: () => ({ width: 400, height: 800 }) } }) vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) vi.mock('lucide-react-native', () => ({ AlertCircle: 'AlertCircle', - MessageSquareMore: 'MessageSquareMore', AlertTriangle: 'AlertTriangle', ArrowUp: 'ArrowUp', + Bot: 'Bot', Brain: 'Brain', + Check: 'Check', ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight', Copy: 'Copy', + Eye: 'Eye', + FileMinus2: 'FileMinus2', + FilePen: 'FilePen', + FilePlus2: 'FilePlus2', + Folder: 'Folder', + Globe: 'Globe', + ListChecks: 'ListChecks', + MessageSquareMore: 'MessageSquareMore', + Pencil: 'Pencil', + Plug: 'Plug', + Search: 'Search', SquareChevronRight: 'SquareChevronRight', SquareTerminal: 'SquareTerminal', - Wrench: 'Wrench', - ChevronRight: 'ChevronRight' + Wrench: 'Wrench' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ @@ -94,6 +103,8 @@ describe('MobileNativeChatMessage', () => { toolsExpanded?: boolean structuredActivityUi?: boolean activeTurnIsWorking?: boolean + trailingRun?: boolean + foldedAway?: boolean turnExpanded?: boolean turnStatus?: { startedAt: number | null @@ -166,6 +177,13 @@ describe('MobileNativeChatMessage', () => { ]) }) + it("keeps an answered prompt's receipt when its settled turn folds away, as desktop does", () => { + const { messages, receipts } = structuredQuestionTranscript(ANSWERED_PROMPT_ITEMS) + const question = messages.find((message) => receipts.get(message.id)?.kind === 'question')! + const tree = render(question, { receipt: receipts.get(question.id), foldedAway: true }) + expect(words(tree.root).join('')).toContain('Asked: Do you prefer tea or coffee?') + }) + it('zooms a receipt with the chat, as its words were before it became a receipt', () => { const { messages, receipts } = structuredQuestionTranscript(ANSWERED_PROMPT_ITEMS) const question = messages.find((message) => receipts.get(message.id)?.kind === 'question')! @@ -586,127 +604,6 @@ describe('MobileNativeChatMessage', () => { expect(markdown.props.rangeSelectable).toBe(true) }) - it('labels a tool row with the target path instead of raw input JSON', () => { - const tree = render( - toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]), - { toolsExpanded: true } - ) - const texts = textIn(tree.root) - expect(texts).toContain('src/index.ts') - expect(texts.some((text) => text.includes('"file_path":"src/index.ts"'))).toBe(false) - }) - - it('bounds expanded diff-less tool input before native text layout', () => { - const tree = render( - toolMessage([ - { type: 'tool-call', name: 'CustomTool', input: { payload: 'x'.repeat(100_000) } } - ]), - { toolsExpanded: true } - ) - const detail = textIn(tree.root).find((text) => text.startsWith('{\n')) - expect(detail).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1) - expect(detail?.endsWith('…')).toBe(true) - }) - - it('expands formatted detail for a collapsed JSON-string tool input', () => { - const tree = render( - toolMessage([ - { - type: 'tool-call', - name: 'CustomTool', - input: '{"cmd":"git status","description":"Inspect changes"}' - } - ]) - ) - const pressableWith = (label: string): ReactTestInstance => - tree.root.findAllByType('Pressable' as never).find((node) => textIn(node).includes(label))! - - act(() => pressableWith('1×').props.onPress()) - // The row label is the command, and the detail stays closed until tapped. - expect(textIn(tree.root)).toContain('git status') - expect(textIn(tree.root).some((text) => text.startsWith('{\n'))).toBe(false) - - act(() => pressableWith('CustomTool').props.onPress()) - expect(textIn(tree.root)).toContain( - '{\n "cmd": "git status",\n "description": "Inspect changes"\n}' - ) - }) - - it('does not echo the row label as detail when a row has nothing to expand', () => { - // The Tools toggle opens every row at once, bypassing the tap guard — a row - // whose formatted input is its own label would echo itself in a panel that - // no tap can dismiss. - const tree = render(toolMessage([{ type: 'tool-call', name: 'ListTodos', input: '{}' }]), { - toolsExpanded: true - }) - expect(textIn(tree.root).filter((text) => text === '{}')).toHaveLength(1) - // The chevron has to agree with the panel, or the row claims to be open over - // nothing and the tap that would close it is guarded off. Only the run header - // is open here; the row itself stays collapsed. - expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1) - expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1) - }) - - it('does not expand a plain input that already fits in the row label', () => { - const input = 'x'.repeat(60) - const tree = render(toolMessage([{ type: 'tool-call', name: 'CustomTool', input }]), { - toolsExpanded: true - }) - expect(textIn(tree.root).filter((text) => text === input)).toHaveLength(1) - expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1) - expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1) - }) - - describe('output a call left when it ended early', () => { - function projectedCall(ending: Pick): NativeChatMessage { - const body: AgentJournalToolCallItem = { - kind: 'tool-call', - name: 'shell', - input: { command: 'sleep 20' }, - state: 'failed', - ...ending, - output: { head: 'partial', byteLength: 7, digest: 'd', truncated: false } - } - const projected = projectStructuredItemToNativeChat({ - itemId: 'call', - sequence: 1, - revision: 1, - observedAt: 100, - body - }) - if (!projected) { - throw new Error('the call projects no message') - } - return { ...projected, id: 'a1', source: 'transcript' } - } - const outputStyle = (message: NativeChatMessage): unknown => { - const tree = render(message, { toolsExpanded: true }) - const output = tree.root - .findAllByType('Text' as never) - .find((node) => node.children.join('') === 'partial') - // The tint is on the result box: the nearest View around the output text. - let box = output?.parent ?? null - while (box && String(box.type) !== 'View') { - box = box.parent - } - return box?.props.style - } - - it('shows the output a stop cut short without the error tint', () => { - expect(outputStyle(projectedCall({ endedAs: 'interrupted' }))).toEqual([ - expect.any(Object), - false - ]) - }) - - it('keeps the error tint on a call nothing proved was cut short', () => { - expect(outputStyle(projectedCall({ endedAs: 'unverifiable' }))).toEqual([ - expect.any(Object), - expect.objectContaining({ backgroundColor: expect.any(String) }) - ]) - }) - }) - describe('structured activity UI', () => { const runningCall = { type: 'tool-call' as const, @@ -721,66 +618,66 @@ describe('MobileNativeChatMessage', () => { state: 'completed' as const } - it('shows the live tool label with a terminal glyph while a command runs', () => { + it('keeps one live header while calls start and finish, naming the latest call beside it', () => { const tree = render(toolMessage([runningCall]), { structuredActivityUi: true, activeTurnIsWorking: true }) - expect(textIn(tree.root)).toContain('Running npm test') + expect(textIn(tree.root)).toEqual(expect.arrayContaining(['Running 1 command', 'npm test'])) expect(tree.root.findAllByType('SquareTerminal' as never)).toHaveLength(1) - expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0) - }) - - it('uses the wrench glyph for a non-command tool', () => { - const tree = render( - toolMessage([ - { type: 'tool-call', name: 'Read', input: { file_path: 'a/b.ts' }, state: 'running' } - ]), - { structuredActivityUi: true, activeTurnIsWorking: true } + // A finished call, and a Codex diff that carries no state, keep the same header. + act(() => + tree.update( + createElement(MobileNativeChatMessage, { + message: toolMessage([ + { ...runningCall, state: 'completed' }, + { type: 'tool-call', name: 'Diff', input: { path: 'src/a.ts' } } + ]), + structuredActivityUi: true, + activeTurnIsWorking: true + }) + ) ) - expect(textIn(tree.root)).toContain('Running Read a/b.ts') - expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(1) + expect(textIn(tree.root)).toEqual( + expect.arrayContaining(['Running 1 command and editing 1 file', 'Diff src/a.ts']) + ) + expect(textIn(tree.root).some((text) => text.startsWith('Running Diff'))).toBe(false) }) - it('falls back to the collapsed count row once the run settles', () => { - const tree = render(toolMessage([settledCall]), { + it('settles to the past tense once the agent moves past the run', () => { + const tree = render(toolMessage([settledCall, runningCall]), { structuredActivityUi: true, - activeTurnIsWorking: true + activeTurnIsWorking: true, + trailingRun: false }) - expect(textIn(tree.root)).not.toContain('Running Read a/b.ts') - expect(textIn(tree.root)).toContain('1×') + expect(textIn(tree.root)).toContain('Read 1 file and ran 1 command') }) - it("hides a completed turn's activity until the turn caret discloses it", () => { - const collapsed = render(toolMessage([settledCall]), { + it('draws nothing for a row its settled turn folded away, unless the Tools toggle is on', () => { + const folded = render(toolMessage([settledCall]), { + structuredActivityUi: true, + activeTurnIsWorking: false, + foldedAway: true + }) + expect(textIn(folded.root)).toEqual([]) + act(() => folded.unmount()) + + // Otherwise the composer's Tools control is a no-op on every settled turn. + const revealed = render(toolMessage([settledCall]), { + structuredActivityUi: true, + activeTurnIsWorking: false, + foldedAway: true, + toolsExpanded: true + }) + expect(textIn(revealed.root)).toContain('Read 1 file') + }) + + it("keeps a settled turn's answer row whole, its own run included", () => { + const tree = render(toolMessage([{ type: 'text', text: 'Done.' }, settledCall]), { structuredActivityUi: true, activeTurnIsWorking: false }) - expect(textIn(collapsed.root)).not.toContain('1×') - act(() => collapsed.unmount()) - - const disclosed = render(toolMessage([settledCall]), { - structuredActivityUi: true, - activeTurnIsWorking: false, - turnExpanded: true - }) - expect(textIn(disclosed.root)).toContain('1×') - }) - - it('lets the global Tools toggle reveal a hidden settled run', () => { - // Otherwise the composer's Tools control is a no-op on every settled turn. - const tree = render(toolMessage([settledCall]), { - structuredActivityUi: true, - activeTurnIsWorking: false, - toolsExpanded: true - }) - expect(textIn(tree.root)).toContain('1\u00d7') - }) - - it('keeps the bridge lane on its always-visible tool run', () => { - const tree = render(toolMessage([settledCall]), { activeTurnIsWorking: false }) - expect(textIn(tree.root)).toContain('1×') - expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0) + expect(textIn(tree.root)).toContain('Read 1 file') }) it('renders the settled turn status row under a user message', () => { diff --git a/mobile/src/session/MobileNativeChatMessage.tool-result-availability.test.tsx b/mobile/src/session/MobileNativeChatMessage.tool-result-availability.test.tsx index d56514c29e9..0729b2200d6 100644 --- a/mobile/src/session/MobileNativeChatMessage.tool-result-availability.test.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tool-result-availability.test.tsx @@ -9,7 +9,6 @@ import type { import * as toolFold from '../../../src/shared/native-chat-tool-fold' import { projectStructuredItemsToNativeChat } from '../../../src/shared/structured-agent-session-projection' import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' -import { styles } from './mobile-native-chat-message-styles' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' vi.mock('react-native', async () => { @@ -35,19 +34,33 @@ vi.mock('react-native', async () => { Text, View: ({ children, ...props }: { children?: ReactNode }) => React.createElement('View', props, children), - StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + useWindowDimensions: () => ({ width: 400, height: 800 }) } }) vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', + Bot: 'Bot', Brain: 'Brain', + Check: 'Check', ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight', Copy: 'Copy', + Eye: 'Eye', + FileMinus2: 'FileMinus2', + FilePen: 'FilePen', + FilePlus2: 'FilePlus2', + Folder: 'Folder', + Globe: 'Globe', + ListChecks: 'ListChecks', + MessageSquareMore: 'MessageSquareMore', + Pencil: 'Pencil', + Plug: 'Plug', + Search: 'Search', SquareChevronRight: 'SquareChevronRight', SquareTerminal: 'SquareTerminal', - Wrench: 'Wrench', - ChevronRight: 'ChevronRight' + Wrench: 'Wrench' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ @@ -76,14 +89,11 @@ function button(root: ReactTestInstance, label: string): ReactTestInstance { return root.find((node) => String(node.type) === 'Pressable' && textIn(node).includes(label)) } +/** A run's own rows: the presses inside its opened box. */ function toolLines(root: ReactTestInstance): ReactTestInstance[] { - return root.findAll( - (node) => - String(node.type) === 'Pressable' && - node.findAll( - (child) => String(child.type) === 'Text' && child.props.style === styles.toolName - ).length > 0 - ) + return root + .findAll((node) => String(node.type) === 'ScrollView') + .flatMap((box) => box.findAll((node) => String(node.type) === 'Pressable')) } function press(node: ReactTestInstance): void { @@ -101,14 +111,23 @@ describe('mobile tool result availability', () => { vi.restoreAllMocks() }) - function render(row: NativeChatMessage, turnExpanded = true): ReactTestRenderer { + /** The row with its run opened by its header, its lines left closed. */ + function render(row: NativeChatMessage, openRun = true): ReactTestRenderer { act(() => { - renderer = create(createElement(MobileNativeChatMessage, { message: row, turnExpanded })) + renderer = create(createElement(MobileNativeChatMessage, { message: row })) }) + if (openRun) { + press( + renderer!.root.find( + (node) => + String(node.type) === 'Pressable' && node.props.accessibilityLiveRegion === 'polite' + ) + ) + } return renderer! } - it('counts every orphan and lets the reader reveal the seventh output and hide it again', () => { + it('draws each orphan output as its own Result row under one Result header', () => { const raw = message( Array.from({ length: 8 }, (_, index) => ({ type: 'tool-result' as const, @@ -120,27 +139,16 @@ describe('mobile tool result availability', () => { expect(row?.role).toBe('tool') expect(foldMobileNativeChatMessages([row!], 'claude')).toEqual([row]) const tree = render(row!) - expect(textIn(tree.root)).toContain('8×') - expect( - tree.root.find( - (node) => String(node.type) === 'Text' && node.props.style === styles.toolRunLabel - ).props.children - ).toBe('Result') - expect(toolLines(tree.root)).toHaveLength(6) - expect(textIn(tree.root)).not.toContain('preview 6') - - press(button(tree.root, 'Show more')) + expect(textIn(tree.root).filter((text) => text === 'Result')).toHaveLength(9) expect(toolLines(tree.root)).toHaveLength(8) + expect(textIn(tree.root)).not.toContain('preview 6\ncomplete output 6') + press(button(tree.root, 'preview 6')) expect(textIn(tree.root)).toContain('preview 6\ncomplete output 6') expect(textIn(tree.root)).not.toContain('preview 7\ncomplete output 7') - - press(button(tree.root, 'Show less')) - expect(toolLines(tree.root)).toHaveLength(6) - expect(textIn(tree.root)).not.toContain('preview 6\ncomplete output 6') }) - it('reveals a named completion beyond silent calls without attaching it to the prefix', () => { + it('gives a named completion to its own call past silent calls, not to the first', () => { const calls: NativeChatMessage['blocks'] = Array.from({ length: 18 }, (_, index) => ({ type: 'tool-call', callId: `call-${index}`, @@ -152,47 +160,31 @@ describe('mobile tool result availability', () => { { type: 'tool-result', callId: 'call-17', output: 'late named output' } ]) 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') const tree = render(row!) - expect(toolLines(tree.root)).toHaveLength(6) - expect(textIn(tree.root)).not.toContain('late named output') - - press(button(tree.root, 'Show more')) - expect(toolLines(tree.root)).toHaveLength(12) - press(button(tree.root, 'Show more')) expect(toolLines(tree.root)).toHaveLength(18) + expect(textIn(tree.root)).not.toContain('late named output') press(button(tree.root, 'command 17')) expect(textIn(tree.root)).toContain('late named output') - expect(textIn(tree.root)).not.toContain('command 0\nlate named output') + press(button(tree.root, 'command 17')) + // A call with no result has nothing to open. + expect(button(tree.root, 'command 0').props.disabled).toBe(true) }) - it.each(['calls', 'results'] as const)( - 'retains only the requested prefix and one reveal sentinel for 2,049 %s', - (kind) => { - const blocks: NativeChatMessage['blocks'] = Array.from({ length: 2049 }, (_, index) => - kind === 'calls' - ? { type: 'tool-call', callId: `call-${index}`, name: 'Bash', input: `input ${index}` } - : { type: 'tool-result', callId: `missing-${index}`, output: `output ${index}` } - ) - const pairing = vi.spyOn(toolFold, 'pairToolBlocks') - const tree = render(message(blocks), false) - expect(toolLines(tree.root)).toHaveLength(0) - expect(pairing).not.toHaveBeenCalled() - press(button(tree.root, '2049×')) - expect(toolLines(tree.root)).toHaveLength(6) - expect(pairing).toHaveBeenCalledTimes(1) - expect(pairing).toHaveBeenLastCalledWith(blocks, 7) - expect(pairing.mock.results[0]?.value).toHaveLength(7) - expect(textIn(tree.root)).toContain('2049×') - expect(button(tree.root, 'Show more').props.accessibilityRole).toBe('button') - press(button(tree.root, 'Show more')) - expect(pairing).toHaveBeenLastCalledWith(blocks, 13) - expect(pairing.mock.results[1]?.value).toHaveLength(13) - expect(toolLines(tree.root)).toHaveLength(12) - } - ) + it('pairs nothing while its run is collapsed', () => { + const blocks: NativeChatMessage['blocks'] = Array.from({ length: 2049 }, (_, index) => ({ + type: 'tool-call', + callId: `call-${index}`, + name: 'Bash', + input: { command: `input ${index}` } + })) + const pairing = vi.spyOn(toolFold, 'pairToolBlocks') + const tree = render(message(blocks), false) + expect(toolLines(tree.root)).toHaveLength(0) + expect(pairing).not.toHaveBeenCalled() + press(button(tree.root, 'Ran 2049 commands')) + expect(pairing).toHaveBeenCalled() + expect(toolLines(tree.root)).toHaveLength(2049) + }) it.each([true, false])( 'draws derived outputs ahead of a prompt queued while stopping (host scopes: %s)', @@ -322,9 +314,7 @@ describe('mobile tool result availability', () => { { type: 'tool-result', callId: 'duplicate', output: 'second output' }, { type: 'tool-call', callId: 'later', name: 'Bash', input: 'later input' } ]) - act(() => - tree.update(createElement(MobileNativeChatMessage, { message: updated, turnExpanded: true })) - ) + act(() => tree.update(createElement(MobileNativeChatMessage, { message: updated }))) expect(button(tree.root, 'changed first input')).toBe(first) expect(button(tree.root, 'changed second input')).toBe(second) expect(textIn(tree.root)).toContain('first output') diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index c355acb93e2..02f74d11afc 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useContext, useLayoutEffect, useRef } from 'react' +import { memo, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react' import { Text, View } from 'react-native' import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection' import { MobileNativeChatLongPressContent as Content } from './MobileNativeChatLongPressContent' @@ -13,11 +13,17 @@ import { isRenderableSubagentGroup, withoutSubagentGroupTwins } from '../../../src/shared/native-chat-subagent-summary' -import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatToolCallBlock +} from '../../../src/shared/native-chat-types' 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 { nativeChatWorkRunEntries } from '../../../src/shared/native-chat-work-run' +import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' import { MobileNativeChatNoticeRow } from './MobileNativeChatNoticeRow' import { MobileNativeChatRowOverlaysContext } from './mobile-native-chat-row-overlays' @@ -43,6 +49,8 @@ function MobileNativeChatMessageImpl({ message, mayStillGrow = false, toolsExpanded = false, + previousTodoWrite, + previousUpdatePlan, fontScale = 1, onOpenFile, turnStatus, @@ -51,6 +59,11 @@ function MobileNativeChatMessageImpl({ turnKey, onToggleTurn, activeTurnIsWorking, + trailingRun, + workRun, + joinsWorkRun = false, + foldedAway = false, + openReasoning, structuredActivityUi = false, reasoningIsLive = false, reasoningExpanded, @@ -67,6 +80,9 @@ function MobileNativeChatMessageImpl({ /** The newest assistant row of a live turn with no prompt open: its last text may still grow. */ mayStillGrow?: boolean toolsExpanded?: boolean + /** The last plan each task-list tool wrote before this row, so an update reads as its changes. */ + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number onOpenFile?: (relativePath: string) => void @@ -82,6 +98,17 @@ function MobileNativeChatMessageImpl({ onToggleTurn?: (turnKey: string) => void /** Session-level working state for this message's turn; gates the live tool row. */ activeTurnIsWorking?: boolean + /** Whether this row is the live turn's frontier, the last where the agent spoke or acted. */ + trailingRun?: boolean + /** Set on the first row of a work run: every member, which this row draws as one run. */ + workRun?: readonly NativeChatMessage[] + /** A later member of a work run, which its first row draws. */ + joinsWorkRun?: boolean + /** Folded behind its settled turn's "Worked for" row. The Tools toggle still shows it, or that + * toggle would do nothing on every settled turn. */ + foldedAway?: boolean + /** The reasoning disclosures the reader opened, for thoughts drawn inside the work run. */ + openReasoning?: ReadonlySet /** Structured lane only: live tool progress plus the turn-status disclosure. */ structuredActivityUi?: boolean /** This open reasoning block is disclosed by the live activity line, so its row draws nothing. */ @@ -107,30 +134,47 @@ function MobileNativeChatMessageImpl({ // tool calls fold into a collapsible run beneath. The user's own messages get // an inverted (filled accent) bubble so they stand apart from agent prose. const { prose, tools: blocksOfTools } = splitNativeChatBlocks(message.blocks) - const { asks, unansweredAsks, work: tools } = nativeChatAskRunBlocks(blocksOfTools) - // A roster draws as its group row, which replaces its frozen sentence. - const drawnProse = withoutSubagentGroupTwins(prose) + const { asks, unansweredAsks, work: ownTools } = nativeChatAskRunBlocks(blocksOfTools) + const { openActions: openSheet } = useContext(MobileNativeChatRowOverlaysContext) + // A work run draws every member's calls as this row's one run, its thoughts among them. + const run = useMemo(() => (workRun ? nativeChatWorkRunEntries(workRun) : null), [workRun]) + const tools = run?.blocks ?? ownTools + const asides = useMemo(() => { + if (!run) { + return undefined + } + const thought = (thoughtMessage: NativeChatMessage): React.JSX.Element => ( + // Keyed by the thought, so it stays mounted as calls arrive after it. + openSheet(thoughtMessage)} + /> + ) + const before = new Map() + for (const [block, thoughts] of run.thoughtsBefore) { + before.set(block, thoughts.map(thought)) + } + return { before, after: run.thoughtsAfter.map(thought) } + }, [run, fontScale, activeTurnIsWorking, openReasoning, onToggleReasoning, onOpenFile, openSheet]) + // A roster draws as its group row, which replaces its frozen sentence. A thought heading a + // run draws inside the run, so its words are not this row's prose. + const drawnProse = isReasoning ? [] : withoutSubagentGroupTwins(prose) // The agent did not write a system row: its words draw as a notice or a faint aside. const isSystem = message.role === 'system' - const activeCall = structuredActivityUi - ? selectActiveToolCall(tools, { activeTurnIsWorking }) - : null - // A completed turn's activity belongs behind the turn-status caret. Leaving the - // grouped row visible made a failed child command read as a failed response. - // The composer's global Tools toggle still overrides this, or it would silently - // do nothing on every settled turn. - const settledToolsHidden = - structuredActivityUi && - activeCall == null && - activeTurnIsWorking === false && - !turnExpanded && - !toolsExpanded - const showToolRun = tools.length > 0 && !settledToolsHidden + const showToolRun = tools.length > 0 const askPending = structuredActivityUi ? selectActiveToolCall(unansweredAsks, { activeTurnIsWorking }) !== null : unansweredAsks.length > 0 const askRow = - asks.length > 0 && !settledToolsHidden ? ( + asks.length > 0 ? ( ) : null // A ref keeps the memoized Markdown context stable as the message streams. - const { openActions: openSheet } = useContext(MobileNativeChatRowOverlaysContext) const pressedMessage = useRef(message) useLayoutEffect(() => { pressedMessage.current = message @@ -162,6 +205,10 @@ function MobileNativeChatMessageImpl({ onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined} /> ) : null + // A receipt outlives the fold, as on desktop: it is the record of what the reader answered. + if (joinsWorkRun || (foldedAway && !toolsExpanded && !receipt)) { + return <>{statusRow} + } if (receipt) { return ( <> @@ -177,7 +224,8 @@ function MobileNativeChatMessageImpl({ ) } - if (isReasoning) { + // A run that opens on a thought draws as the run, the thought inside it. + if (isReasoning && !workRun) { // The same text the live line's selector reads, so the two agree on whether there is any. const markdown = deriveNativeChatRowContent(message.blocks).markdown // Blank, or disclosed by the live activity line: nothing draws, not even an empty row. @@ -282,13 +330,19 @@ function MobileNativeChatMessageImpl({ {askRow} {showToolRun ? ( ) : null} diff --git a/mobile/src/session/MobileNativeChatMessage.work-run.test.tsx b/mobile/src/session/MobileNativeChatMessage.work-run.test.tsx new file mode 100644 index 00000000000..2e0760fdc66 --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.work-run.test.tsx @@ -0,0 +1,174 @@ +// A work run on the phone, as desktop draws one: the run's first row draws every member's calls +// under one header with the thoughts among them, and the rows it absorbed draw nothing. + +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +vi.mock('react-native', async () => { + const React = await import('react') + const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode => + React.createElement('Text', props, children) + return { + Animated: { + Text, + Value: class { + setValue(): void {} + } + }, + Image: 'Image', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('ScrollView', props, children), + Text, + View: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('View', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + useWindowDimensions: () => ({ width: 400, height: 800 }) + } +}) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) +vi.mock('lucide-react-native', () => ({ + Bot: 'Bot', + Brain: 'Brain', + Check: 'Check', + ChevronRight: 'ChevronRight', + Circle: 'Circle', + CircleCheck: 'CircleCheck', + CircleDot: 'CircleDot', + Eye: 'Eye', + FileMinus2: 'FileMinus2', + FilePen: 'FilePen', + FilePlus2: 'FilePlus2', + Folder: 'Folder', + Globe: 'Globe', + ListChecks: 'ListChecks', + MessageSquareMore: 'MessageSquareMore', + Pencil: 'Pencil', + Plug: 'Plug', + Search: 'Search', + SquareTerminal: 'SquareTerminal', + Wrench: 'Wrench' +})) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +// Android: a long press opens the row's actions sheet instead of selecting text. +vi.mock('../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false })) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) + +import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { MobileNativeChatRowOverlaysContext } from './mobile-native-chat-row-overlays' + +const thought: NativeChatMessage = { + id: 'r1', + role: 'reasoning', + state: 'completed', + completedAt: 2_000, + blocks: [{ type: 'text', text: 'Which file holds the fold?' }], + timestamp: 0, + source: 'transcript' +} +const read: NativeChatMessage = { + id: 'a1', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Read', input: { file_path: 'src/a.ts' }, state: 'completed' } + ], + timestamp: 0, + source: 'transcript' +} +const command: NativeChatMessage = { + id: 'a2', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'pnpm test' }, state: 'completed' } + ], + timestamp: 0, + source: 'transcript' +} + +let renderer: ReactTestRenderer | null = null +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +const textIn = (node: ReactTestInstance): string[] => + node.findAll((child) => String(child.type) === 'Text').map((text) => text.children.join('')) + +it('draws a run that opens on a thought as one header, the thought inside it', () => { + act(() => { + renderer = create( + createElement(MobileNativeChatMessage, { + message: thought, + workRun: [thought, read, command], + structuredActivityUi: true, + activeTurnIsWorking: false + }) + ) + }) + const tree = renderer! + expect(textIn(tree.root)).toContain('Read 1 file and ran 1 command') + // Folded, the run shows its sentence only. + expect(tree.root.findAll((node) => String(node.type) === 'Brain')).toHaveLength(0) + act(() => + tree.root + .find((node) => String(node.type) === 'Pressable' && node.props.accessibilityLiveRegion) + .props.onPress() + ) + expect(tree.root.findAll((node) => String(node.type) === 'Brain')).toHaveLength(1) + expect(textIn(tree.root)).toEqual(expect.arrayContaining(['Read', 'a.ts', 'pnpm test'])) + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) +}) + +it('draws nothing for a row a run absorbed', () => { + act(() => { + renderer = create( + createElement(MobileNativeChatMessage, { + message: command, + joinsWorkRun: true, + structuredActivityUi: true, + activeTurnIsWorking: false + }) + ) + }) + expect(renderer!.toJSON()).toBeNull() +}) + +it("opens a grouped thought's own actions on a long press, not the run's first row's", () => { + const openActions = vi.fn() + act(() => { + renderer = create( + createElement( + MobileNativeChatRowOverlaysContext.Provider, + { value: { openImage: vi.fn(), openActions } }, + createElement(MobileNativeChatMessage, { + message: read, + workRun: [read, thought, command], + openReasoning: new Set([nativeChatReasoningDisclosureKey(thought.id)]), + onToggleReasoning: vi.fn(), + structuredActivityUi: true, + activeTurnIsWorking: false + }) + ) + ) + }) + const tree = renderer! + act(() => { + tree.root + .find((node) => String(node.type) === 'Pressable' && node.props.accessibilityLiveRegion) + .props.onPress() + }) + act(() => { + // The innermost: the thought's own text, inside the row that holds the run. + tree.root + .findAll((node) => String(node.type) === 'Pressable' && node.props.onLongPress) + .at(-1)! + .props.onLongPress() + }) + expect(openActions).toHaveBeenCalledWith(thought) +}) diff --git a/mobile/src/session/MobileNativeChatTaskList.tsx b/mobile/src/session/MobileNativeChatTaskList.tsx new file mode 100644 index 00000000000..627f0ea6a24 --- /dev/null +++ b/mobile/src/session/MobileNativeChatTaskList.tsx @@ -0,0 +1,176 @@ +import { useState } from 'react' +import { Pressable, Text, View } from 'react-native' +import { ChevronRight, Circle, CircleCheck, CircleDot, ListChecks } from 'lucide-react-native' +import { + diffNativeChatTaskLists, + NATIVE_CHAT_TASK_LIST_COPY, + nativeChatTaskChangeWords, + nativeChatTaskLabel, + type NativeChatTask, + type NativeChatTaskList as TaskList +} from '../../../src/shared/native-chat-task-list' +import { colors } from '../theme/mobile-theme' +import { useMobileNativeChatOpenedDetail } from './mobile-native-chat-opened-detail' +import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles' + +const STATUS_WORD: Record = { + completed: NATIVE_CHAT_TASK_LIST_COPY.completed, + in_progress: NATIVE_CHAT_TASK_LIST_COPY.inProgress, + pending: NATIVE_CHAT_TASK_LIST_COPY.pending +} + +/** Content plus occurrence is the only identity the providers give these entries. */ +function occurrenceKeyed(items: readonly T[], content: (item: T) => string): [T, string][] { + const seen = new Map() + return items.map((item) => { + const signature = content(item) + const occurrence = seen.get(signature) ?? 0 + seen.set(signature, occurrence + 1) + return [item, `${signature}:${occurrence}`] + }) +} + +function TaskRow({ + task, + label, + fontScale +}: { + task: NativeChatTask + label?: string + fontScale: number +}): React.JSX.Element { + const Icon = + task.status === 'completed' ? CircleCheck : task.status === 'in_progress' ? CircleDot : Circle + const active = task.status === 'in_progress' + return ( + + + + + + {label ?? nativeChatTaskLabel(task)} + + + ) +} + +function Checklist({ list, fontScale }: { list: TaskList; fontScale: number }) { + return list.tasks.length === 0 ? ( + + {NATIVE_CHAT_TASK_LIST_COPY.empty} + + ) : ( + + {occurrenceKeyed(list.tasks, (task) => task.content).map(([task, key]) => ( + + ))} + + ) +} + +/** The whole list behind a disclosure, under a list of what changed. */ +function FullList({ list, fontScale }: { list: TaskList; fontScale: number }) { + const [open, setOpen] = useState(false) + return ( + + setOpen((value) => !value)} + accessibilityRole="button" + accessibilityState={{ expanded: open }} + > + + + + + {NATIVE_CHAT_TASK_LIST_COPY.showAll} + + + {open ? : null} + + ) +} + +function OpenedChecklist({ list, fontScale }: { list: TaskList; fontScale: number }) { + const onLayout = useMobileNativeChatOpenedDetail() + return ( + + + + ) +} + +/** Desktop parity: `NativeChatTaskList`, inline. A plan update reads as what it + * changed against the previous list, or as the whole checklist when it is the first. */ +export function MobileNativeChatTaskList({ + list, + previous, + fontScale +}: { + list: TaskList + previous?: TaskList + fontScale: number +}): React.JSX.Element { + const completed = list.tasks.filter((task) => task.status === 'completed').length + const changes = previous ? diffNativeChatTaskLists(previous, list) : null + const type = toolType(fontScale) + return ( + + + + + {NATIVE_CHAT_TASK_LIST_COPY.title} + + {`${completed}/${list.tasks.length}`} + + {changes ? ( + <> + {changes.length > 0 ? ( + + {occurrenceKeyed(changes, (change) => `${change.kind}:${change.task.content}`).map( + ([change, rowKey]) => { + const { key, task } = nativeChatTaskChangeWords(change) + return ( + + ) + } + )} + + ) : ( + {NATIVE_CHAT_TASK_LIST_COPY.unchanged} + )} + + + ) : ( + + )} + {list.explanation ? ( + {list.explanation} + ) : null} + + ) +} diff --git a/mobile/src/session/MobileNativeChatToolIcon.tsx b/mobile/src/session/MobileNativeChatToolIcon.tsx new file mode 100644 index 00000000000..adab4eae1e2 --- /dev/null +++ b/mobile/src/session/MobileNativeChatToolIcon.tsx @@ -0,0 +1,46 @@ +import { + Bot, + Brain, + Eye, + Folder, + Globe, + ListChecks, + MessageSquareMore, + Pencil, + Plug, + Search, + SquareTerminal, + Wrench, + type LucideIcon +} from 'lucide-react-native' +import type { NativeChatToolIconName } from '../../../src/shared/native-chat-tool-icon' + +/** Desktop parity: `NativeChatToolIcon`'s glyph table. */ +const GLYPHS: Record = { + eye: Eye, + search: Search, + folder: Folder, + 'square-terminal': SquareTerminal, + pencil: Pencil, + globe: Globe, + plug: Plug, + bot: Bot, + 'list-checks': ListChecks, + wrench: Wrench, + 'message-square-more': MessageSquareMore, + brain: Brain +} + +/** A tool category's glyph. Decorative: the word beside it names the row. */ +export function MobileNativeChatToolGlyph({ + name, + size, + color +}: { + name: NativeChatToolIconName + size: number + color: string +}): React.JSX.Element { + const Glyph = GLYPHS[name] + return +} diff --git a/mobile/src/session/MobileNativeChatToolLine.tsx b/mobile/src/session/MobileNativeChatToolLine.tsx new file mode 100644 index 00000000000..05ce18f8a4b --- /dev/null +++ b/mobile/src/session/MobileNativeChatToolLine.tsx @@ -0,0 +1,261 @@ +import { useMemo } from 'react' +import { Pressable, ScrollView, Text, View } from 'react-native' +import { ChevronRight } from 'lucide-react-native' +import { agentJournalToolCallLifecycle } from '../../../src/shared/agent-journal-tool-call-lifecycle' +import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff' +import type { NativeChatDiffLine } from '../../../src/shared/native-chat-diff' +import { + formatToolDuration, + mcpToolIdentity, + toolWebSearchResults, + type NativeChatWebSearchResult +} from '../../../src/shared/native-chat-tool-identity' +import { nativeChatToolIconName } from '../../../src/shared/native-chat-tool-icon' +import { + NATIVE_CHAT_TOOL_ROW_VERB_COPY, + nativeChatToolLineLabel +} from '../../../src/shared/native-chat-tool-line-label' +import { truncateToolDetail } from '../../../src/shared/native-chat-tool-summary' +import { + isToolCallBlock, + type NativeChatToolCallBlock, + type NativeChatToolResultBlock +} from '../../../src/shared/native-chat-types' +import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments' +import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection' +import { openExternalLink } from '../platform/external-link' +import { colors } from '../theme/mobile-theme' +import { highlightMobileCode } from './mobile-file-syntax' +import { MobileNativeChatToolGlyph } from './MobileNativeChatToolIcon' +import { useMobileNativeChatOpenedDetail } from './mobile-native-chat-opened-detail' +import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store' +import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles' + +const RESULT_LABEL = 'Result' +const NO_SEARCH_RESULTS: NativeChatWebSearchResult[] = [] + +/** Desktop parity: `NativeChatSearchResults`. The search's hits as links. */ +function SearchResults({ + results, + fontScale +}: { + results: NativeChatWebSearchResult[] + fontScale: number +}): React.JSX.Element { + const type = toolType(fontScale).meta + return ( + + {results.map((hit) => ( + openExternalLink(hit.url)} + accessibilityRole="link" + > + {hit.title} + {hit.title !== hit.url ? {` ${hit.url}`} : null} + + ))} + + ) +} + +function DiffView({ lines, fontScale }: { lines: NativeChatDiffLine[]; fontScale: number }) { + const type = toolType(fontScale).code + return ( + + {lines.map((line, index) => ( + + {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} + {line.text} + + ))} + + ) +} + +/** Desktop parity: `NativeChatToolLine`'s `ToolLineDetail`. Its own component so a + * row diffs and highlights only while it is open. */ +function ToolLineDetail({ + call, + body, + fullCommand, + commandLanguage, + results, + fontScale +}: { + call: NativeChatToolCallBlock | undefined + body: NativeChatToolResultBlock | undefined + fullCommand: string | null + commandLanguage: string | null + results: NativeChatWebSearchResult[] + fontScale: number +}): React.JSX.Element { + const diff = call + ? diffFromToolCall(call.name, call.input) + : body + ? diffFromText(body.output) + : null + const command = useMemo( + () => + !diff && fullCommand && commandLanguage + ? highlightMobileCode(fullCommand, commandLanguage).segments + : null, + [diff, fullCommand, commandLanguage] + ) + const type = toolType(fontScale).code + const onLayout = useMobileNativeChatOpenedDetail() + return ( + + {results.length > 0 ? : null} + {diff ? : null} + {!diff && fullCommand ? ( + + + {command ? : fullCommand} + + + ) : null} + {body ? ( + + + {truncateToolDetail(body.output)} + + + ) : null} + + ) +} + +/** Desktop parity: `NativeChatToolLine`. A tool sentence with its output behind its + * own disclosure; an orphan result draws as a `Result` row. */ +export function MobileNativeChatToolLine({ + block, + result, + defaultExpanded, + disclosureKey, + fontScale, + onOpenFile +}: { + block: NativeChatToolCallBlock | NativeChatToolResultBlock + /** This call's output, when the run paired one to it. */ + result?: NativeChatToolResultBlock + defaultExpanded: boolean + /** Identity this line's open state is remembered under while it is unmounted. */ + disclosureKey?: string + fontScale: number + onOpenFile?: (path: string) => void +}): React.JSX.Element { + const [expanded, setExpanded] = useMobileNativeChatDisclosure(disclosureKey, defaultExpanded) + const call = isToolCallBlock(block) ? block : undefined + const label = call ? nativeChatToolLineLabel(call, result) : null + const body = call ? result : block.type === 'tool-result' ? block : undefined + const fullCommand = label?.commandDetail ?? null + // A call the reader stopped was cut short, not failed. + const lifecycle = call ? agentJournalToolCallLifecycle(call) : null + const failed = lifecycle === 'failed' || (lifecycle !== 'interrupted' && result?.isError === true) + const verb = label?.verb ? NATIVE_CHAT_TOOL_ROW_VERB_COPY[label.verb] : null + // The run's header already says these ran; the row is the command itself. + const commandAlone = label?.command === true && verb !== null && label.target.length > 0 + const mcp = call ? mcpToolIdentity(call.name, call.mcpIdentity) : null + const name = call ? (mcp ? `${mcp.server} / ${mcp.tool}` : call.name) : RESULT_LABEL + const target = label ? label.target : (body?.output.split('\n')[0]?.slice(0, 80) ?? '') + const results = useMemo( + () => (call ? toolWebSearchResults(call.webSearchResults) : NO_SEARCH_RESULTS), + [call] + ) + // An edit that has not landed has no output yet, but the change it proposes is worth opening. + const hasDetail = + body !== undefined || + fullCommand !== null || + results.length > 0 || + (call !== undefined && diffFromToolCall(call.name, call.input) !== null) + const filePath = label?.filePath ?? null + const type = toolType(fontScale) + const iconSize = 15 * fontScale + const exitCode = call && Number.isSafeInteger(call.exitCode) ? call.exitCode : undefined + const duration = call ? formatToolDuration(call.durationMs) : null + + return ( + + hasDetail && setExpanded(!expanded)} + disabled={!hasDetail} + accessibilityRole="button" + accessibilityState={hasDetail ? { expanded } : undefined} + > + + {call ? ( + + ) : null} + + {commandAlone ? null : ( + + {verb ?? name} + + )} + {target ? ( + onOpenFile(filePath) : undefined} + suppressHighlighting={!(filePath && onOpenFile)} + accessibilityRole={filePath && onOpenFile ? 'link' : undefined} + > + {target} + + ) : null} + {/* Held at the row's right edge, so the carets of a run line up in one column. */} + + {exitCode !== undefined ? ( + + {`exit ${exitCode}`} + + ) : null} + {duration ? {duration} : null} + {hasDetail ? ( + + + + ) : null} + + + {hasDetail && expanded ? ( + + ) : null} + + ) +} diff --git a/mobile/src/session/MobileNativeChatToolRun.identity.test.tsx b/mobile/src/session/MobileNativeChatToolRun.identity.test.tsx index c5b83e8395b..ddd96019664 100644 --- a/mobile/src/session/MobileNativeChatToolRun.identity.test.tsx +++ b/mobile/src/session/MobileNativeChatToolRun.identity.test.tsx @@ -2,16 +2,47 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, expect, it, vi } from 'vitest' import type { NativeChatBlock } from '../../../src/shared/native-chat-types' -import { ToolRun } from './MobileNativeChatToolRun' -vi.mock('react-native', () => ({ Text: 'span', View: 'div', Pressable: 'button' })) +vi.mock('react-native', () => ({ + Animated: { + Text: 'span', + Value: class { + setValue(): void {} + } + }, + Platform: { OS: 'ios' }, + Pressable: 'button', + ScrollView: 'div', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'span', + View: 'div', + useWindowDimensions: () => ({ width: 400, height: 800 }) +})) vi.mock('lucide-react-native', () => ({ + Bot: () => null, + Brain: () => null, + Check: () => null, ChevronDown: () => null, + ChevronRight: () => null, + Eye: () => null, + FileMinus2: () => null, + FilePen: () => null, + FilePlus2: () => null, + Folder: () => null, + Globe: () => null, + ListChecks: () => null, + MessageSquareMore: () => null, + Pencil: () => null, + Plug: () => null, + Search: () => null, SquareChevronRight: () => null, SquareTerminal: () => null, Wrench: () => null })) -vi.mock('./mobile-native-chat-message-styles', () => ({ styles: {} })) + +vi.mock('../components/MobileSourceCopyButton', () => ({ MobileSourceCopyButton: 'CopyButton' })) + +import { ToolRun } from './MobileNativeChatToolRun' let tree: ReactTestRenderer | undefined afterEach(async () => { @@ -31,8 +62,7 @@ async function update(next: NativeChatBlock[]): Promise { const element = createElement(ToolRun, { blocks: next, defaultExpanded: true, - expandChildren: false, - activeCall: null + expandChildren: false }) if (tree) { tree.update(element) diff --git a/mobile/src/session/MobileNativeChatToolRun.test.tsx b/mobile/src/session/MobileNativeChatToolRun.test.tsx new file mode 100644 index 00000000000..029ad2f2c01 --- /dev/null +++ b/mobile/src/session/MobileNativeChatToolRun.test.tsx @@ -0,0 +1,600 @@ +import { createElement, type ReactNode } 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 { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary' +import { projectStructuredItemToNativeChat } from '../../../src/shared/structured-agent-session-projection' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatToolCallBlock +} from '../../../src/shared/native-chat-types' +import { colors } from '../theme/mobile-theme' + +const scrolls = vi.hoisted(() => ({ toEnd: vi.fn(), to: vi.fn() })) + +vi.mock('react-native', async () => { + const React = await import('react') + // A class, so the run's box can call it the way it calls a real ScrollView. + class ScrollView extends React.Component<{ children?: ReactNode }> { + scrollToEnd = scrolls.toEnd + scrollTo = scrolls.to + render(): ReactNode { + return React.createElement('ScrollView', this.props, this.props.children) + } + } + const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode => + React.createElement('Text', props, children) + return { + Animated: { + Text, + Value: class { + setValue(): void {} + }, + loop: (animation: unknown) => animation, + sequence: () => ({ start: vi.fn(), stop: vi.fn() }), + timing: () => ({ start: vi.fn(), stop: vi.fn() }) + }, + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView, + Text, + View: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('View', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + useWindowDimensions: () => ({ width: 400, height: 800 }) + } +}) +vi.mock('lucide-react-native', () => ({ + Bot: 'Bot', + Brain: 'Brain', + Check: 'Check', + ChevronRight: 'ChevronRight', + Circle: 'Circle', + CircleCheck: 'CircleCheck', + CircleDot: 'CircleDot', + Eye: 'Eye', + FileMinus2: 'FileMinus2', + FilePen: 'FilePen', + FilePlus2: 'FilePlus2', + Folder: 'Folder', + Globe: 'Globe', + ListChecks: 'ListChecks', + MessageSquareMore: 'MessageSquareMore', + Pencil: 'Pencil', + Plug: 'Plug', + Search: 'Search', + SquareTerminal: 'SquareTerminal', + Wrench: 'Wrench' +})) + +vi.mock('../components/MobileSourceCopyButton', () => ({ MobileSourceCopyButton: 'CopyButton' })) +vi.mock('../platform/external-link', () => ({ openExternalLink: vi.fn() })) +// Android's policy: transcript text is not inline-selectable, so a scroll is never read as a selection. +vi.mock('../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false })) + +import { openExternalLink } from '../platform/external-link' +import { ToolRun } from './MobileNativeChatToolRun' +import { + MobileNativeChatDisclosureContext, + useMobileNativeChatDisclosures +} from './mobile-native-chat-disclosure-store' + +describe('ToolRun', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + /** An opened run with its rows closed, as the turn caret leaves it. */ + function render( + blocks: NativeChatBlock[] | NativeChatMessage, + onOpenFile?: (path: string) => void + ): ReactTestRenderer { + act(() => { + renderer = create( + createElement(ToolRun, { + blocks: Array.isArray(blocks) ? blocks : blocks.blocks, + defaultExpanded: true, + expandChildren: false, + onOpenFile + }) + ) + }) + return renderer! + } + + const textIn = (node: ReactTestInstance): string[] => + node + .findAll((node) => String(node.type) === 'Text') + .map((text) => String(text.children.join(''))) + + describe('tool rows, as desktop draws them', () => { + // The last match: a lone command's run header reads as the command too. + const pressable = (tree: ReactTestRenderer, label: string): ReactTestInstance => + tree.root + .findAll((node) => String(node.type) === 'Pressable') + .findLast((node) => textIn(node).some((text) => text.startsWith(label)))! + const open = (tree: ReactTestRenderer, label: string): void => { + act(() => { + pressable(tree, label).props.onPress() + }) + } + const textNode = (tree: ReactTestRenderer, text: string): ReactTestInstance => + tree.root.findAll((node) => String(node.type) === 'Text' && textIn(node)[0] === text)[0]! + const flatStyle = (node: ReactTestInstance): Record => + Object.assign({}, ...[node.props.style].flat(Infinity).filter(Boolean)) + + it('names a read by its verb and file, not its input', () => { + const tree = render([ + { type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }, + { type: 'tool-result', output: 'contents' } + ]) + expect(textIn(tree.root)).toEqual(expect.arrayContaining(['Read 1 file', 'Read', 'index.ts'])) + expect(textIn(tree.root).some((text) => text.includes('file_path'))).toBe(false) + }) + + it('opens a call to its output, plus the whole command only when the row shortened it', () => { + const command = `pnpm vitest run ${'src/session/'.repeat(10)}` + const tree = render([ + { type: 'tool-call', name: 'Bash', input: { command } }, + { type: 'tool-result', output: 'ok' }, + { type: 'tool-call', name: 'Bash', input: { command: 'ls', description: 'List' } }, + { type: 'tool-result', output: 'a.ts' } + ]) + open(tree, 'ls') + expect(textIn(tree.root)).toContain('a.ts') + expect(textIn(tree.root).some((text) => text.includes('"description"'))).toBe(false) + open(tree, 'pnpm vitest run') + expect(textIn(tree.root)).toEqual(expect.arrayContaining([command, 'ok'])) + }) + + it('bounds an opened output before native text layout', () => { + const tree = render([ + { type: 'tool-call', name: 'Bash', input: { command: 'cat big.log' } }, + { type: 'tool-result', output: 'x'.repeat(100_000) } + ]) + open(tree, 'cat big.log') + const output = textIn(tree.root).find((text) => text.startsWith('xxx')) + expect(output).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1) + expect(output?.endsWith('…')).toBe(true) + }) + + it('draws command output that merely starts lines with - or + as plain text', () => { + const listing = '-rw-r--r-- 1 me staff 12 a.ts\n-rw-r--r-- 1 me staff 14 b.ts' + const tree = render([ + { type: 'tool-call', name: 'Bash', input: { command: 'ls -la' } }, + { type: 'tool-result', output: listing } + ]) + open(tree, 'ls -la') + expect(textIn(tree.root)).toContain(listing) + }) + + it('keeps a failed command red even when its output reads as a diff', () => { + const output = 'error: patch failed\n-const a = 1\n+const a = 2' + const tree = render([ + { type: 'tool-call', name: 'Bash', input: { command: 'git apply x.patch' } }, + { type: 'tool-result', output, isError: true } + ]) + expect(textIn(tree.root)).toContain(' · 1 failed') + open(tree, 'git apply x.patch') + expect(flatStyle(textNode(tree, output)).color).toBe(colors.statusRed) + }) + + it('links only a read or edited file, and only by its name', () => { + const onOpenFile = vi.fn() + const tree = render( + [ + { type: 'tool-call', name: 'Read', input: { file_path: 'src/a.ts' } }, + { type: 'tool-result', output: 'a' }, + { type: 'tool-call', name: 'list', input: { command: 'ls src', path: 'src' } }, + { type: 'tool-result', output: 'a.ts' } + ], + onOpenFile + ) + act(() => textNode(tree, 'a.ts').props.onPress()) + expect(onOpenFile).toHaveBeenCalledWith('src/a.ts') + expect(textIn(tree.root)).toEqual(expect.arrayContaining(['Listed', 'src'])) + expect(textNode(tree, 'src').props.onPress).toBeUndefined() + // The rest of the row opens the row, not the file. + open(tree, 'Read') + expect(textIn(tree.root)).toContain('a') + expect(onOpenFile).toHaveBeenCalledTimes(1) + }) + + it('offers no caret on a row with nothing to show', () => { + const tree = render([{ type: 'tool-call', name: 'Agent', input: { description: 'Explore' } }]) + expect(textIn(tree.root)).toEqual(expect.arrayContaining(['Subagent', 'Explore'])) + expect(tree.root.findAll((node) => String(node.type) === 'ChevronRight')).toHaveLength(0) + expect(pressable(tree, 'Subagent').props.disabled).toBe(true) + }) + }) + + describe('output a call left when it ended early', () => { + function projectedCall(ending: Pick): NativeChatMessage { + const body: AgentJournalToolCallItem = { + kind: 'tool-call', + name: 'shell', + input: { command: 'sleep 20' }, + state: 'failed', + ...ending, + output: { head: 'partial', byteLength: 7, digest: 'd', truncated: false } + } + const projected = projectStructuredItemToNativeChat({ + itemId: 'call', + sequence: 1, + revision: 1, + observedAt: 100, + body + }) + if (!projected) { + throw new Error('the call projects no message') + } + return { ...projected, id: 'a1', source: 'transcript' } + } + const outputColor = (message: NativeChatMessage): unknown => { + const tree = render(message) + act(() => + tree.root + .findAll((node) => String(node.type) === 'Pressable') + .findLast((node) => textIn(node).includes('sleep 20'))! + .props.onPress() + ) + const output = tree.root + .findAll((node) => String(node.type) === 'Text') + .find((node) => node.children.join('') === 'partial')! + return Object.assign({}, ...[output.props.style].flat(Infinity).filter(Boolean)).color + } + + it('shows the output a stop cut short without the error colour', () => { + expect(outputColor(projectedCall({ endedAs: 'interrupted' }))).not.toBe(colors.statusRed) + }) + + it('keeps the error colour on a call nothing proved was cut short', () => { + expect(outputColor(projectedCall({ endedAs: 'unverifiable' }))).toBe(colors.statusRed) + }) + }) + + describe('edits and searches, as desktop draws them', () => { + const openRow = (tree: ReactTestRenderer, label: string): void => { + act(() => { + tree.root + .findAll((node) => String(node.type) === 'Pressable') + .findLast((node) => textIn(node).includes(label))! + .props.onPress() + }) + } + + it('opens a rewrite to every removed and added line, interleaved, with nothing cut', () => { + const before = Array.from({ length: 15 }, (_, i) => `old ${i}`).join('\n') + const after = Array.from({ length: 15 }, (_, i) => `new ${i}`).join('\n') + const tree = render([ + { + type: 'tool-call', + name: 'Edit', + input: { file_path: 'src/a.ts', old_string: before, new_string: after } + }, + { type: 'tool-result', output: 'The file src/a.ts has been updated.' } + ]) + expect(textIn(tree.root)).toEqual(expect.arrayContaining(['Edited', 'a.ts', '+15', '-15'])) + openRow(tree, 'a.ts') + const rows = textIn(tree.root).filter((text) => /^(old|new) \d+$/.test(text)) + expect(rows).toHaveLength(30) + expect(textIn(tree.root).some((text) => text.includes('truncated'))).toBe(false) + // The result the card speaks for draws no row of its own. + expect(textIn(tree.root)).not.toContain('The file src/a.ts has been updated.') + }) + + it.each([ + [ + 'a MultiEdit', + { + type: 'tool-call', + name: 'MultiEdit', + input: { + file_path: 'src/b.ts', + edits: [{ old_string: 'let x = 1', new_string: 'let x = 2' }] + } + } + ], + [ + 'a Codex patch', + { + type: 'tool-call', + name: 'apply_patch', + input: + '*** Begin Patch\n*** Update File: src/b.ts\n@@\n-let x = 1\n+let x = 2\n*** End Patch' + } + ] + ] as const)('draws %s as a diff card, not escaped text', (_name, call) => { + const tree = render([ + { ...call, state: 'completed' }, + { type: 'tool-result', output: 'Success' } + ]) + openRow(tree, 'b.ts') + expect(textIn(tree.root)).toEqual(expect.arrayContaining(['let x = 1', 'let x = 2'])) + expect( + textIn(tree.root).some((text) => text.includes('\\n') || text.includes('*** Begin')) + ).toBe(false) + }) + + it('gives each file of a multi-file change its own card, and links no deleted file', () => { + const onOpenFile = vi.fn() + const tree = render( + [ + { + type: 'tool-call', + name: 'apply_patch', + input: + '*** Begin Patch\n*** Update File: src/d.ts\n@@\n-d\n+e\n*** Delete File: src/gone.ts\n*** End Patch', + state: 'completed' + }, + { type: 'tool-result', output: 'Success' } + ], + onOpenFile + ) + expect(textIn(tree.root)).toEqual( + expect.arrayContaining(['Edited', 'd.ts', 'Deleted', 'gone.ts']) + ) + const name = (text: string) => + tree.root.findAll((node) => String(node.type) === 'Text' && textIn(node)[0] === text)[0]! + act(() => name('d.ts').props.onPress()) + expect(onOpenFile).toHaveBeenCalledWith('src/d.ts') + expect(name('gone.ts').props.onPress).toBeUndefined() + }) + + it('opens a web search to its hits as links', () => { + const tree = render([ + { + type: 'tool-call', + name: 'web_search', + input: { query: 'FlatList docs' }, + webSearchResults: [{ title: 'FlatList', url: 'https://reactnative.dev/docs/flatlist' }] + } + ]) + openRow(tree, 'FlatList docs') + const hit = tree.root.findAll( + (node) => String(node.type) === 'Text' && node.props.accessibilityRole === 'link' + )[0]! + act(() => hit.props.onPress()) + expect(openExternalLink).toHaveBeenCalledWith('https://reactnative.dev/docs/flatlist') + }) + }) + + describe('task lists, as desktop draws them', () => { + const todo = (statuses: string[]): NativeChatToolCallBlock => ({ + type: 'tool-call', + name: 'TodoWrite', + state: 'completed', + input: { + todos: statuses.map((status, index) => ({ + content: `Task ${index}`, + status, + activeForm: `Doing task ${index}` + })) + } + }) + + it('draws a first plan as its checklist, not as raw input', () => { + const tree = render([ + todo(['completed', 'in_progress']), + { type: 'tool-result', output: 'Todos updated' } + ]) + expect(textIn(tree.root)).toEqual( + expect.arrayContaining(['Updated the plan', 'Tasks', '1/2', 'Task 0', 'Doing task 1']) + ) + expect(textIn(tree.root).some((text) => text.includes('todos'))).toBe(false) + expect(textIn(tree.root)).not.toContain('Todos updated') + }) + + it('draws a later plan as what changed since the one before', () => { + act(() => { + renderer = create( + createElement(ToolRun, { + blocks: [todo(['completed', 'completed', 'pending'])], + previousTodoWrite: todo(['completed', 'in_progress']), + defaultExpanded: true, + expandChildren: false + }) + ) + }) + expect(textIn(renderer!.root)).toEqual( + expect.arrayContaining(['Completed Task 1', 'Added Task 2', 'Full task list']) + ) + expect(textIn(renderer!.root)).not.toContain('Task 0') + }) + }) + + describe("what the reader opened, kept above the list's recycled rows", () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' }, + { type: 'tool-result', output: 'a.ts' } + ] + + /** The transcript's store, with the run mounted or recycled away. */ + function Transcript({ mounted, toolsExpanded }: { mounted: boolean; toolsExpanded: boolean }) { + const disclosures = useMobileNativeChatDisclosures('host\0worktree\0tab') + return createElement( + MobileNativeChatDisclosureContext.Provider, + { value: disclosures }, + mounted + ? createElement(ToolRun, { + blocks, + disclosureId: 'a1', + defaultExpanded: toolsExpanded, + expandChildren: toolsExpanded + }) + : null + ) + } + + function show(props: { mounted: boolean; toolsExpanded: boolean }): void { + act(() => { + if (renderer) { + renderer.update(createElement(Transcript, props)) + } else { + renderer = create(createElement(Transcript, props)) + } + }) + } + + const press = (label: string): void => { + act(() => { + renderer!.root + .findAll((node) => String(node.type) === 'Pressable') + .findLast((node) => textIn(node).includes(label))! + .props.onPress() + }) + } + + it('keeps an opened run and line open after the row is recycled and drawn again', () => { + show({ mounted: true, toolsExpanded: false }) + press('ls') + press('ls') + expect(textIn(renderer!.root)).toContain('a.ts') + show({ mounted: false, toolsExpanded: false }) + show({ mounted: true, toolsExpanded: false }) + expect(textIn(renderer!.root)).toContain('a.ts') + }) + + const box = (): ReactTestInstance => + renderer!.root.findAll((node) => String(node.type) === 'ScrollView')[0]! + const scrolled = (top: number) => ({ + nativeEvent: { + contentOffset: { y: top }, + contentSize: { height: 1_000 }, + layoutMeasurement: { height: 300 } + } + }) + + it('comes back where the reader left its box after the row is recycled', () => { + show({ mounted: true, toolsExpanded: false }) + press('ls') + act(() => { + box().props.onScroll(scrolled(120)) + }) + show({ mounted: false, toolsExpanded: false }) + show({ mounted: true, toolsExpanded: false }) + scrolls.to.mockClear() + act(() => { + box().props.onContentSizeChange() + }) + expect(scrolls.to).toHaveBeenCalledWith({ y: 120, animated: false }) + }) + + it('restores the reader’s choice when the Tools toggle goes back off', () => { + show({ mounted: true, toolsExpanded: false }) + press('ls') + show({ mounted: true, toolsExpanded: true }) + expect(textIn(renderer!.root)).toContain('a.ts') + show({ mounted: true, toolsExpanded: false }) + // The run the reader opened is still open; the line they never opened is closed again. + expect(textIn(renderer!.root).filter((text) => text === 'ls')).toHaveLength(2) + expect(textIn(renderer!.root)).not.toContain('a.ts') + }) + }) + + it('lets a tap inside the run land while the keyboard is up, as the transcript list does', () => { + const tree = render([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }]) + const boxes = tree.root.findAll((node) => String(node.type) === 'ScrollView') + expect(boxes.map((box) => box.props.keyboardShouldPersistTaps)).toEqual(['handled']) + }) + + it('follows new work at the end of a live run, but holds still when the reader opens a row', () => { + const live = (blocks: NativeChatBlock[]) => + createElement(ToolRun, { + blocks, + defaultExpanded: true, + expandChildren: false, + activeTurnIsWorking: true, + trailing: true + }) + const first: NativeChatBlock[] = [ + { type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' }, + { type: 'tool-result', output: 'a.ts' } + ] + act(() => { + renderer = create(live(first)) + }) + const box = () => renderer!.root.findAll((node) => String(node.type) === 'ScrollView')[0]! + act(() => { + box().props.onContentSizeChange() + }) + scrolls.toEnd.mockClear() + act(() => { + renderer!.root + .findAll((node) => String(node.type) === 'Pressable') + .findLast((node) => textIn(node).includes('ls'))! + .props.onPress() + }) + act(() => { + box().props.onContentSizeChange() + }) + expect(scrolls.toEnd).not.toHaveBeenCalled() + act(() => { + renderer!.update( + live([ + ...first, + { type: 'tool-call', name: 'Bash', input: { command: 'pwd' }, state: 'running' } + ]) + ) + }) + act(() => { + box().props.onContentSizeChange() + }) + expect(scrolls.toEnd).toHaveBeenCalledTimes(1) + }) + + it("keeps a row's output out of inline selection where the platform forbids it", () => { + const tree = render([ + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'tool-result', output: 'a.ts' } + ]) + act(() => { + tree.root + .findAll((node) => String(node.type) === 'Pressable') + .findLast((node) => textIn(node).includes('ls'))! + .props.onPress() + }) + const output = tree.root.findAll( + (node) => String(node.type) === 'Text' && textIn(node)[0] === 'a.ts' + )[0]! + expect(output.props.selectable).toBe(false) + }) + + it('does not save a follow for a later row opening when new work left the height unchanged', () => { + const live = (blocks: NativeChatBlock[]) => + createElement(ToolRun, { + blocks, + defaultExpanded: true, + expandChildren: false, + activeTurnIsWorking: true, + trailing: true + }) + const call: NativeChatBlock = { type: 'tool-call', name: 'Bash', input: { command: 'ls' } } + act(() => { + renderer = create(live([call])) + }) + const box = () => renderer!.root.findAll((node) => String(node.type) === 'ScrollView')[0]! + act(() => { + box().props.onContentSizeChange() + }) + // The result lands under the closed row: the box's height does not change. + act(() => { + renderer!.update(live([call, { type: 'tool-result', output: 'a.ts' }])) + }) + scrolls.toEnd.mockClear() + act(() => { + renderer!.root + .findAll((node) => String(node.type) === 'Pressable') + .findLast((node) => textIn(node).includes('ls'))! + .props.onPress() + }) + act(() => { + box().props.onContentSizeChange() + }) + expect(scrolls.toEnd).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/MobileNativeChatToolRun.tsx b/mobile/src/session/MobileNativeChatToolRun.tsx index f7c7865ea01..312edaa1969 100644 --- a/mobile/src/session/MobileNativeChatToolRun.tsx +++ b/mobile/src/session/MobileNativeChatToolRun.tsx @@ -1,282 +1,428 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { Animated, Pressable, Text, View } from 'react-native' -import { ChevronDown, SquareChevronRight, SquareTerminal, Wrench } from 'lucide-react-native' -import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff' -import type { NativeChatDiffLine as DiffLine } from '../../../src/shared/native-chat-diff' -import { pairToolBlocks } from '../../../src/shared/native-chat-tool-fold' -import type { NativeChatToolPair as ToolPair } from '../../../src/shared/native-chat-tool-fold' import { - createToolInputDisplay, - summarizeToolRun, - truncateToolDetail -} from '../../../src/shared/native-chat-tool-summary' + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' import { - describeActiveToolCall, - formatActiveToolLabel, - formatToolCallCount, + Animated, + Pressable, + ScrollView, + Text, + useWindowDimensions, + View, + type NativeScrollEvent, + type NativeSyntheticEvent +} from 'react-native' +import { Check } from 'lucide-react-native' +import { + describeLatestToolCall, + NATIVE_CHAT_TOOL_ACTIVITY_COPY, selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' -import { isShellActivityToolCall } from '../../../src/shared/native-chat-tool-icon' -import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { nativeChatToolRunIconName } from '../../../src/shared/native-chat-tool-icon' import { nativeChatToolLineIdentity } from '../../../src/shared/native-chat-tool-line-identity' +import { pairNativeChatToolResults } from '../../../src/shared/native-chat-tool-pairing' +import { nativeChatToolRunOutcome } from '../../../src/shared/native-chat-tool-run-outcome' +import { + NATIVE_CHAT_TOOL_RUN_SENTENCE_ENGLISH, + nativeChatToolRunSentence +} from '../../../src/shared/native-chat-tool-run-sentence' +import { countToolCalls } from '../../../src/shared/native-chat-tool-summary' +import { buildEditCards } from '../../../src/shared/native-chat-edit-cards' +import { + isToolCallBlock, + type NativeChatBlock, + type NativeChatToolCallBlock +} from '../../../src/shared/native-chat-types' +import { buildNativeChatTaskListRows } from '../../../src/shared/native-chat-task-list-history' import { colors } from '../theme/mobile-theme' -import { styles } from './mobile-native-chat-message-styles' +import { MobileNativeChatToolGlyph } from './MobileNativeChatToolIcon' +import { MobileNativeChatToolLine } from './MobileNativeChatToolLine' +import { MobileNativeChatDiffCard } from './MobileNativeChatDiffCard' +import { MobileNativeChatTaskList } from './MobileNativeChatTaskList' +import { MobileNativeChatOpenedDetailContext } from './mobile-native-chat-opened-detail' +import { + MAX_DISCLOSURES, + MobileNativeChatDisclosureContext, + useMobileNativeChatDisclosure +} from './mobile-native-chat-disclosure-store' +import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles' -const TOOL_PAIR_REVEAL_PAGE = 6 -const MAX_TOOL_RUN_DIFF_ROWS = 240 +/** Desktop's 18rem: an opened run lists this much before it scrolls in place. */ +const MEMBERS_MIN_HEIGHT = 288 +/** Desktop's `NATIVE_CHAT_FOLLOW_REARM_PX`: this close to the end, the box follows new work. */ +const FOLLOW_REARM_PX = 24 -function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element { - return ( - - {lines.map((line, i) => ( - - {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} - {line.text} - - ))} - - ) +function fill(template: string, value: number): string { + return template.replaceAll('{{value0}}', String(value)) } -/** A single inline tool line — `▸ ToolName preview` — that expands in place to - * show the call's diff/input or the result's body. Mirrors the reference design - * where tool calls read as flat lines in the conversation, not boxed blocks. */ -function ResultBody({ - output, - isError, - diff -}: { - output: string - isError?: boolean - diff: DiffLine[] | null -}): React.JSX.Element { - if (diff) { - return - } - return ( - - {truncateToolDetail(output)} - - ) -} - -/** One request: a tool call and its result rendered together as a single - * expandable line. `defaultExpanded` lets the group toggle open every line. */ -function ToolLine({ - pair, - defaultExpanded, - diffLineLimit, - onOpenFile -}: { - pair: ToolPair - defaultExpanded: boolean - diffLineLimit: number - onOpenFile?: (relativePath: string) => void -}): React.JSX.Element { - const [expanded, setExpanded] = useState(defaultExpanded) - const { call, result } = pair - const name = call ? call.name : 'Result' - const inputDisplay = call ? createToolInputDisplay(call.input) : null - const preview = inputDisplay?.label ?? result?.output.split('\n')[0]?.slice(0, 80) ?? '' - // Why: collapsed tool rows are the common path; defer bounded diff parsing - // and detail formatting until the user asks to reveal the detail. - const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null - const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null - const callDetail = expanded && inputDisplay && !callDiff ? inputDisplay.formatDetail() : undefined - const hasDetail = callDiff !== null || result !== undefined || inputDisplay?.hasDetail === true - // The group toggle opens every line at once, bypassing the tap guard, so the - // panel has to consult it too — else a detail-less row echoes its own label - // under itself and no tap can dismiss it. - const showDetail = hasDetail && expanded - // A tool that targets a file (Read/Edit/Write…) renders its preview as a - // tappable link that opens the file, independent of the line's expand tap. - const filePath = inputDisplay?.filePath ?? null - const openable = filePath !== null && onOpenFile !== undefined - return ( - - hasDetail && setExpanded((v) => !v)} - hitSlop={6} - > - {showDetail ? ( - - ) : ( - - )} - {name} - {preview ? ( - onOpenFile!(filePath!) : undefined} - suppressHighlighting={!openable} - > - {preview} - - ) : null} - - {showDetail ? ( - - {callDiff ? : null} - {callDetail ? {callDetail} : null} - {result ? ( - - ) : null} - - ) : null} - - ) -} - -/** Breathing label for a still-running tool, matching desktop's `animate-pulse`. */ +/** Desktop's `animate-pulse` on a live run's sentence. One element either way, so the + * header never remounts as the run settles. */ function PulsingText({ + pulsing, style, - numberOfLines, children }: { + pulsing: boolean style?: React.ComponentProps['style'] - numberOfLines?: number children: React.ReactNode }): React.JSX.Element { - const pulse = useRef(new Animated.Value(1)).current + const opacity = useRef(new Animated.Value(1)).current useEffect(() => { + if (!pulsing) { + opacity.setValue(1) + return + } const animation = Animated.loop( Animated.sequence([ - Animated.timing(pulse, { toValue: 0.45, duration: 700, useNativeDriver: true }), - Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }) + Animated.timing(opacity, { toValue: 0.5, duration: 1000, useNativeDriver: true }), + Animated.timing(opacity, { toValue: 1, duration: 1000, useNativeDriver: true }) ]) ) animation.start() return () => animation.stop() - }, [pulse]) + }, [opacity, pulsing]) return ( - + {children} ) } -/** A run of a message's tool calls/results, collapsed to a one-line summary that - * expands to the individual inline tool lines. `defaultExpanded` lets the global - * toolbar toggle drive every run at once while still allowing per-run override. */ +/** Desktop parity: `NativeChatToolRunAsides`. Rows drawn among an open run's lines, each keyed: + * before the block they are keyed by, or after the last. */ +export type MobileNativeChatToolRunAsides = { + before: ReadonlyMap + after: readonly React.JSX.Element[] +} + +/** Desktop parity: `NativeChatToolRunMembers`. An opened run's members in a bounded + * box, so live work fills it instead of lengthening the chat. A live run opens on + * what it is doing now and follows it while the reader stays at the end. */ +function ToolRunMembers({ + followKey, + startsAtEnd, + memoryKey, + children +}: { + /** How much work the run holds: a reader at the end follows it growing. Opening a detail + * must not count. */ + followKey: number + startsAtEnd: boolean + /** The run's disclosure key. The list recycles rows; the box comes back as the reader left it. */ + memoryKey?: string + children: React.ReactNode +}): React.JSX.Element { + const { height } = useWindowDimensions() + const runScroll = useContext(MobileNativeChatDisclosureContext)?.runScroll + const [remembered] = useState(() => + memoryKey === undefined ? undefined : runScroll?.get(memoryKey) + ) + const scroller = useRef(null) + const followsEnd = useRef(remembered?.followsEnd ?? startsAtEnd) + const pendingRestore = useRef(remembered !== undefined && !remembered.followsEnd) + // Desktop's rule: follow when the work changes, and only if the reader was at the end before + // it. Resolved here, with the update, so a later detail opening never inherits a follow. + useLayoutEffect(() => { + if (followsEnd.current) { + scroller.current?.scrollToEnd({ animated: false }) + } + }, [followKey]) + const details = useRef(new Map()) + const [openedHeight, setOpenedHeight] = useState(0) + // Room for the list plus what the reader has opened in it, up to half the screen. + const reportDetail = useCallback((detailId: string, detailHeight: number | null) => { + if (detailHeight === null) { + details.current.delete(detailId) + } else { + details.current.set(detailId, detailHeight) + } + let total = 0 + for (const value of details.current.values()) { + total += value + } + setOpenedHeight(total) + }, []) + const onScroll = ({ nativeEvent }: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = nativeEvent + followsEnd.current = + contentSize.height - contentOffset.y - layoutMeasurement.height <= FOLLOW_REARM_PX + if (runScroll && memoryKey !== undefined) { + runScroll.delete(memoryKey) + runScroll.set(memoryKey, { top: contentOffset.y, followsEnd: followsEnd.current }) + if (runScroll.size > MAX_DISCLOSURES) { + const oldest = runScroll.keys().next().value + if (oldest !== undefined) { + runScroll.delete(oldest) + } + } + } + } + // The remembered offset needs the rows laid out first, which the box's first size reports. + const onContentSizeChange = () => { + if (pendingRestore.current && remembered) { + pendingRestore.current = false + scroller.current?.scrollTo({ y: remembered.top, animated: false }) + } + } + return ( + + + {children} + + + ) +} + +/** Desktop parity: `NativeChatToolRun`. A run of tool calls under one sentence that + * opens to the individual tool lines. */ export function ToolRun({ blocks, defaultExpanded, expandChildren, - activeCall, + disclosureId, + asides, + activeTurnIsWorking, + trailing, + awaitingAnswer = false, + previousTodoWrite, + previousUpdatePlan, + fontScale = 1, onOpenFile }: { blocks: NativeChatBlock[] + /** The last plan each task-list tool wrote before this run, so an update reads as its changes. */ + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock defaultExpanded: boolean - /** Child tool lines stay collapsed when the turn caret drove the run open. */ + /** The row this run belongs to. The list recycles rows, so what the reader opened is + * remembered somewhere that outlives the row. */ + disclosureId?: string + /** Rows drawn among the run's lines, each before the block it is keyed by, or after the last. */ + asides?: MobileNativeChatToolRunAsides + /** Every line's default: open under the Tools toggle. */ expandChildren: boolean - /** The still-running call, when the turn is live (desktop parity). */ - activeCall: ReturnType - onOpenFile?: (relativePath: string) => void + /** Structured lifecycle state; keeps an orphaned running call from spinning. */ + activeTurnIsWorking?: boolean + /** Whether this run is the working turn's last. Only that run is live. */ + trailing?: boolean + /** The turn is blocked on the reader's answer, so its calls decide liveness. */ + awaitingAnswer?: boolean + fontScale?: number + onOpenFile?: (path: string) => void }): React.JSX.Element { - const [open, setOpen] = useState(defaultExpanded) - const [visibleCount, setVisibleCount] = useState(TOOL_PAIR_REVEAL_PAGE) - const pairs = useMemo( - () => (open ? pairToolBlocks(blocks, visibleCount + 1) : []), - [blocks, open, visibleCount] + // A reader's choice belongs to the default it deviated from, so toggling the Tools + // default back restores it. + const runKey = disclosureId === undefined ? undefined : `run:${disclosureId}:${defaultExpanded}` + const [open, setOpen] = useMobileNativeChatDisclosure(runKey, defaultExpanded) + // Rows drawn among the calls count as work too: a reader at the end follows them arriving. + const asideCount = asides + ? asides.after.length + + Array.from(asides.before.values()).reduce((count, rows) => count + rows.length, 0) + : 0 + // Live is the turn's state, not a call's: deriving it from "some call is running" + // flipped the header to settled and back around every call. + const live = + activeTurnIsWorking === true && !awaitingAnswer + ? trailing !== false + : selectActiveToolCall(blocks, { activeTurnIsWorking }) !== null + const callCount = countToolCalls(blocks) + const sentence = nativeChatToolRunSentence( + blocks, + { live }, + NATIVE_CHAT_TOOL_RUN_SENTENCE_ENGLISH ) - const visiblePairs = pairs.slice(0, visibleCount) - const seen = new Map() - const diffLineLimit = Math.max( - 1, - Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (visiblePairs.length * 2 || 1)) - ) - let callCount = 0 - let resultCount = 0 - for (const block of blocks) { - if (block.type === 'tool-call') { - callCount++ - } else if (block.type === 'tool-result') { - resultCount++ - } - } - const count = callCount || resultCount - const summary = callCount > 0 ? summarizeToolRun(blocks) : 'Result' - // The call's input, not its word: Codex names a classified shell row - // `read`/`search`/`list` and keeps the command it ran, while Claude's `Read` - // shares that word and ran none. - const ActiveToolIcon = activeCall && isShellActivityToolCall(activeCall) ? SquareTerminal : Wrench + const latestCall = live ? blocks.findLast(isToolCallBlock) : undefined + const latestCallLabel = latestCall ? describeLatestToolCall(latestCall) : null + const outcome = nativeChatToolRunOutcome(blocks, { activeTurnIsWorking }) + const headerIcon = nativeChatToolRunIconName(blocks.filter(isToolCallBlock)) + const fallbackLabel = + callCount === 0 + ? 'Result' + : callCount === 1 + ? NATIVE_CHAT_TOOL_ACTIVITY_COPY.countOne + : fill(NATIVE_CHAT_TOOL_ACTIVITY_COPY.countN, callCount) + const marks = [ + outcome.failedCallCount > 0 + ? fill(NATIVE_CHAT_TOOL_ACTIVITY_COPY.failedCount, outcome.failedCallCount) + : null, + outcome.interruptedCallCount > 0 + ? fill(NATIVE_CHAT_TOOL_ACTIVITY_COPY.interruptedCount, outcome.interruptedCallCount) + : null + ].filter((mark) => mark !== null) + const type = toolType(fontScale) + const iconSize = 15 * fontScale + return ( - - - {activeCall ? ( - setOpen((v) => !v)} - hitSlop={6} - accessibilityRole="button" - accessibilityState={{ expanded: open }} - accessibilityLiveRegion="polite" - > - - - {formatActiveToolLabel(describeActiveToolCall(activeCall))} - - {open ? : null} - - ) : ( - setOpen((v) => !v)} hitSlop={6}> - {open ? ( - - ) : ( - - )} - {count}× - - {summary || formatToolCallCount(count)} - - - )} - + + {/* One element for the run's whole life: live and settled are states of this + button, not two buttons. */} + setOpen(!open)} + accessibilityRole="button" + accessibilityState={{ expanded: open }} + accessibilityLiveRegion="polite" + > + {headerIcon ? ( + + ) : null} + + {sentence ?? fallbackLabel} + + {marks.length > 0 ? ( + // Never shrinks: it is the one thing the reader cannot afford to miss. + + {` · ${marks.join(', ')}`} + + ) : null} + {/* Only a stated success is marked done, and never while live. */} + {!live && outcome.succeeded ? ( + + ) : null} + {latestCallLabel ? ( + + {latestCallLabel} + + ) : null} + {open ? ( - - {visiblePairs.map((pair) => ( - - ))} - {pairs.length > visiblePairs.length ? ( - TOOL_PAIR_REVEAL_PAGE }} - onPress={() => setVisibleCount((count) => count + TOOL_PAIR_REVEAL_PAGE)} - hitSlop={6} - > - Show more - - ) : null} - {visiblePairs.length > TOOL_PAIR_REVEAL_PAGE ? ( - setVisibleCount(TOOL_PAIR_REVEAL_PAGE)} - hitSlop={6} - > - Show less - - ) : null} - + + + ) : null} ) } + +/** Desktop parity: `NativeChatToolRunMemberList`. Mounted only while the run is open, + * so a collapsed run pays for none of the pairing. */ +function ToolRunMemberList({ + blocks, + disclosureId, + asides, + previousTodoWrite, + previousUpdatePlan, + expandChildren, + fontScale, + onOpenFile +}: { + blocks: NativeChatBlock[] + disclosureId?: string + asides?: MobileNativeChatToolRunAsides + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock + expandChildren: boolean + fontScale: number + onOpenFile?: (path: string) => void +}): React.JSX.Element { + const taskLists = useMemo( + () => + buildNativeChatTaskListRows(blocks, { + todowrite: previousTodoWrite, + update_plan: previousUpdatePlan + }), + [blocks, previousTodoWrite, previousUpdatePlan] + ) + const { editCards, consumedResults } = useMemo(() => buildEditCards(blocks), [blocks]) + const { resultByCall, pairedResults } = useMemo(() => pairNativeChatToolResults(blocks), [blocks]) + const seen = new Map() + const member = (block: NativeChatBlock, blockIndex: number): React.ReactNode => { + const taskList = taskLists.rows.get(block) + if (taskList) { + return ( + + ) + } + const edit = editCards.get(block) + if (edit) { + return ( + + {edit.files.map((file, fileIndex) => ( + + ))} + + ) + } + // A result its call now owns is drawn by that call's line, not as a row of its own. + if ( + taskLists.consumedResults.has(block) || + consumedResults.has(block) || + pairedResults.has(block) || + (block.type !== 'tool-call' && block.type !== 'tool-result') + ) { + return null + } + const lineIdentity = nativeChatToolLineIdentity(block, seen, true) + return ( + + ) + } + + return ( + <> + {/* One flat keyed list with or without asides, so a row gaining its first never remounts a line. */} + {blocks.flatMap((block, blockIndex) => [ + ...(asides?.before.get(block) ?? []), + member(block, blockIndex) + ])} + {asides?.after} + + ) +} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 2a3e1365e8b..e90e7c09b70 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -1,4 +1,9 @@ import { useCallback, useMemo, useState } from 'react' +import { nativeChatTaskListPredecessors } from '../../../src/shared/native-chat-task-list-history' +import { + MobileNativeChatDisclosureContext, + useMobileNativeChatDisclosures +} from './mobile-native-chat-disclosure-store' import { ActivityIndicator, FlatList, @@ -297,11 +302,16 @@ export function MobileNativeChatView({ // The display status, decided once in the session hook. stopping: turnIndicator?.stopping === true, lineYields: ask != null || permission != null || question != null, + receipts, scopeKey: sendSurfaceId }) + // What the reader opened in this chat's runs, kept above the rows the list recycles. + const disclosures = useMobileNativeChatDisclosures(sendSurfaceId) + const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(data), [data]) const renderItem = useMobileNativeChatRowRenderer({ toolsExpanded, + taskListPredecessors, fontScale, onOpenFile, structuredActivityUi, @@ -353,39 +363,41 @@ export function MobileNativeChatView({ ) : ( - - - item.id} - renderItem={history.renderListRow} - contentContainerStyle={ - toolCallCount > 0 ? styles.listContentUnderFloatingControls : styles.listContent - } - // Let link/file taps land while the composer keyboard is up - // instead of being swallowed by the dismiss gesture. - keyboardShouldPersistTaps="handled" - onScroll={onScroll} - onScrollBeginDrag={beginUserScroll} - onScrollEndDrag={endUserDrag} - onMomentumScrollBegin={beginMomentum} - onMomentumScrollEnd={endMomentum} - scrollEventThrottle={32} - onContentSizeChange={pinToTailAfterContentResize} - onLayout={pinToTail} - maintainVisibleContentPosition={mobileNativeChatRowAnchor(hasMore === true)} - ListHeaderComponent={history.withHistory( - mobileNativeChatListHeader(hasMore, loadingEarlier, loadEarlier) - )} - ListFooterComponent={mobileNativeChatListFooter( - liveStatus, - turns.waitingRows, - renderItem - )} - ListEmptyComponent={emptyStateView} - /> - + + + + item.id} + renderItem={history.renderListRow} + contentContainerStyle={ + toolCallCount > 0 ? styles.listContentUnderFloatingControls : styles.listContent + } + // Let link/file taps land while the composer keyboard is up + // instead of being swallowed by the dismiss gesture. + keyboardShouldPersistTaps="handled" + onScroll={onScroll} + onScrollBeginDrag={beginUserScroll} + onScrollEndDrag={endUserDrag} + onMomentumScrollBegin={beginMomentum} + onMomentumScrollEnd={endMomentum} + scrollEventThrottle={32} + onContentSizeChange={pinToTailAfterContentResize} + onLayout={pinToTail} + maintainVisibleContentPosition={mobileNativeChatRowAnchor(hasMore === true)} + ListHeaderComponent={history.withHistory( + mobileNativeChatListHeader(hasMore, loadingEarlier, loadEarlier) + )} + ListFooterComponent={mobileNativeChatListFooter( + liveStatus, + turns.waitingRows, + renderItem + )} + ListEmptyComponent={emptyStateView} + /> + + = { tsx: 'typescript', sh: 'bash', shell: 'bash', + shellscript: 'bash', zsh: 'bash', fish: 'bash', yml: 'yaml' diff --git a/mobile/src/session/mobile-native-chat-disclosure-store.ts b/mobile/src/session/mobile-native-chat-disclosure-store.ts new file mode 100644 index 00000000000..d84f4e62f36 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-disclosure-store.ts @@ -0,0 +1,92 @@ +// Desktop parity: `native-chat-disclosure-store`. Where a transcript row's open/closed +// disclosures live when the row itself may be unmounted: a run or line the reader opened +// stays open after the list recycles its row, on reload, and across the Tools toggle. + +import { createContext, useCallback, useContext, useMemo, useState } from 'react' + +/** Where the reader left an opened run's own scroll box. */ +export type MobileNativeChatRunScroll = { top: number; followsEnd: boolean } + +export type MobileNativeChatDisclosureStore = { + read: (key: string) => boolean | undefined + write: (key: string, open: boolean) => void + /** By the run's disclosure key, and forgotten when that run closes. A plain map, not state: + * scrolling a run must not re-render the transcript. */ + runScroll: Map +} + +export const MobileNativeChatDisclosureContext = + createContext(null) + +/** Bounded, so a chat that ran for a day does not carry every disclosure it ever opened. */ +export const MAX_DISCLOSURES = 512 + +type ScopedDisclosures = { + scopeKey: string + open: ReadonlyMap + runScroll: Map +} + +function emptyDisclosures(scopeKey: string): ScopedDisclosures { + return { scopeKey, open: new Map(), runScroll: new Map() } +} + +/** One store per chat: another chat's choices never leak in. */ +export function useMobileNativeChatDisclosures(scopeKey: string): MobileNativeChatDisclosureStore { + const [state, setState] = useState(() => emptyDisclosures(scopeKey)) + if (state.scopeKey !== scopeKey) { + setState(emptyDisclosures(scopeKey)) + } + const { open, runScroll } = state + const write = useCallback( + (key: string, next: boolean) => { + // Forgotten with the run that closed, as desktop does. + if (!next) { + runScroll.delete(key) + } + setState((current) => { + if (current.open.get(key) === next) { + return current + } + const updated = new Map(current.open) + updated.delete(key) + updated.set(key, next) + if (updated.size > MAX_DISCLOSURES) { + const oldest = updated.keys().next().value + if (oldest !== undefined) { + updated.delete(oldest) + } + } + return { ...current, open: updated } + }) + }, + [runScroll] + ) + return useMemo( + () => ({ read: (key: string) => open.get(key), write, runScroll }), + [open, write, runScroll] + ) +} + +/** A disclosure remembered under `key` while the transcript provides a store; local otherwise. */ +export function useMobileNativeChatDisclosure( + key: string | undefined, + initialOpen: boolean +): [boolean, (next: boolean) => void] { + const store = useContext(MobileNativeChatDisclosureContext) + const [local, setLocal] = useState({ key, initialOpen, open: initialOpen }) + const localOpen = + local.key === key && local.initialOpen === initialOpen ? local.open : initialOpen + const open = key !== undefined && store ? (store.read(key) ?? localOpen) : localOpen + const write = store?.write + const setOpen = useCallback( + (next: boolean) => { + setLocal({ key, initialOpen, open: next }) + if (key !== undefined && write) { + write(key, next) + } + }, + [initialOpen, key, write] + ) + return [open, setOpen] +} diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index ea0cbf5b34e..3739840de3f 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -80,92 +80,6 @@ export const styles = StyleSheet.create({ color: colors.statusRed, fontSize: typography.metaSize }, - toolRun: { - marginTop: spacing.xs - }, - toolRunHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - toolRunToggle: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: 3 - }, - toolRunCount: { - color: colors.statusGreen, - fontFamily: typography.monoFamily, - fontSize: MONO_SIZE, - fontWeight: '700' - }, - toolRunLabel: { - flex: 1, - color: colors.textMuted, - fontFamily: typography.monoFamily, - fontSize: MONO_SIZE - }, - toolRunActive: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: 3 - }, - toolRunActiveLabel: { - flex: 1, - color: colors.textSecondary, - fontSize: typography.bodySize - }, - toolRunBody: { - paddingLeft: spacing.sm, - borderLeftWidth: 2, - borderLeftColor: colors.borderSubtle, - marginTop: spacing.xs - }, - toolLine: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: 3 - }, - toolName: { - color: colors.textPrimary, - fontFamily: typography.monoFamily, - fontSize: MONO_SIZE + 1, - fontWeight: '600' - }, - toolPreview: { - flex: 1, - color: colors.textMuted, - fontFamily: typography.monoFamily, - fontSize: MONO_SIZE - }, - toolPreviewLink: { - color: colors.accentBlue, - textDecorationLine: 'underline' - }, - toolDetail: { - paddingLeft: spacing.lg, - paddingBottom: spacing.xs, - gap: spacing.xs - }, - mono: { - color: colors.textSecondary, - fontFamily: typography.monoFamily, - fontSize: MONO_SIZE, - lineHeight: MONO_SIZE + 5 - }, - toolResult: { - borderRadius: radii.button, - backgroundColor: colors.bgPanel, - padding: spacing.md - }, - toolResultError: { - backgroundColor: colors.diffDeletedBg - }, imageRef: { color: colors.textSecondary, fontSize: TEXT_SIZE @@ -177,29 +91,5 @@ export const styles = StyleSheet.create({ backgroundColor: colors.bgRaised, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.borderSubtle - }, - diff: { - borderRadius: radii.button, - backgroundColor: colors.bgPanel, - paddingVertical: spacing.xs, - overflow: 'hidden' - }, - diffLine: { - color: colors.textSecondary, - fontFamily: typography.monoFamily, - fontSize: MONO_SIZE, - lineHeight: MONO_SIZE + 5, - paddingHorizontal: spacing.sm - }, - diffAdd: { - color: colors.gitDecorationAdded, - backgroundColor: colors.diffAddedBg - }, - diffDel: { - color: colors.gitDecorationDeleted, - backgroundColor: colors.diffDeletedBg - }, - diffMeta: { - color: colors.textMuted } }) 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 052c992766a..d9a6d257c6c 100644 --- a/mobile/src/session/mobile-native-chat-message-visuals.test.ts +++ b/mobile/src/session/mobile-native-chat-message-visuals.test.ts @@ -36,14 +36,26 @@ vi.mock('react-native', async () => { vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', + Bot: 'Bot', Brain: 'Brain', + Check: 'Check', ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight', Copy: 'Copy', + Eye: 'Eye', + FileMinus2: 'FileMinus2', + FilePen: 'FilePen', + FilePlus2: 'FilePlus2', + Folder: 'Folder', + Globe: 'Globe', + ListChecks: 'ListChecks', MessageSquareMore: 'MessageSquareMore', + Pencil: 'Pencil', + Plug: 'Plug', + Search: 'Search', SquareChevronRight: 'SquareChevronRight', SquareTerminal: 'SquareTerminal', - Wrench: 'Wrench', - ChevronRight: 'ChevronRight' + Wrench: 'Wrench' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ diff --git a/mobile/src/session/mobile-native-chat-opened-detail.ts b/mobile/src/session/mobile-native-chat-opened-detail.ts new file mode 100644 index 00000000000..557b4b7e1ea --- /dev/null +++ b/mobile/src/session/mobile-native-chat-opened-detail.ts @@ -0,0 +1,15 @@ +import { createContext, useContext, useEffect, useId } from 'react' +import type { LayoutChangeEvent } from 'react-native' + +type ReportDetail = (detailId: string, height: number | null) => void + +/** An opened run's box grows by the height of every detail opened in it, as desktop's does. */ +export const MobileNativeChatOpenedDetailContext = createContext(null) + +/** The `onLayout` for an opened detail, which reports its height to the run's box. */ +export function useMobileNativeChatOpenedDetail(): (event: LayoutChangeEvent) => void { + const report = useContext(MobileNativeChatOpenedDetailContext) + const detailId = useId() + useEffect(() => () => report?.(detailId, null), [detailId, report]) + return (event) => report?.(detailId, event.nativeEvent.layout.height) +} 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 d9cd73b6be3..d86503e75db 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -288,9 +288,8 @@ describe('foldMobileNativeChatMessages', () => { expect(folded.map((message) => message.id)).toEqual(['a1']) }) - // Mobile draws no task list, so a hidden plan update must not split the run around it. - it('keeps one tool run across a Codex plan update it does not draw', () => { - const plan: NativeChatMessage = { + function planFrame(head: string): NativeChatMessage { + return { id: 'plan', role: 'system', timestamp: 0, @@ -302,21 +301,33 @@ describe('foldMobileNativeChatMessages', () => { providerFrame: { provider: 'codex', kind: 'notification:turn/plan/updated', - payload: { head: '{}', byteLength: 2, digest: 'digest', truncated: false } + payload: { head, byteLength: head.length, digest: 'digest', truncated: false } } } ] } + } + + // Desktop draws a Codex plan update in place, as its task list. + it('draws a Codex plan update where it happened, as an update_plan call', () => { const folded = fold([ assistant('a1', 'Working.'), toolCall('c1'), toolResult('r1', 'ok'), - plan, + planFrame('{"plan":[{"step":"Port rows","status":"inProgress"}]}'), toolCall('c2'), toolResult('r2', 'ok') ]) + expect(folded.map((message) => message.id)).toEqual(['a1', 'plan', 'c2']) + expect(folded[1]).toMatchObject({ + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'update_plan', state: 'completed' }] + }) + }) + + it('draws nothing for a plan update it cannot read', () => { + const folded = fold([assistant('a1', 'Working.'), planFrame('{}')]) expect(folded.map((message) => message.id)).toEqual(['a1']) - expect(folded[0]?.blocks.filter((block) => block.type === 'tool-call')).toHaveLength(2) }) it('still folds a result whose call is inside the window', () => { diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index d6c006a1a38..9fc7e76da0c 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -7,7 +7,11 @@ import { isRootAgentJournalItem } from '../../../src/shared/agent-session-journa import { stripNoiseMessages } from '../../../src/shared/native-chat-noise' import { nativeChatStreamingMessage } from '../../../src/shared/native-chat-streaming' import { isWordlessProviderFrameMessage } from '../../../src/shared/native-chat-provider-frame-summary' -import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' +import { + foldToolMessages, + isUndrawnProviderFrameMessage +} from '../../../src/shared/native-chat-tool-fold' +import { projectNativeChatTaskListFrames } from '../../../src/shared/native-chat-task-list-frames' import { foldSubagentItems } from '../../../src/shared/native-chat-subagent-items' import { prepareNativeChatMessages } from '../../../src/shared/native-chat-message-preparation' import { @@ -66,21 +70,25 @@ export function foldMobileNativeChatMessages( // 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. // 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( - prepareNativeChatMessages( - messages.filter( - (message) => isRootAgentJournalItem(message) && !isWordlessProviderFrameMessage(message) - ), - agent + // user's message. An unrecognised event with no words of its own is stored, not drawn, so + // it goes before folding and never splits a tool run. A Codex plan update draws as its task + // list, projected after folding as desktop does, so it never takes another call's result; one + // whose payload cannot be read draws nothing, as on desktop. + return projectNativeChatTaskListFrames( + stripNoiseMessages( + foldToolMessages( + foldSubagentItems( + prepareNativeChatMessages( + messages.filter( + (message) => + isRootAgentJournalItem(message) && !isUndrawnProviderFrameMessage(message) + ), + agent + ) ) ) ) - ) + ).filter((message) => !isWordlessProviderFrameMessage(message)) } /** Assemble the folded transcript, streaming text, and optimistic user echoes. */ 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 6867612e0cc..c8f8cfe1f5e 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 @@ -43,12 +43,26 @@ vi.mock('react-native', async () => { vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', + Bot: 'Bot', + Brain: 'Brain', + Check: 'Check', ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight', Copy: 'Copy', + Eye: 'Eye', + FileMinus2: 'FileMinus2', + FilePen: 'FilePen', + FilePlus2: 'FilePlus2', + Folder: 'Folder', + Globe: 'Globe', + ListChecks: 'ListChecks', + MessageSquareMore: 'MessageSquareMore', + Pencil: 'Pencil', + Plug: 'Plug', + Search: 'Search', SquareChevronRight: 'SquareChevronRight', SquareTerminal: 'SquareTerminal', - Wrench: 'Wrench', - ChevronRight: 'ChevronRight' + Wrench: 'Wrench' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: () => null })) vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ diff --git a/mobile/src/session/mobile-native-chat-tool-styles.ts b/mobile/src/session/mobile-native-chat-tool-styles.ts new file mode 100644 index 00000000000..633f86cd47f --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-styles.ts @@ -0,0 +1,249 @@ +import { Platform, StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { MONO_SIZE, scaledType } from './mobile-native-chat-message-styles' + +/** Desktop's chat type scale: tool rows in the body size, code in the code size, + * annotations a step smaller. Pinch-zoom scales all three. */ +export function toolType(fontScale: number) { + return { + row: scaledType(typography.bodySize, fontScale), + code: scaledType(MONO_SIZE, fontScale, 5), + meta: scaledType(typography.metaSize, fontScale) + } +} + +// iOS has no font named `monospace`; it silently falls back to the proportional system face. +const MONO_FAMILY = Platform.OS === 'ios' ? 'Menlo' : typography.monoFamily + +export const toolStyles = StyleSheet.create({ + run: { + marginTop: spacing.xs + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 3 + }, + headerSentence: { + flexShrink: 1, + color: colors.textMuted + }, + headerSentenceLive: { + flexShrink: 0, + maxWidth: '72%', + color: colors.textPrimary + }, + headerMarks: { + flexShrink: 0 + }, + headerLatest: { + flexShrink: 1, + color: colors.textMuted + }, + members: { + marginLeft: 7, + marginTop: 2, + paddingLeft: 13, + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: colors.borderSubtle + }, + line: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 26 + }, + iconSlot: { + alignItems: 'center', + justifyContent: 'center' + }, + lineWord: { + flexShrink: 0, + color: colors.textMuted + }, + /** A row named by its tool rather than a verb keeps most of the width for that name. */ + lineName: { + flexShrink: 0, + maxWidth: '60%' + }, + lineTarget: { + flexShrink: 1, + color: colors.textPrimary + }, + lineLink: { + textDecorationLine: 'underline' + }, + lineTrailing: { + marginLeft: 'auto', + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingLeft: spacing.sm + }, + lineMeta: { + color: colors.textMuted, + fontVariant: ['tabular-nums'] + }, + caretOpen: { + transform: [{ rotate: '90deg' }] + }, + detail: { + marginLeft: 24, + paddingVertical: spacing.xs, + gap: 6 + }, + codeBox: { + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel, + padding: spacing.sm + }, + /** Desktop's `max-h-64`: one long output scrolls in place instead of filling the screen. */ + codeScroll: { + maxHeight: 256, + flexGrow: 0 + }, + codeText: { + color: colors.textPrimary, + fontFamily: MONO_FAMILY + }, + searchResults: { + gap: 2 + }, + searchHit: { + textDecorationLine: 'underline' + }, + searchUrl: { + color: colors.textMuted, + textDecorationLine: 'none' + }, + taskCard: { + gap: spacing.xs, + paddingVertical: spacing.xs + }, + taskHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 6 + }, + taskTitle: { + fontWeight: '500' + }, + taskList: { + gap: spacing.xs, + paddingVertical: spacing.xs + }, + taskRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 6 + }, + taskIcon: { + justifyContent: 'center' + }, + taskText: { + flexShrink: 1, + color: colors.textMuted + }, + taskActive: { + color: colors.textPrimary, + fontWeight: '500' + }, + taskDisclosure: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: 2 + }, + errorText: { + color: colors.statusRed + }, + // Desktop's `NativeChatDiffView`: diff text on a tinted row in the chat's own diff colours. + diffAdd: { + color: colors.chatDiffAdded, + backgroundColor: colors.diffAddedBg + }, + diffDel: { + color: colors.chatDiffRemoved, + backgroundColor: colors.diffDeletedBg + }, + diffMeta: { + color: colors.textMuted + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginVertical: 2 + }, + cardToggle: { + flex: 1, + minWidth: 0 + }, + shrinkable: { + flexShrink: 1 + }, + struck: { + textDecorationLine: 'line-through' + }, + countAdded: { + color: colors.gitDecorationAdded + }, + countRemoved: { + color: colors.gitDecorationDeleted + }, + /** Desktop's `max-h-72`: a long change scrolls in place. */ + diffBody: { + marginLeft: 24, + marginTop: spacing.xs, + marginBottom: spacing.xs, + maxHeight: 288, + flexGrow: 0, + paddingHorizontal: 0 + }, + diffRow: { + flexDirection: 'row', + alignItems: 'flex-start' + }, + diffAddGround: { + backgroundColor: colors.diffAddedBg + }, + diffDelGround: { + backgroundColor: colors.diffDeletedBg + }, + diffGutter: { + paddingRight: 6, + textAlign: 'right', + color: colors.textMuted, + fontVariant: ['tabular-nums'] + }, + diffAddGutter: { + backgroundColor: colors.diffAddedGutter + }, + diffDelGutter: { + backgroundColor: colors.diffDeletedGutter + }, + diffSign: { + width: 14, + textAlign: 'center' + }, + diffAddText: { + color: colors.chatDiffAdded + }, + diffDelText: { + color: colors.chatDiffRemoved + }, + diffText: { + flex: 1, + paddingRight: spacing.sm + }, + diffGap: { + textAlign: 'center', + color: colors.textMuted, + borderTopWidth: StyleSheet.hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + } +}) diff --git a/mobile/src/session/use-mobile-native-chat-row-renderer.tsx b/mobile/src/session/use-mobile-native-chat-row-renderer.tsx index 7e6a443856b..5618cfa9060 100644 --- a/mobile/src/session/use-mobile-native-chat-row-renderer.tsx +++ b/mobile/src/session/use-mobile-native-chat-row-renderer.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' +import type { NativeChatTaskListPredecessors } from '../../../src/shared/native-chat-task-list-history' import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt' import { isActionableStructuredAgentSessionPrompt } from '../../../src/shared/structured-agent-session-live-turn' import { MobileNativeChatMessage } from './MobileNativeChatMessage' @@ -10,6 +11,7 @@ import type { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat /** Desktop parity: `NativeChatTranscriptRow`'s context. */ export function useMobileNativeChatRowRenderer({ toolsExpanded, + taskListPredecessors, fontScale, onOpenFile, structuredActivityUi, @@ -20,6 +22,8 @@ export function useMobileNativeChatRowRenderer({ actionablePromptIds }: { toolsExpanded: boolean + /** The last plan each task-list tool wrote before each row, by row id. */ + taskListPredecessors: ReadonlyMap fontScale: number onOpenFile?: (relativePath: string) => void structuredActivityUi: boolean @@ -38,6 +42,9 @@ export function useMobileNativeChatRowRenderer({ { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function rows(messages: NativeChatMessage[], isWorking: boolean) { + act(() => { + renderer = create(createElement(Harness, { messages, enabled: true, isWorking })) + }) + const { disclosure } = renderer!.root.findByType(Result).props + return { + disclosure, + resolved: messages.map((message, index) => disclosure.resolveRow(index, message)) + } + } + + it('draws tool rows and the thoughts between them as one run at its first row', () => { + const messages = [userMessage('u1'), toolRow('a1'), thought('r1'), toolRow('a2'), answer('a3')] + const { resolved } = rows(messages, true) + expect(resolved[1]?.workRun?.map((member: NativeChatMessage) => member.id)).toEqual([ + 'a1', + 'r1', + 'a2' + ]) + expect(resolved.map((row) => row.joinsWorkRun === true)).toEqual([ + false, + false, + true, + true, + false + ]) + }) + + it('folds a settled turn to its answer until the reader opens it, and only offers that where it hides something', () => { + const messages = [ + userMessage('u1'), + toolRow('a1'), + answer('a2'), + userMessage('u2'), + answer('a3') + ] + const settledTurns = new Map([ + ['u1', { startedAt: 0, workedSeconds: 3 }], + ['u2', { startedAt: 0, workedSeconds: 2 }] + ]) + act(() => { + renderer = create( + createElement(Harness, { messages, enabled: true, isWorking: false, settledTurns }) + ) + }) + const read = () => { + const { disclosure } = renderer!.root.findByType(Result).props + return messages.map((message, index) => disclosure.resolveRow(index, message)) + } + const settled = read() + expect(settled.map((row) => row.foldedAway === true)).toEqual([ + false, + true, + false, + false, + false + ]) + expect(settled[0]?.turnKey).toBe('u1') + expect(settled[3]?.turnKey).toBeUndefined() + + act(() => renderer!.root.findByType(Result).props.disclosure.onToggleTurn('u1')) + expect(read().some((row) => row.foldedAway)).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-transcript-rows.ts b/mobile/src/session/use-mobile-native-chat-transcript-rows.ts new file mode 100644 index 00000000000..66427398983 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-transcript-rows.ts @@ -0,0 +1,148 @@ +// Desktop parity: `native-chat-transcript-slots` and `native-chat-transcript-work-runs`. Which +// rows a settled turn folds away, and which unbroken stretches draw as one work run. + +import { useMemo } from 'react' +import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt' +import { deriveNativeChatRowContent } from '../../../src/shared/native-chat-row-content' +import { + NATIVE_CHAT_EMPTY_TURN_FOLD, + nativeChatTurnFold, + nativeChatTurnFoldRow, + type NativeChatTurnFold +} from '../../../src/shared/native-chat-turn-fold' +import { + nativeChatRowRendersProse, + nativeChatRowSpeaksOrActs +} from '../../../src/shared/native-chat-trailing-run' +import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + nativeChatWorkRunMember, + nativeChatWorkRunSpans +} from '../../../src/shared/native-chat-work-run' + +type TranscriptRows = { + rows: readonly NativeChatMessage[] + turnKeys: readonly (string | undefined)[] + receipts: ReadonlyMap | null | undefined +} + +export type MobileNativeChatWorkRuns = { + /** Each run's first row, with every member in order. */ + heads: ReadonlyMap + /** The later members, which their first row draws. */ + joined: ReadonlySet +} + +/** A settled turn folds to its answer until the reader opens it. */ +function mobileNativeChatTurnFold({ + rows, + turnKeys, + receipts, + settledTurnKeys, + expandedTurnKeys +}: TranscriptRows & { + settledTurnKeys: ReadonlySet + expandedTurnKeys: ReadonlySet +}): NativeChatTurnFold { + return nativeChatTurnFold({ + rows: rows.map((message, index) => + nativeChatTurnFoldRow(message, turnKeys[index], receipts?.has(message.id) === true) + ), + settledTurnKeys, + expandedTurnKeys + }) +} + +/** Thoughts sit inside a run rather than splitting it. A row that draws nothing (a blank + * thought, or the one the live line discloses) neither joins nor ends one. */ +function mobileNativeChatWorkRuns({ + rows, + turnKeys, + receipts, + liveReasoningId +}: TranscriptRows & { liveReasoningId: string | undefined }): MobileNativeChatWorkRuns { + const spans = nativeChatWorkRunSpans( + rows.map((message, index) => ({ + // At the top level the live line owns the open thought. + member: nativeChatWorkRunMember(message, receipts?.has(message.id) === true, false), + draws: + message.role !== 'reasoning' || + (message.id !== liveReasoningId && + deriveNativeChatRowContent(message.blocks).markdown.trim().length > 0), + scope: turnKeys[index] + })) + ) + const heads = new Map() + const joined = new Set() + for (const [head, ...rest] of spans) { + if (head === undefined) { + continue + } + heads.set( + head, + [head, ...rest].flatMap((index) => rows[index] ?? []) + ) + for (const index of rest) { + joined.add(index) + } + } + return { heads, joined } +} + +const NO_RECEIPTS: ReadonlyMap = new Map() +const NO_WORK_RUNS: MobileNativeChatWorkRuns = { heads: new Map(), joined: new Set() } + +/** How the structured lane's rows draw: the fold, the live frontier, and the work runs. The + * bridge lane has no turns, so none of it applies there. */ +export function useMobileNativeChatTranscriptRows({ + enabled, + rows, + turnKeys, + receipts, + completedByTurn, + expandedTurnKeys, + liveReasoningId +}: TranscriptRows & { + enabled: boolean + completedByTurn: Readonly> + expandedTurnKeys: ReadonlySet + liveReasoningId: string | undefined +}): { fold: NativeChatTurnFold; trailingRunIndex: number; workRuns: MobileNativeChatWorkRuns } { + const fold = useMemo( + () => + enabled + ? mobileNativeChatTurnFold({ + rows, + turnKeys, + receipts, + settledTurnKeys: new Set( + Object.entries(completedByTurn).flatMap(([turnKey, status]) => + status.workedSeconds != null ? [turnKey] : [] + ) + ), + expandedTurnKeys + }) + : NATIVE_CHAT_EMPTY_TURN_FOLD, + [completedByTurn, enabled, expandedTurnKeys, receipts, rows, turnKeys] + ) + // Liveness is the turn's, not any one call's: the run at the frontier stays live + // between its calls, and a run the agent has moved past is settled. + const trailingRunIndex = useMemo( + () => + enabled + ? rows.findLastIndex((row) => + nativeChatRowSpeaksOrActs(row, nativeChatRowRendersProse(row), receipts ?? NO_RECEIPTS) + ) + : -1, + [enabled, receipts, rows] + ) + const workRuns = useMemo( + () => + enabled + ? mobileNativeChatWorkRuns({ rows, turnKeys, receipts, liveReasoningId }) + : NO_WORK_RUNS, + [enabled, liveReasoningId, receipts, rows, turnKeys] + ) + return { fold, trailingRunIndex, workRuns } +} diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index 4118a0e3d67..23f7095207e 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -13,6 +13,17 @@ import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn import { Harness, Result, userMessage } from './use-mobile-native-chat-turn-disclosure.test-fixture' +/** A row the agent worked in, which a settled turn folds away. */ +function workRow(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' }], + timestamp: null, + source: 'transcript' + } +} + describe('useMobileNativeChatTurnDisclosure', () => { let renderer: ReactTestRenderer | null = null @@ -50,6 +61,7 @@ describe('useMobileNativeChatTurnDisclosure', () => { vi.useFakeTimers() try { vi.setSystemTime(1_000) + // A turn offers its disclosure only when it folds something away, as on desktop. const messages: NativeChatMessage[] = [ { id: 'u1', @@ -57,7 +69,8 @@ describe('useMobileNativeChatTurnDisclosure', () => { blocks: [{ type: 'text', text: 'go' }], timestamp: null, source: 'transcript' - } + }, + workRow('a1') ] act(() => { renderer = create(createElement(Harness, { messages, enabled: true })) @@ -97,7 +110,7 @@ describe('useMobileNativeChatTurnDisclosure', () => { vi.useFakeTimers() try { vi.setSystemTime(1_000) - const messages = [userMessage('u1')] + const messages = [userMessage('u1'), workRow('a1')] act(() => { renderer = create(createElement(Harness, { messages, enabled: true })) }) @@ -409,8 +422,7 @@ describe('useMobileNativeChatTurnDisclosure', () => { renderer?.update(createElement(Harness, { messages, enabled: true, isWorking: false })) }) const disclosureNow = renderer!.root.findByType(Result).props.disclosure - const row = disclosureNow.resolveRow(index, messages[index]) - act(() => disclosureNow.onToggleTurn(row.turnKey)) + act(() => disclosureNow.onToggleTurn(`u${index}`)) } const disclosure = renderer!.root.findByType(Result).props.disclosure diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 7a68e168c1c..4509969d4a8 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -13,6 +13,8 @@ import { } from '../../../src/shared/native-chat-turn-membership' import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../src/shared/native-chat-messages-waiting-behind-live-turn' import { NATIVE_CHAT_STREAMING_ID } from '../../../src/shared/native-chat-streaming' +import type { NativeChatResolvedPrompt } from '../../../src/shared/native-chat-resolution-receipt' +import { useMobileNativeChatTranscriptRows } from './use-mobile-native-chat-transcript-rows' import { nativeChatRowsInDrawOrder, nativeChatTurnBarRows @@ -34,6 +36,17 @@ export type MobileNativeChatTurnRow = { /** Set only on a settled turn — the one row that has activity to disclose. */ turnKey?: string activeTurnIsWorking: boolean + /** The live frontier: the last row where the agent spoke or acted. Only its run is live. */ + trailingRun: boolean + /** Desktop parity: an unbroken stretch of tool rows and finished thoughts draws as one run, + * at its first row. Set on that row: every member, in order. */ + workRun?: readonly NativeChatMessage[] + /** A later member of a work run, which its first row draws. */ + joinsWorkRun?: boolean + /** A settled turn folded behind its "Worked for" row hides every row but its answer. */ + foldedAway?: boolean + /** The reasoning disclosures the reader opened, for the thoughts drawn inside a work run. */ + openReasoning?: ReadonlySet /** The newest assistant row of a live turn with no prompt open: its last text may still grow. */ mayStillGrow: boolean /** The live activity line discloses this open reasoning block, so its row draws nothing. */ @@ -94,6 +107,7 @@ export function useMobileNativeChatTurnDisclosure({ activityText = null, stopping = false, lineYields = false, + receipts, scopeKey }: { messages: readonly NativeChatMessage[] @@ -112,6 +126,8 @@ export function useMobileNativeChatTurnDisclosure({ stopping?: boolean /** A prompt the reader must answer replaces the live activity line. */ lineYields?: boolean + /** Resolved prompts by row; an approval's receipt does not move the agent past its run. */ + receipts?: ReadonlyMap | null /** Host/worktree/tab identity for timing and disclosure isolation. */ scopeKey: string }): { @@ -208,6 +224,15 @@ export function useMobileNativeChatTurnDisclosure({ }, [expandedReasoning, line] ) + const { fold, trailingRunIndex, workRuns } = useMobileNativeChatTranscriptRows({ + enabled, + rows, + turnKeys, + receipts, + completedByTurn, + expandedTurnKeys: expandedTurnIds, + liveReasoningId + }) const latestAssistantId = useMemo( () => waiting.listMessages.findLast((row) => row.role === 'assistant')?.id ?? null, [waiting.listMessages] @@ -233,12 +258,21 @@ export function useMobileNativeChatTurnDisclosure({ // closure per row would be a new identity every render of a streaming // transcript, defeating the row's memo; caching one per turn would mean // writing a ref during render, which react-freeze can discard. - turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined, + // Offered only where the fold hides something, as on desktop. + turnKey: turnKey && fold.foldableTurnKeys.has(turnKey) ? turnKey : undefined, + ...(fold.foldedRows.has(index) ? { foldedAway: true } : {}), // With no user boundary at all, the session's working state stays authoritative. The bridge // lane has no turns: only its synthetic streaming row is known to be in the live one. activeTurnIsWorking: enabled ? inLiveWorkingTurn(index) : isWorking && message.id === NATIVE_CHAT_STREAMING_ID, + trailingRun: + index === trailingRunIndex || + (workRuns.heads.get(index)?.includes(rows[trailingRunIndex]!) ?? false), + ...(workRuns.heads.has(index) + ? { workRun: workRuns.heads.get(index), openReasoning: expandedReasoning } + : {}), + ...(workRuns.joined.has(index) ? { joinsWorkRun: true } : {}), mayStillGrow: !lineYields && message.id === latestAssistantId, reasoningIsLive: message.id === liveReasoningId, reasoningExpanded: @@ -263,6 +297,10 @@ export function useMobileNativeChatTurnDisclosure({ completedByTurn, expandedTurnIds, inLiveWorkingTurn, + trailingRunIndex, + workRuns, + fold, + rows, latestAssistantId, lineYields, liveReasoningId, diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts index 4a97f08252f..ae4beb2c282 100644 --- a/mobile/src/theme/mobile-theme.ts +++ b/mobile/src/theme/mobile-theme.ts @@ -35,6 +35,13 @@ export const colors = { gitDecorationDeleted: '#c74e39', diffAddedBg: 'rgba(129, 184, 139, 0.1)', diffDeletedBg: 'rgba(199, 78, 57, 0.11)', + // Desktop's `--diff-*-gutter`: a tinted row's line-number column. + diffAddedGutter: 'rgba(129, 184, 139, 0.3)', + diffDeletedGutter: 'rgba(199, 78, 57, 0.32)', + // Desktop's `--chat-diff-*`: diff text in the chat, mixed toward the foreground so it reads on a + // tinted row where the git decoration colours would not. + chatDiffAdded: '#a5ccac', + chatDiffRemoved: '#d68273', syntaxComment: '#6a9955', syntaxKeyword: '#569cd6', diff --git a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx index 3afd476d228..3acba88aac0 100644 --- a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx @@ -7,6 +7,8 @@ import { translate } from '@/i18n/i18n' import { DiffLineCounts } from '../right-sidebar/source-control/listing/diff-line-counts' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatToolFileTarget } from './NativeChatToolFileTarget' +import { nativeChatToolRowVerb } from './native-chat-tool-line-label' +import { nativeChatEditFilePatchText } from '../../../../shared/native-chat-edit-cards' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { unifiedLineNumber, @@ -14,19 +16,6 @@ import { type NativeChatEditLine } from '../../../../shared/native-chat-edit-model' -function verbLabel(file: NativeChatEditFile): string { - switch (file.changeKind) { - case 'added': - return translate('components.native-chat.tool.row.added', 'Added') - case 'deleted': - return translate('components.native-chat.tool.row.deleted', 'Deleted') - case 'renamed': - return translate('components.native-chat.tool.row.renamed', 'Renamed') - case 'edited': - return translate('components.native-chat.tool.row.edited', 'Edited') - } -} - function VerbIcon({ kind }: { kind: NativeChatEditFile['changeKind'] }): React.JSX.Element { const className = 'size-3.5 shrink-0 text-chat-foreground-faint' if (kind === 'added') { @@ -42,13 +31,6 @@ function baseName(path: string): string { return path.split(/[\\/]/).at(-1) || path } -function patchText(lines: readonly NativeChatEditLine[]): string { - return lines - .filter((line) => line.kind !== 'gap') - .map((line) => `${line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}${line.text}`) - .join('\n') -} - /** The break between two regions of the file, quiet enough not to read as a * row of content but present enough that the gutter's jump is accounted for. */ function DiffGapRow(): React.JSX.Element { @@ -170,7 +152,7 @@ export function NativeChatDiffCard({ }, [revealSignal, onReveal, setExpanded]) // Joining every row to seed the copy button is the card's most expensive // work, and a collapsed card renders none of those rows. - const copyText = useMemo(() => patchText(file.lines), [file.lines]) + const copyText = useMemo(() => nativeChatEditFilePatchText(file.lines), [file.lines]) const hasBody = file.lines.length > 0 const widest = file.lineNumbersKnown ? file.lines.reduce((max, line) => Math.max(max, unifiedLineNumber(line) ?? 0), 0) @@ -192,7 +174,9 @@ export function NativeChatDiffCard({ - {verbLabel(file)} + + {nativeChatToolRowVerb(file.changeKind)} + {file.oldPath ? ( <> = { diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 4aa8bddf6dd..686173ebe25 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -22,7 +22,7 @@ import { NATIVE_CHAT_TOOL_ACTIVITY_COPY, selectActiveToolCall } from '../../../../shared/native-chat-tool-activity' -import { nativeChatToolRunIconName } from './native-chat-tool-category' +import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon' import { nativeChatToolRunOutcome } from '../../../../shared/native-chat-tool-run-outcome' import { nativeChatAskRunBlocks, diff --git a/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx b/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx index 2d44b6d17f3..a79472e5f1c 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRunMemberList.tsx @@ -6,8 +6,8 @@ import { nativeChatToolLineIdentity } from '../../../../shared/native-chat-tool- import { NativeChatDiffCard } from './NativeChatDiffCard' import { NativeChatTaskList } from './NativeChatTaskList' import { NativeChatToolLine } from './NativeChatToolLine' -import { buildEditCards } from './native-chat-edit-cards' -import { buildNativeChatTaskListRows } from './native-chat-task-list-history' +import { buildEditCards } from '../../../../shared/native-chat-edit-cards' +import { buildNativeChatTaskListRows } from '../../../../shared/native-chat-task-list-history' import type { NativeChatDiffReveal } from './native-chat-turn-diffs' import type { NativeChatToolRunAsides } from './NativeChatToolRun' diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index 6b2f2bfa447..5966eab2d19 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -9,7 +9,7 @@ import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup' import { NativeChatSubagentSectionHead } from './NativeChatSubagentSectionHead' import { NativeChatSubagentEntries } from './NativeChatSubagentRun' import type { NativeChatSubagentDisclosure } from './native-chat-subagent-sections' -import type { NativeChatTaskListPredecessors } from './native-chat-task-list-history' +import type { NativeChatTaskListPredecessors } from '../../../../shared/native-chat-task-list-history' import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' import { NativeChatRewindContext } from './native-chat-rewind-context' diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index dddcaf20df3..82fffeb478d 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -22,7 +22,7 @@ import { import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat-resolution-receipt' import { nativeChatSubagentSections } from './native-chat-subagent-sections' -import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' +import { projectNativeChatTaskListFrames } from '../../../../shared/native-chat-task-list-frames' import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' diff --git a/src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts b/src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts index ca3c8883c02..266ccdca442 100644 --- a/src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts @@ -3,9 +3,13 @@ import en from '@/i18n/locales/en.json' import { NATIVE_CHAT_TOOL_ACTIVITY_COPY } from '../../../../shared/native-chat-tool-activity' import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../../shared/native-chat-turn-status' import { + NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY, NATIVE_CHAT_TOOL_RUN_SENTENCE_COPY, NATIVE_CHAT_TOOL_RUN_SENTENCE_JOINERS } from '../../../../shared/native-chat-tool-run-sentence' +import { NATIVE_CHAT_TOOL_ROW_VERB_COPY } from '../../../../shared/native-chat-tool-line-label' +import { NATIVE_CHAT_DIFF_CARD_COPY } from '../../../../shared/native-chat-edit-cards' +import { NATIVE_CHAT_TASK_LIST_COPY } from '../../../../shared/native-chat-task-list' // The shared copy is desktop's i18n fallback and mobile's actual rendered string. // If the two drift, desktop keeps showing en.json while mobile shows the constant — @@ -21,6 +25,9 @@ const catalog = en as unknown as { } } +const rowVerbCatalog: Record = en.components['native-chat'].tool.row +const taskListCatalog: Record = en.components['native-chat'].taskList + describe('native-chat shared copy matches the English catalog', () => { it.each(Object.entries(NATIVE_CHAT_TURN_STATUS_COPY))( 'status.%s matches en.json', @@ -66,6 +73,52 @@ describe('native-chat shared copy matches the English catalog', () => { expect(catalog.components['native-chat'].tool[key]).toBe(value) }) + it.each([ + ['runLiveReadOne', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.read.one], + ['runLiveReadMany', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.read.many], + ['runLiveSearchOne', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.search.one], + ['runLiveSearchMany', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.search.many], + ['runLiveListFilesOne', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.listFiles.one], + ['runLiveListFilesMany', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.listFiles.many], + ['runLiveCommandOne', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.unknown.one], + ['runLiveCommandMany', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.unknown.many], + ['runLiveFileChangeOne', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.fileChange.one], + ['runLiveFileChangeMany', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.fileChange.many], + ['runLiveWebSearchOne', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.webSearch.one], + ['runLiveWebSearchMany', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.webSearch.many], + ['runLiveIntegrationOne', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.mcpToolCall.one], + ['runLiveIntegrationMany', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.mcpToolCall.many], + ['runLiveAgentOne', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.subAgentActivity.one], + ['runLiveAgentMany', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.subAgentActivity.many], + ['runLivePlanOne', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.todoList.one], + ['runLivePlanMany', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.todoList.many], + ['runLiveToolOne', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.other.one], + ['runLiveToolMany', NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY.other.many] + ])('tool.%s matches the shared live run sentence copy', (key, value) => { + expect(catalog.components['native-chat'].tool[key]).toBe(value) + }) + + it.each(Object.entries(NATIVE_CHAT_TOOL_ROW_VERB_COPY))( + 'tool.row.%s matches the shared row verb copy', + (key, value) => { + expect(rowVerbCatalog[key]).toBe(value) + } + ) + + it.each(Object.entries(NATIVE_CHAT_DIFF_CARD_COPY))( + 'tool.%s matches the shared diff card copy', + (key, value) => { + expect(catalog.components['native-chat'].tool[key]).toBe(value) + } + ) + + it.each(Object.entries(NATIVE_CHAT_TASK_LIST_COPY))( + 'taskList.%s matches the shared task list copy', + (key, value) => { + expect(taskListCatalog[key]).toBe(value) + } + ) + it('keeps the interpolation placeholders the catalog expects', () => { expect(NATIVE_CHAT_TURN_STATUS_COPY.workedFor).toContain('{{value0}}') expect(NATIVE_CHAT_TURN_STATUS_COPY.workingFor).toContain('{{value0}}') diff --git a/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts b/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts index 0543d5c6731..e3d14f436ad 100644 --- a/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts @@ -23,7 +23,10 @@ import { type NativeChatSubagentRosterState, type NativeChatSubagentSections } from './native-chat-subagent-sections' -import { nativeChatRowRendersProse, nativeChatRowSpeaksOrActs } from './native-chat-trailing-run' +import { + nativeChatRowRendersProse, + nativeChatRowSpeaksOrActs +} from '../../../../shared/native-chat-trailing-run' import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' /** The head of a section no loaded roster row holds: it names whose rows follow it. */ diff --git a/src/renderer/src/components/native-chat/native-chat-tool-category.ts b/src/renderer/src/components/native-chat/native-chat-tool-category.ts deleted file mode 100644 index c61456e21d8..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-tool-category.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - nativeChatToolCategory as sharedCategory, - nativeChatToolIconName as sharedIconName, - nativeChatToolRunIconName as sharedRunIconName -} from '../../../../shared/native-chat-tool-icon' -import { nativeChatToolRunClauses as sharedRunClauses } from '../../../../shared/native-chat-tool-run-sentence' -import type { NativeChatMcpIdentity } from '../../../../shared/native-chat-tool-identity' - -type CategoryCall = { name: string; mcpIdentity?: NativeChatMcpIdentity } - -// Agent is the transcript alias of Task; keep desktop rows and headers in agreement. -function categoryName(name: string): string { - return name.trim().toLowerCase() === 'agent' ? 'Task' : name -} - -function categoryCalls(calls: readonly CategoryCall[]): CategoryCall[] { - return calls.map((call) => ({ ...call, name: categoryName(call.name) })) -} - -export function nativeChatToolCategory(name: string, identity?: NativeChatMcpIdentity) { - return sharedCategory(categoryName(name), identity) -} - -export function nativeChatToolIconName(name: string, identity?: NativeChatMcpIdentity) { - return sharedIconName(categoryName(name), identity) -} - -export function nativeChatToolRunIconName(calls: readonly CategoryCall[]) { - return sharedRunIconName(categoryCalls(calls)) -} - -export function nativeChatToolRunClauses(calls: readonly CategoryCall[]) { - return sharedRunClauses(categoryCalls(calls)) -} diff --git a/src/renderer/src/components/native-chat/native-chat-tool-line-label.ts b/src/renderer/src/components/native-chat/native-chat-tool-line-label.ts index 72419af7f07..1761fc4e18c 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-line-label.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-line-label.ts @@ -1,124 +1,74 @@ import { translate } from '@/i18n/i18n' -import { nativeChatCommandLanguage } from './native-chat-command-language' -import { nativeChatToolCategory } from './native-chat-tool-category' import { - nativeChatFullCommand, - nativeChatPlainCommandInput, - nativeChatToolInputText -} from './native-chat-tool-input-text' -import { - createToolInputDisplay, - summarizeToolInput -} from '../../../../shared/native-chat-tool-summary' + nativeChatToolLineLabel as sharedToolLineLabel, + type NativeChatToolLineLabel, + type NativeChatToolRowVerb +} from '../../../../shared/native-chat-tool-line-label' import type { NativeChatToolCallBlock, NativeChatToolResultBlock } from '../../../../shared/native-chat-types' +/** Spelled out per verb, with the English literal at the call site, so the catalog + * extractor sees every key; `native-chat-shared-copy-matches-catalog` pins them + * against the record mobile renders. */ +export function nativeChatToolRowVerb(verb: NativeChatToolRowVerb): string { + switch (verb) { + case 'running': + return translate('components.native-chat.tool.row.running', 'Running') + case 'ran': + return translate('components.native-chat.tool.row.ran', 'Ran') + case 'reading': + return translate('components.native-chat.tool.row.reading', 'Reading') + case 'read': + return translate('components.native-chat.tool.row.read', 'Read') + case 'triedRead': + return translate('components.native-chat.tool.row.triedRead', 'Tried to read') + case 'editing': + return translate('components.native-chat.tool.row.editing', 'Editing') + case 'edited': + return translate('components.native-chat.tool.row.edited', 'Edited') + case 'triedEdit': + return translate('components.native-chat.tool.row.triedEdit', 'Tried to edit') + case 'added': + return translate('components.native-chat.tool.row.added', 'Added') + case 'deleted': + return translate('components.native-chat.tool.row.deleted', 'Deleted') + case 'renamed': + return translate('components.native-chat.tool.row.renamed', 'Renamed') + case 'subagent': + return translate('components.native-chat.tool.row.subagent', 'Subagent') + case 'searching': + return translate('components.native-chat.tool.row.searching', 'Searching') + case 'searched': + return translate('components.native-chat.tool.row.searched', 'Searched') + case 'triedSearch': + return translate('components.native-chat.tool.row.triedSearch', 'Tried to search') + case 'listing': + return translate('components.native-chat.tool.row.listing', 'Listing') + case 'listed': + return translate('components.native-chat.tool.row.listed', 'Listed') + case 'triedList': + return translate('components.native-chat.tool.row.triedList', 'Tried to list') + case 'fetching': + return translate('components.native-chat.tool.row.fetching', 'Fetching') + case 'fetched': + return translate('components.native-chat.tool.row.fetched', 'Fetched') + case 'triedFetch': + return translate('components.native-chat.tool.row.triedFetch', 'Tried to fetch') + case 'searchingWeb': + return translate('components.native-chat.tool.row.searchingWeb', 'Searching the web') + case 'searchedWeb': + return translate('components.native-chat.tool.row.searchedWeb', 'Searched the web') + case 'triedSearchWeb': + return translate('components.native-chat.tool.row.triedSearchWeb', 'Tried to search the web') + } +} + export function nativeChatToolLineLabel( call: NativeChatToolCallBlock, result?: NativeChatToolResultBlock -): { - verb: string | null - target: string - title: string - command: boolean - filePath: string | null - /** The whole command, when the row had to shorten it. */ - commandDetail: string | null - /** Grammar of `commandDetail`; null when the row holds no command text. */ - commandLanguage: string | null -} { - const display = createToolInputDisplay(call.input) - const category = nativeChatToolCategory(call.name, call.mcpIdentity) - const command = category === 'unknown' - const running = call.state === 'running' - const completed = - call.state !== 'failed' && - !result?.isError && - (call.state === 'completed' || result !== undefined) - const failed = call.state === 'failed' || result?.isError === true - let commandDetail: string | null = null - let commandLanguage: string | null = null - let verb: string | null = null - let target = display.label - let title = display.filePath ?? target - const filePath = category === 'read' || category === 'fileChange' ? display.filePath : null - - if (command) { - const fullCommand = nativeChatFullCommand(call.input) - target = fullCommand === null ? target : summarizeToolInput(fullCommand) - title = fullCommand ?? target - if (fullCommand !== null) { - commandLanguage = nativeChatCommandLanguage(call.name, call.input) - const plainCommand = nativeChatPlainCommandInput(call.input) - if (plainCommand?.trim() === fullCommand) { - commandDetail = plainCommand === target ? null : plainCommand - } - } - if (running) { - verb = translate('components.native-chat.tool.row.running', 'Running') - } else if (completed || result !== undefined || call.exitCode !== undefined) { - verb = translate('components.native-chat.tool.row.ran', 'Ran') - } - } else if (category === 'read' || category === 'fileChange') { - if (running) { - verb = - category === 'read' - ? translate('components.native-chat.tool.row.reading', 'Reading') - : translate('components.native-chat.tool.row.editing', 'Editing') - } else if (completed) { - verb = - category === 'read' - ? translate('components.native-chat.tool.row.read', 'Read') - : translate('components.native-chat.tool.row.edited', 'Edited') - } - if (!running && failed) { - verb = - category === 'read' - ? translate('components.native-chat.tool.row.triedRead', 'Tried to read') - : translate('components.native-chat.tool.row.triedEdit', 'Tried to edit') - } - target = filePath?.split(/[\\/]/).findLast((part) => part.length > 0) ?? target - } else if (category === 'subAgentActivity') { - verb = translate('components.native-chat.tool.row.subagent', 'Subagent') - target = nativeChatToolInputText(call.input, 'description') ?? target - title = target - } else if (category === 'search') { - if (running) { - verb = translate('components.native-chat.tool.row.searching', 'Searching') - } else if (completed) { - verb = translate('components.native-chat.tool.row.searched', 'Searched') - } else if (failed) { - verb = translate('components.native-chat.tool.row.triedSearch', 'Tried to search') - } - } else if (category === 'listFiles') { - if (running) { - verb = translate('components.native-chat.tool.row.listing', 'Listing') - } else if (completed) { - verb = translate('components.native-chat.tool.row.listed', 'Listed') - } else if (failed) { - verb = translate('components.native-chat.tool.row.triedList', 'Tried to list') - } - } else if (category === 'webSearch') { - if (call.name.trim().toLowerCase() === 'webfetch') { - const url = nativeChatToolInputText(call.input, 'url') ?? target - target = url.replace(/^[a-z][a-z\d+.-]*:\/\//i, '') - title = url - if (running) { - verb = translate('components.native-chat.tool.row.fetching', 'Fetching') - } else if (completed) { - verb = translate('components.native-chat.tool.row.fetched', 'Fetched') - } else if (failed) { - verb = translate('components.native-chat.tool.row.triedFetch', 'Tried to fetch') - } - } else if (running) { - verb = translate('components.native-chat.tool.row.searchingWeb', 'Searching the web') - } else if (completed) { - verb = translate('components.native-chat.tool.row.searchedWeb', 'Searched the web') - } else if (failed) { - verb = translate('components.native-chat.tool.row.triedSearchWeb', 'Tried to search the web') - } - } - return { verb, target, title, command, filePath, commandDetail, commandLanguage } +): Omit & { verb: string | null } { + const label = sharedToolLineLabel(call, result) + return { ...label, verb: label.verb === null ? null : nativeChatToolRowVerb(label.verb) } } diff --git a/src/renderer/src/components/native-chat/native-chat-tool-run-label.ts b/src/renderer/src/components/native-chat/native-chat-tool-run-label.ts index a14b3c2a93c..53030e2f997 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-run-label.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-run-label.ts @@ -1,9 +1,10 @@ import { translate } from '@/i18n/i18n' -import { joinNativeChatToolRunClauses } from '../../../../shared/native-chat-tool-run-sentence' -import { nativeChatToolRunClauses } from './native-chat-tool-category' +import { + nativeChatToolRunSentence as sharedRunSentence, + type NativeChatToolRunSentenceWords +} from '../../../../shared/native-chat-tool-run-sentence' import type { NativeChatToolCategory } from '../../../../shared/native-chat-tool-icon' -import { isToolCallBlock, type NativeChatBlock } from '../../../../shared/native-chat-types' -import { toolInputCommand } from '../../../../shared/native-chat-tool-summary' +import type { NativeChatBlock } from '../../../../shared/native-chat-types' /** One clause, localized. * @@ -174,41 +175,25 @@ function liveClause( } } +const LOCALIZED_WORDS: NativeChatToolRunSentenceWords = { + clause, + pair: (first, second) => + translate('components.native-chat.tool.runPair', '{{value0}} and {{value1}}', { + value0: first, + value1: second + }), + list: (leading, last) => + translate('components.native-chat.tool.runList', '{{value0}}, and {{value1}}', { + value0: leading, + value1: last + }) +} + /** The one line a run reads as, localized: past tense once settled, present * while live. */ export function nativeChatToolRunSentence( blocks: readonly NativeChatBlock[], { live = false }: { live?: boolean } = {} ): string | null { - const calls = blocks.filter(isToolCallBlock) - if (calls.length === 0) { - return null - } - // A lone shell call keeps its own command: `git push` identifies the work - // better than "Ran 1 command" can, and it is the row the reader would open - // anyway. A call carrying no command has nothing better than its category. - // Not while live: the command is then the preview beside the sentence, and a - // header that swapped shape when the second call arrived is the swap this row - // exists to avoid. - if (calls.length === 1 && !live) { - const command = toolInputCommand(calls[0].input) - if (command !== null && command.length > 0) { - return command - } - } - return joinNativeChatToolRunClauses( - nativeChatToolRunClauses(calls).map(({ category, count }) => clause(category, count, live)), - { - pair: (first, second) => - translate('components.native-chat.tool.runPair', '{{value0}} and {{value1}}', { - value0: first, - value1: second - }), - list: (leading, last) => - translate('components.native-chat.tool.runList', '{{value0}}, and {{value1}}', { - value0: leading, - value1: last - }) - } - ) + return sharedRunSentence(blocks, { live }, LOCALIZED_WORDS) } diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 8ae77d274cd..5d36695537c 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -11,23 +11,13 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' -import { AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION } from '../../../../shared/agent-session-compaction' -import { - isBackgroundTaskBlock, - isSubagentGroupBlock, - type NativeChatMessage -} from '../../../../shared/native-chat-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' import { isNativeChatRowInLiveWorkingTurn } from '../../../../shared/native-chat-turn-membership' import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../../shared/native-chat-messages-waiting-behind-live-turn' import { nativeChatTurnBarRows } from '../../../../shared/native-chat-turn-grouping' -import { - nativeChatTurnFold, - type NativeChatTurnFoldRow -} from '../../../../shared/native-chat-turn-fold' +import { nativeChatTurnFold, nativeChatTurnFoldRow } from '../../../../shared/native-chat-turn-fold' import { nativeChatRowRendersContent } from '../../../../shared/native-chat-row-content' -import { isStoppedBeforeStartBlock } from '../../../../shared/native-chat-stopped-before-start' import { type NativeChatRowTypography, estimateNativeChatRowHeight, @@ -48,7 +38,7 @@ import { type NativeChatSubagentEntriesSlot, type NativeChatSubagentSectionSlot } from './native-chat-subagent-section-slots' -import { nativeChatRowRendersProse, nativeChatRowSpeaksOrActs } from './native-chat-trailing-run' +import { nativeChatRowSpeaksOrActs } from '../../../../shared/native-chat-trailing-run' import { nativeChatTranscriptWorkRuns } from './native-chat-transcript-work-runs' export type NativeChatTranscriptSlot = @@ -140,34 +130,9 @@ export function buildNativeChatTranscriptSlots( } = input // One pass to decide what each row draws, then the fold over those readings — // so "is this the answer" and "does this row render prose" cannot disagree. - const foldRows: NativeChatTurnFoldRow[] = messages.map((message, index) => ({ - turnKey: turnKeys[index], - role: message.role, - rendersProse: nativeChatRowRendersProse(message), - draws: receipts.has(message.id) || nativeChatRowRendersContent(message.blocks), - // The raw blocks, not the renderable ones: a childless roster draws no row - // and its plain-text twin is then the only record the spawn happened. - outlivesTurn: message.blocks.some( - (block) => - isSubagentGroupBlock(block) || - isBackgroundTaskBlock(block) || - isStoppedBeforeStartBlock(block) - ), - // A row about Orca's own stop is stored red for clients that predate it; it reports no failure. - reportsFailure: message.blocks.some( - (block) => - block.type === 'text' && - block.tone === 'error' && - block.presentation !== AGENT_SESSION_ORCA_STOP_PRESENTATION - ), - explainsTurn: message.blocks.some( - (block) => - block.type === 'text' && - (block.presentation === 'compaction' || - block.presentation === AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION || - block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION) - ) - })) + const foldRows = messages.map((message, index) => + nativeChatTurnFoldRow(message, turnKeys[index], receipts.has(message.id)) + ) // Liveness is the turn's, not any one call's: the run at the frontier stays // live between its calls, and a run the agent has moved past is settled even // while its last call is still reporting. diff --git a/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts b/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts index 4a5cc07704b..b18d78f8917 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts @@ -2,7 +2,7 @@ import { MAX_EDIT_LINES } from '../../../../shared/native-chat-edit-model' import { describe, expect, it } from 'vitest' import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' import { foldToolMessages } from './native-chat-tool-fold' -import { buildDiffSummaries, buildEditCards } from './native-chat-edit-cards' +import { buildDiffSummaries, buildEditCards } from '../../../../shared/native-chat-edit-cards' import { nativeChatTurnDiffEditFile, nativeChatTurnDiffs } from './native-chat-turn-diffs' function diff(id: string, path: string, patch = '@@ -1 +1 @@\n-old\n+new'): NativeChatMessage { diff --git a/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts b/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts index cbb0d1007bc..2b6c08ddcab 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts +++ b/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts @@ -4,7 +4,10 @@ import type { NativeChatToolCallBlock, NativeChatToolResultBlock } from '../../../../shared/native-chat-types' -import { buildDiffSummaries, normalizedToolEditFiles } from './native-chat-edit-cards' +import { + buildDiffSummaries, + normalizedToolEditFiles +} from '../../../../shared/native-chat-edit-cards' export type NativeChatDiffTarget = { messageId: string diff --git a/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts b/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts index 5f1d1d194c1..c906aadb415 100644 --- a/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-work-run-pairing.test.ts @@ -7,7 +7,7 @@ import { nativeChatWorkRunEntries } from '../../../../shared/native-chat-work-run' import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' -import { buildEditCards } from './native-chat-edit-cards' +import { buildEditCards } from '../../../../shared/native-chat-edit-cards' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' function item( diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-projection.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-projection.ts index b58446b222c..0aa661cbe6d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-projection.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-projection.ts @@ -7,7 +7,7 @@ import type { NativeChatSubagentRow } from '../../../../shared/native-chat-trans import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' -import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' +import { projectNativeChatTaskListFrames } from '../../../../shared/native-chat-task-list-frames' import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' import type { NativeChatLiveSession } from './use-native-chat-live-session' diff --git a/src/renderer/src/components/native-chat/native-chat-command-language.test.ts b/src/shared/native-chat-command-language.test.ts similarity index 100% rename from src/renderer/src/components/native-chat/native-chat-command-language.test.ts rename to src/shared/native-chat-command-language.test.ts diff --git a/src/renderer/src/components/native-chat/native-chat-command-language.ts b/src/shared/native-chat-command-language.ts similarity index 100% rename from src/renderer/src/components/native-chat/native-chat-command-language.ts rename to src/shared/native-chat-command-language.ts diff --git a/src/renderer/src/components/native-chat/native-chat-edit-cards.ts b/src/shared/native-chat-edit-cards.ts similarity index 82% rename from src/renderer/src/components/native-chat/native-chat-edit-cards.ts rename to src/shared/native-chat-edit-cards.ts index 8a42b072fe8..7afd4f59816 100644 --- a/src/renderer/src/components/native-chat/native-chat-edit-cards.ts +++ b/src/shared/native-chat-edit-cards.ts @@ -2,18 +2,30 @@ import type { NativeChatBlock, NativeChatToolCallBlock, NativeChatToolResultBlock -} from '../../../../shared/native-chat-types' -import { - editFilesFromToolPair, - isEditToolName -} from '../../../../shared/native-chat-edit-normalize' -import type { NativeChatEditFile } from '../../../../shared/native-chat-edit-model' +} from './native-chat-types' +import { editFilesFromToolPair, isEditToolName } from './native-chat-edit-normalize' +import type { NativeChatEditFile, NativeChatEditLine } from './native-chat-edit-model' import { editFilesFromPatchText, type NativeChatEditFileSummary -} from '../../../../shared/native-chat-edit-patch-files' +} from './native-chat-edit-patch-files' import { pairToolBlocks } from './native-chat-tool-fold' +/** A diff card's own words. Desktop maps these onto `translate`; mobile renders them. */ +export const NATIVE_CHAT_DIFF_CARD_COPY = { + diffTruncated: 'Diff truncated', + diffGap: 'Lines not shown', + copyDiff: 'Copy diff' +} as const + +/** A card's rows as unified patch text, for its copy button. */ +export function nativeChatEditFilePatchText(lines: readonly NativeChatEditLine[]): string { + return lines + .filter((line) => line.kind !== 'gap') + .map((line) => `${line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}${line.text}`) + .join('\n') +} + const normalizedEdits = new WeakMap< NativeChatBlock, { diff --git a/src/shared/native-chat-edit-patch-files.ts b/src/shared/native-chat-edit-patch-files.ts index b0bd101338d..26480ac21ec 100644 --- a/src/shared/native-chat-edit-patch-files.ts +++ b/src/shared/native-chat-edit-patch-files.ts @@ -9,6 +9,11 @@ import { const FILE_COUNT_PATH = /^\d+ files?$/ +/** Codex reports a change to several files under the path `N files`, which names no file. */ +export function isNativeChatFileCountPath(path: string): boolean { + return FILE_COUNT_PATH.test(path) +} + /** A move is appended to the patch body as prose rather than a header field, on * every lane that carries the body as text. Left in place it renders as a * numbered line of the file it moved. @@ -44,7 +49,7 @@ export function editFilesFromPatchText( callerPath: string | null, summaryOnly = false ): NativeChatEditFileSummary[] | null { - if (callerPath !== null && FILE_COUNT_PATH.test(callerPath)) { + if (callerPath !== null && isNativeChatFileCountPath(callerPath)) { // A file count cannot name a card, regardless of the patch contents. return null } diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts b/src/shared/native-chat-task-list-frames.ts similarity index 87% rename from src/renderer/src/components/native-chat/native-chat-task-list-frames.ts rename to src/shared/native-chat-task-list-frames.ts index f58c4a2bbdd..fb8a9a57483 100644 --- a/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts +++ b/src/shared/native-chat-task-list-frames.ts @@ -1,5 +1,5 @@ -import { normalizeNativeChatTaskList } from '../../../../shared/native-chat-task-list' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { normalizeNativeChatTaskList } from './native-chat-task-list' +import type { NativeChatMessage } from './native-chat-types' const projectedFrames = new WeakMap() diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts b/src/shared/native-chat-task-list-history.test.ts similarity index 98% rename from src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts rename to src/shared/native-chat-task-list-history.test.ts index ef8eae291ad..f2e71c8865f 100644 --- a/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts +++ b/src/shared/native-chat-task-list-history.test.ts @@ -3,7 +3,7 @@ import type { NativeChatBlock, NativeChatMessage, NativeChatToolCallBlock -} from '../../../../shared/native-chat-types' +} from './native-chat-types' import { buildNativeChatTaskListRows, nativeChatTaskListPredecessors diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-history.ts b/src/shared/native-chat-task-list-history.ts similarity index 96% rename from src/renderer/src/components/native-chat/native-chat-task-list-history.ts rename to src/shared/native-chat-task-list-history.ts index 62582e2a7cf..0cec64dc85f 100644 --- a/src/renderer/src/components/native-chat/native-chat-task-list-history.ts +++ b/src/shared/native-chat-task-list-history.ts @@ -3,12 +3,12 @@ import { normalizeNativeChatTaskList, type NativeChatTaskList, type NativeChatTaskListTool -} from '../../../../shared/native-chat-task-list' +} from './native-chat-task-list' import type { NativeChatBlock, NativeChatMessage, NativeChatToolCallBlock -} from '../../../../shared/native-chat-types' +} from './native-chat-types' import { pairToolBlocks } from './native-chat-tool-fold' export type NativeChatTaskListPredecessors = Partial< diff --git a/src/shared/native-chat-task-list.ts b/src/shared/native-chat-task-list.ts index 4d4d470b246..a5829aa6822 100644 --- a/src/shared/native-chat-task-list.ts +++ b/src/shared/native-chat-task-list.ts @@ -69,6 +69,51 @@ export function normalizeNativeChatTaskList( return { tasks, ...(explanation ? { explanation } : {}) } } +/** A task list's words. Desktop maps these onto `translate`; mobile renders them. */ +export const NATIVE_CHAT_TASK_LIST_COPY = { + title: 'Tasks', + completed: 'Completed', + inProgress: 'In progress', + pending: 'Pending', + empty: 'No tasks', + progress: '{{completed}} of {{total}} tasks completed', + added: 'Added {{task}}', + removed: 'Removed {{task}}', + started: 'Started {{task}}', + finished: 'Completed {{task}}', + reset: 'Marked pending: {{task}}', + updated: 'Updated {{task}}', + unchanged: 'Tasks unchanged', + showAll: 'Full task list' +} as const + +export type NativeChatTaskChangeCopyKey = + | 'added' + | 'removed' + | 'started' + | 'finished' + | 'reset' + | 'updated' + +/** Which sentence a change reads as, and the task it names. */ +export function nativeChatTaskChangeWords(change: NativeChatTaskChange): { + key: NativeChatTaskChangeCopyKey + task: string +} { + switch (change.kind) { + case 'added': + case 'removed': + case 'started': + return { key: change.kind, task: change.task.content } + case 'completed': + return { key: 'finished', task: change.task.content } + case 'pending': + return { key: 'reset', task: change.task.content } + case 'updated': + return { key: 'updated', task: nativeChatTaskLabel(change.task) } + } +} + export function nativeChatTaskLabel(task: NativeChatTask): string { return task.status === 'in_progress' && task.activeForm ? task.activeForm : task.content } diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts index 1ea21e0c8de..623ecf86550 100644 --- a/src/shared/native-chat-tool-fold.ts +++ b/src/shared/native-chat-tool-fold.ts @@ -55,7 +55,7 @@ function isBackgroundTaskMessage(message: NativeChatMessage): boolean { /** A stored-only provider event draws nothing, so it must not split the run around it. A plan * update still does: the desktop draws it in place as the task list. */ -function isUndrawnProviderFrameMessage(message: NativeChatMessage): boolean { +export function isUndrawnProviderFrameMessage(message: NativeChatMessage): boolean { return ( isWordlessProviderFrameMessage(message) && !message.blocks.some( diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts index 0a34703e36e..c64e50fc9e0 100644 --- a/src/shared/native-chat-tool-icon.ts +++ b/src/shared/native-chat-tool-icon.ts @@ -87,6 +87,8 @@ const CATEGORY_BY_ROW_WORD = new Map([ // Claude's Task call lasts as long as its subagent. Codex's `spawn_agent` ends once the helper // starts, so it is deliberately absent: its roster row, not the call, stands for the helper. ['task', 'subAgentActivity'], + // Claude's transcript alias of Task. + ['agent', 'subAgentActivity'], ['webfetch', 'webSearch'], ['todowrite', 'todoList'], ['update_plan', 'todoList'], diff --git a/src/renderer/src/components/native-chat/native-chat-tool-input-text.ts b/src/shared/native-chat-tool-input-text.ts similarity index 91% rename from src/renderer/src/components/native-chat/native-chat-tool-input-text.ts rename to src/shared/native-chat-tool-input-text.ts index 1aae4f47b0b..ff175a8f3f4 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-input-text.ts +++ b/src/shared/native-chat-tool-input-text.ts @@ -1,5 +1,5 @@ -import { unwrapLoginShellCommand } from '../../../../shared/native-chat-tool-preview-prefix' -import { normalizeToolInput } from '../../../../shared/native-chat-tool-summary' +import { unwrapLoginShellCommand } from './native-chat-tool-preview-prefix' +import { normalizeToolInput } from './native-chat-tool-summary' type NativeChatTextInputKey = 'command' | 'cmd' | 'description' | 'url' type NativeChatTextInput = Partial> diff --git a/src/shared/native-chat-tool-line-identity.test.ts b/src/shared/native-chat-tool-line-identity.test.ts index 83eadc14a06..8d8c3a5465f 100644 --- a/src/shared/native-chat-tool-line-identity.test.ts +++ b/src/shared/native-chat-tool-line-identity.test.ts @@ -1,23 +1,30 @@ import { expect, it } from 'vitest' -import type { NativeChatToolCallBlock } from './native-chat-types' +import type { NativeChatToolCallBlock, NativeChatToolResultBlock } from './native-chat-types' import { nativeChatToolLineIdentity } from './native-chat-tool-line-identity' -it('uses object identity for anonymous mobile rows without serializing large input', () => { +it('keys an anonymous mobile row by a bounded summary, so a refetched copy keeps it', () => { const input = { + command: 'ls', toJSON: () => { throw new Error('Input must stay unread.') } } - const block: NativeChatToolCallBlock = { type: 'tool-call', name: 'Read', input } + const block: NativeChatToolCallBlock = { type: 'tool-call', name: 'Bash', input } const original = nativeChatToolLineIdentity(block, new Map(), true) - expect(nativeChatToolLineIdentity(block, new Map(), true)).toBe(original) - expect(nativeChatToolLineIdentity({ ...block }, new Map(), true)).not.toBe(original) + expect(nativeChatToolLineIdentity({ ...block }, new Map(), true)).toBe(original) const seen = new Map() expect(nativeChatToolLineIdentity(block, seen, true)).not.toBe( nativeChatToolLineIdentity(block, seen, true) ) }) +it('keeps a named mobile result through an output update', () => { + const result: NativeChatToolResultBlock = { type: 'tool-result', callId: 'gone', output: 'a' } + expect(nativeChatToolLineIdentity({ ...result, output: 'a\nb' }, new Map(), true)).toBe( + nativeChatToolLineIdentity(result, new Map(), true) + ) +}) + it('preserves named desktop identity and independent duplicate occurrences', () => { const block: NativeChatToolCallBlock = { type: 'tool-call', diff --git a/src/shared/native-chat-tool-line-identity.ts b/src/shared/native-chat-tool-line-identity.ts index 352f966a9d8..d210e369048 100644 --- a/src/shared/native-chat-tool-line-identity.ts +++ b/src/shared/native-chat-tool-line-identity.ts @@ -1,36 +1,32 @@ import type { NativeChatBlock } from './native-chat-types' +import { summarizeToolInput } from './native-chat-tool-summary' -const anonymousRows = new WeakMap() -let nextAnonymousRow = 0 +/** How much of an anonymous result's output identifies it, so a long log is never hashed whole. */ +const RESULT_SIGNATURE_LENGTH = 160 -/** Each repeated provider ID needs a separate row, while unrelated rows may move freely. */ +/** Each repeated provider ID needs a separate row, while unrelated rows may move freely. + * `bounded` (mobile): a result keeps its call's ID through output updates, and a row with no + * ID is keyed by a bounded summary rather than its whole serialized input, so a refetched copy + * of the same row keeps its identity without paying for a large payload. */ export function nativeChatToolLineIdentity( block: NativeChatBlock, seen: Map, - preserveAnonymousObject = false + bounded = false ): string { const providerCallId = - (block.type === 'tool-call' || (preserveAnonymousObject && block.type === 'tool-result')) && + (block.type === 'tool-call' || (bounded && block.type === 'tool-result')) && block.callId !== undefined && block.callId.trim().length > 0 ? block.callId : undefined - if (providerCallId === undefined && preserveAnonymousObject) { - const id = anonymousRows.get(block) ?? nextAnonymousRow++ - anonymousRows.set(block, id) - const signature = `anonymous:${id}` - const occurrence = seen.get(signature) ?? 0 - seen.set(signature, occurrence + 1) - return `${signature}:${occurrence}` - } const providerKind = block.type === 'tool-result' ? 'result' : 'call' const signature = providerCallId !== undefined ? `${providerKind}:${providerCallId}` : block.type === 'tool-call' - ? `${block.type}:${block.name}:${JSON.stringify(block.input)}` + ? `${block.type}:${block.name}:${bounded ? summarizeToolInput(block.input) : JSON.stringify(block.input)}` : block.type === 'tool-result' - ? `${block.type}:${block.output}` + ? `${block.type}:${bounded ? block.output.slice(0, RESULT_SIGNATURE_LENGTH) : block.output}` : `${block.type}` const occurrence = seen.get(signature) ?? 0 seen.set(signature, occurrence + 1) diff --git a/src/shared/native-chat-tool-line-label.test.ts b/src/shared/native-chat-tool-line-label.test.ts new file mode 100644 index 00000000000..bfc1aed6e58 --- /dev/null +++ b/src/shared/native-chat-tool-line-label.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { nativeChatToolLineLabel } from './native-chat-tool-line-label' + +describe('nativeChatToolLineLabel', () => { + it('names a Codex change to several files by its count, and offers no file to open', () => { + const label = nativeChatToolLineLabel( + { type: 'tool-call', name: 'Diff', input: { path: '2 files' }, state: 'completed' }, + { type: 'tool-result', output: '@@ -1 +1 @@\n-a\n+b' } + ) + expect(label).toMatchObject({ verb: 'edited', target: '2 files', filePath: null }) + }) + + it('still links a real file whose name reads like a count', () => { + const label = nativeChatToolLineLabel({ + type: 'tool-call', + name: 'Read', + input: { file_path: '2 files' }, + state: 'completed' + }) + expect(label.filePath).toBe('2 files') + }) + + it('links a read or an edit, never a listed folder', () => { + const read = nativeChatToolLineLabel({ + type: 'tool-call', + name: 'Read', + input: { file_path: 'src/a.ts' }, + state: 'completed' + }) + const list = nativeChatToolLineLabel({ + type: 'tool-call', + name: 'list', + input: { command: 'ls src', path: 'src' }, + state: 'completed' + }) + expect(read).toMatchObject({ verb: 'read', target: 'a.ts', filePath: 'src/a.ts' }) + expect(list).toMatchObject({ verb: 'listed', filePath: null }) + }) +}) diff --git a/src/shared/native-chat-tool-line-label.ts b/src/shared/native-chat-tool-line-label.ts new file mode 100644 index 00000000000..27352feafaa --- /dev/null +++ b/src/shared/native-chat-tool-line-label.ts @@ -0,0 +1,146 @@ +import { nativeChatCommandLanguage } from './native-chat-command-language' +import { isNativeChatFileCountPath } from './native-chat-edit-patch-files' +import { nativeChatToolCategory } from './native-chat-tool-icon' +import { + nativeChatFullCommand, + nativeChatPlainCommandInput, + nativeChatToolInputText +} from './native-chat-tool-input-text' +import { createToolInputDisplay, summarizeToolInput } from './native-chat-tool-summary' +import type { NativeChatToolCallBlock, NativeChatToolResultBlock } from './native-chat-types' + +/** A tool row's verb. Desktop maps these onto `translate`; mobile renders the + * English record, so both surfaces read one vocabulary. */ +export const NATIVE_CHAT_TOOL_ROW_VERB_COPY = { + running: 'Running', + ran: 'Ran', + reading: 'Reading', + read: 'Read', + triedRead: 'Tried to read', + editing: 'Editing', + edited: 'Edited', + triedEdit: 'Tried to edit', + added: 'Added', + deleted: 'Deleted', + renamed: 'Renamed', + subagent: 'Subagent', + searching: 'Searching', + searched: 'Searched', + triedSearch: 'Tried to search', + listing: 'Listing', + listed: 'Listed', + triedList: 'Tried to list', + fetching: 'Fetching', + fetched: 'Fetched', + triedFetch: 'Tried to fetch', + searchingWeb: 'Searching the web', + searchedWeb: 'Searched the web', + triedSearchWeb: 'Tried to search the web' +} as const + +export type NativeChatToolRowVerb = keyof typeof NATIVE_CHAT_TOOL_ROW_VERB_COPY + +export type NativeChatToolLineLabel = { + verb: NativeChatToolRowVerb | null + target: string + title: string + command: boolean + filePath: string | null + /** The whole command, when the row had to shorten it. */ + commandDetail: string | null + /** Grammar of `commandDetail`; null when the row holds no command text. */ + commandLanguage: string | null +} + +function phaseVerb( + phase: { running: boolean; completed: boolean; failed: boolean }, + verbs: { running: T; completed: T; failed: T } +): T | null { + if (phase.running) { + return verbs.running + } + if (phase.completed) { + return verbs.completed + } + return phase.failed ? verbs.failed : null +} + +/** What a tool row says: the verb for its category and state, and the target it names. */ +export function nativeChatToolLineLabel( + call: NativeChatToolCallBlock, + result?: NativeChatToolResultBlock +): NativeChatToolLineLabel { + const display = createToolInputDisplay(call.input) + const category = nativeChatToolCategory(call.name, call.mcpIdentity) + const command = category === 'unknown' + const running = call.state === 'running' + const completed = + call.state !== 'failed' && + !result?.isError && + (call.state === 'completed' || result !== undefined) + const failed = call.state === 'failed' || result?.isError === true + const phase = { running, completed, failed } + let commandDetail: string | null = null + let commandLanguage: string | null = null + let verb: NativeChatToolRowVerb | null = null + let target = display.label + let title = display.filePath ?? target + // Only a read or an edit targets a file. Codex names a change to several files `N files` + // under its Diff call, which names none to open. + const filePath = + (category === 'read' || category === 'fileChange') && + display.filePath !== null && + !(call.name === 'Diff' && isNativeChatFileCountPath(display.filePath)) + ? display.filePath + : null + + if (command) { + const fullCommand = nativeChatFullCommand(call.input) + target = fullCommand === null ? target : summarizeToolInput(fullCommand) + title = fullCommand ?? target + if (fullCommand !== null) { + commandLanguage = nativeChatCommandLanguage(call.name, call.input) + const plainCommand = nativeChatPlainCommandInput(call.input) + if (plainCommand?.trim() === fullCommand) { + commandDetail = plainCommand === target ? null : plainCommand + } + } + if (running) { + verb = 'running' + } else if (completed || result !== undefined || call.exitCode !== undefined) { + verb = 'ran' + } + } else if (category === 'read' || category === 'fileChange') { + const read = category === 'read' + if (running) { + verb = read ? 'reading' : 'editing' + } else if (failed) { + verb = read ? 'triedRead' : 'triedEdit' + } else if (completed) { + verb = read ? 'read' : 'edited' + } + target = filePath?.split(/[\\/]/).findLast((part) => part.length > 0) ?? target + } else if (category === 'subAgentActivity') { + verb = 'subagent' + target = nativeChatToolInputText(call.input, 'description') ?? target + title = target + } else if (category === 'search') { + verb = phaseVerb(phase, { running: 'searching', completed: 'searched', failed: 'triedSearch' }) + } else if (category === 'listFiles') { + verb = phaseVerb(phase, { running: 'listing', completed: 'listed', failed: 'triedList' }) + } else if (category === 'webSearch') { + if (call.name.trim().toLowerCase() === 'webfetch') { + const url = nativeChatToolInputText(call.input, 'url') ?? target + target = url.replace(/^[a-z][a-z\d+.-]*:\/\//i, '') + title = url + verb = phaseVerb(phase, { running: 'fetching', completed: 'fetched', failed: 'triedFetch' }) + } else { + verb = phaseVerb(phase, { + running: 'searchingWeb', + completed: 'searchedWeb', + failed: 'triedSearchWeb' + }) + } + } + return { verb, target, title, command, filePath, commandDetail, commandLanguage } +} diff --git a/src/shared/native-chat-tool-run-sentence.test.ts b/src/shared/native-chat-tool-run-sentence.test.ts index 0b75ba6ed92..90cf7b3eb77 100644 --- a/src/shared/native-chat-tool-run-sentence.test.ts +++ b/src/shared/native-chat-tool-run-sentence.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { - formatNativeChatToolRunSentence, - nativeChatToolRunClauses + NATIVE_CHAT_TOOL_RUN_SENTENCE_ENGLISH, + nativeChatToolRunClauses, + nativeChatToolRunSentence } from './native-chat-tool-run-sentence' const read = { name: 'read' } @@ -31,33 +32,53 @@ describe('nativeChatToolRunClauses', () => { }) }) -describe('formatNativeChatToolRunSentence', () => { +function english(calls: { name: string; input?: unknown }[], live = false): string | null { + return nativeChatToolRunSentence( + calls.map((call) => ({ type: 'tool-call', name: call.name, input: call.input ?? {} })), + { live }, + NATIVE_CHAT_TOOL_RUN_SENTENCE_ENGLISH + ) +} + +describe('nativeChatToolRunSentence in English', () => { it('renders one clause on its own', () => { - expect(formatNativeChatToolRunSentence([shell, shell])).toBe('Ran 2 commands') + expect(english([shell, shell])).toBe('Ran 2 commands') }) it('joins two clauses with "and" and no comma', () => { - expect(formatNativeChatToolRunSentence([shell, shell, read])).toBe( - 'Ran 2 commands and read 1 file' - ) + expect(english([shell, shell, read])).toBe('Ran 2 commands and read 1 file') }) it('joins three or more with commas and a final "and"', () => { - expect(formatNativeChatToolRunSentence([read, read, shell, search, edit])).toBe( + expect(english([read, read, shell, search, edit])).toBe( 'Read 2 files, ran 1 command, searched 1 time, and edited 1 file' ) }) it('capitalizes only the first clause, so the rest continue the sentence', () => { - expect(formatNativeChatToolRunSentence([read, shell])).toBe('Read 1 file and ran 1 command') + expect(english([read, shell])).toBe('Read 1 file and ran 1 command') }) it('uses the singular form for a count of one', () => { - expect(formatNativeChatToolRunSentence([read])).toBe('Read 1 file') - expect(formatNativeChatToolRunSentence([search])).toBe('Searched 1 time') + expect(english([read])).toBe('Read 1 file') + expect(english([search])).toBe('Searched 1 time') + }) + + it('names a lone settled command by the command itself, but not while live', () => { + const push = { name: 'Bash', input: { command: 'git push' } } + expect(english([push])).toBe('git push') + expect(english([push], true)).toBe('Running 1 command') + }) + + it('speaks in the present tense while live', () => { + expect(english([read, read, shell], true)).toBe('Reading 2 files and running 1 command') + }) + + it("counts Claude's Agent alias as a subagent", () => { + expect(english([{ name: 'Agent' }, { name: 'Task' }])).toBe('Ran 2 agents') }) it('renders nothing for a run with no calls', () => { - expect(formatNativeChatToolRunSentence([])).toBe('') + expect(english([])).toBeNull() }) }) diff --git a/src/shared/native-chat-tool-run-sentence.ts b/src/shared/native-chat-tool-run-sentence.ts index f45aba4d85b..55edaa4e18f 100644 --- a/src/shared/native-chat-tool-run-sentence.ts +++ b/src/shared/native-chat-tool-run-sentence.ts @@ -12,6 +12,8 @@ import { nativeChatToolCategory, type NativeChatToolCategory } from './native-chat-tool-icon' import type { NativeChatMcpIdentity } from './native-chat-tool-identity' +import { toolInputCommand } from './native-chat-tool-summary' +import { isToolCallBlock, type NativeChatBlock } from './native-chat-types' /** Singular/plural per category. Desktop maps these onto `translate`; mobile * formats them directly, so both surfaces read one vocabulary. */ @@ -31,6 +33,24 @@ export const NATIVE_CHAT_TOOL_RUN_SENTENCE_COPY: Record< other: { one: 'Used 1 tool', many: 'Used {{value0}} tools' } } +/** The present-tense clauses, while the run is still live. Same shape as the + * settled ones so the header's text changes in place and nothing else does. */ +export const NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY: Record< + NativeChatToolCategory, + { one: string; many: string } +> = { + read: { one: 'Reading 1 file', many: 'Reading {{value0}} files' }, + search: { one: 'Searching 1 time', many: 'Searching {{value0}} times' }, + listFiles: { one: 'Listing 1 directory', many: 'Listing {{value0}} directories' }, + unknown: { one: 'Running 1 command', many: 'Running {{value0}} commands' }, + fileChange: { one: 'Editing 1 file', many: 'Editing {{value0}} files' }, + webSearch: { one: 'Searching the web 1 time', many: 'Searching the web {{value0}} times' }, + mcpToolCall: { one: 'Using 1 integration', many: 'Using {{value0}} integrations' }, + subAgentActivity: { one: 'Running 1 agent', many: 'Running {{value0}} agents' }, + todoList: { one: 'Updating the plan', many: 'Updating the plan {{value0}} times' }, + other: { one: 'Using 1 tool', many: 'Using {{value0}} tools' } +} + export const NATIVE_CHAT_TOOL_RUN_SENTENCE_JOINERS = { /** Exactly two clauses. */ pair: '{{value0}} and {{value1}}', @@ -84,22 +104,57 @@ export function joinNativeChatToolRunClauses( return format.list(continued.slice(0, -1).join(', '), continued.at(-1) ?? '') } -/** The sentence in English. For platforms without i18n (mobile). */ -export function formatNativeChatToolRunSentence( - calls: readonly { name: string; mcpIdentity?: NativeChatMcpIdentity }[] -): string { - const rendered = nativeChatToolRunClauses(calls).map(({ category, count }) => { - const copy = NATIVE_CHAT_TOOL_RUN_SENTENCE_COPY[category] - return count === 1 ? copy.one : copy.many.replaceAll('{{value0}}', String(count)) - }) - return joinNativeChatToolRunClauses(rendered, { - pair: (first, second) => - NATIVE_CHAT_TOOL_RUN_SENTENCE_JOINERS.pair - .replaceAll('{{value0}}', first) - .replaceAll('{{value1}}', second), - list: (leading, last) => - NATIVE_CHAT_TOOL_RUN_SENTENCE_JOINERS.list - .replaceAll('{{value0}}', leading) - .replaceAll('{{value1}}', last) - }) +export type NativeChatToolRunSentenceWords = { + clause: (category: NativeChatToolCategory, count: number, live: boolean) => string + pair: (first: string, second: string) => string + list: (leading: string, last: string) => string +} + +/** The one line a run reads as: past tense once settled, present while live. */ +export function nativeChatToolRunSentence( + blocks: readonly NativeChatBlock[], + { live = false }: { live?: boolean }, + words: NativeChatToolRunSentenceWords +): string | null { + const calls = blocks.filter(isToolCallBlock) + if (calls.length === 0) { + return null + } + // A lone shell call keeps its own command: `git push` identifies the work + // better than "Ran 1 command" can, and it is the row the reader would open + // anyway. A call carrying no command has nothing better than its category. + // Not while live: the command is then the preview beside the sentence, and a + // header that swapped shape when the second call arrived is the swap this row + // exists to avoid. + if (calls.length === 1 && !live) { + const command = toolInputCommand(calls[0].input) + if (command !== null && command.length > 0) { + return command + } + } + return joinNativeChatToolRunClauses( + nativeChatToolRunClauses(calls).map(({ category, count }) => + words.clause(category, count, live) + ), + words + ) +} + +function fill(template: string, values: readonly string[]): string { + return values.reduce( + (text, value, index) => text.replaceAll(`{{value${index}}}`, value), + template + ) +} + +/** The sentence's words in English. For platforms without i18n (mobile). */ +export const NATIVE_CHAT_TOOL_RUN_SENTENCE_ENGLISH: NativeChatToolRunSentenceWords = { + clause: (category, count, live) => { + const copy = ( + live ? NATIVE_CHAT_TOOL_RUN_LIVE_SENTENCE_COPY : NATIVE_CHAT_TOOL_RUN_SENTENCE_COPY + )[category] + return count === 1 ? copy.one : fill(copy.many, [String(count)]) + }, + pair: (first, second) => fill(NATIVE_CHAT_TOOL_RUN_SENTENCE_JOINERS.pair, [first, second]), + list: (leading, last) => fill(NATIVE_CHAT_TOOL_RUN_SENTENCE_JOINERS.list, [leading, last]) } diff --git a/src/renderer/src/components/native-chat/native-chat-trailing-run.ts b/src/shared/native-chat-trailing-run.ts similarity index 75% rename from src/renderer/src/components/native-chat/native-chat-trailing-run.ts rename to src/shared/native-chat-trailing-run.ts index 89d3d7c8744..0eb8bb9804a 100644 --- a/src/renderer/src/components/native-chat/native-chat-trailing-run.ts +++ b/src/shared/native-chat-trailing-run.ts @@ -1,9 +1,9 @@ // Which of an agent's rows is its live frontier: the last one where it spoke or acted. // A tool run stays live until a later row of the same agent moves past it. -import { isToolCallBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' -import { deriveNativeChatRowContent } from '../../../../shared/native-chat-row-content' -import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat-resolution-receipt' +import { isToolCallBlock, type NativeChatMessage } from './native-chat-types' +import { deriveNativeChatRowContent } from './native-chat-row-content' +import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt' /** Whether a row moves its agent past the run above it. An approval's receipt * decides a call of that run, which then runs, so it does not. */ diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index 59687f3d789..0e0b3aa1358 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -6,7 +6,17 @@ // derived rather than read — last candidate wins. So a failed turn ends on its // error, and an error the agent recovered from folds behind the answer after it. -import type { NativeChatRole } from './native-chat-types' +import { AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION } from './agent-session-compaction' +import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from './agent-session-orca-stop' +import { nativeChatRowRendersContent } from './native-chat-row-content' +import { isStoppedBeforeStartBlock } from './native-chat-stopped-before-start' +import { nativeChatRowRendersProse } from './native-chat-trailing-run' +import { + isBackgroundTaskBlock, + isSubagentGroupBlock, + type NativeChatMessage, + type NativeChatRole +} from './native-chat-types' /** What the fold needs to know about one transcript row. Deliberately not a * `NativeChatMessage`: each surface derives "renders prose" through its own @@ -33,6 +43,42 @@ export type NativeChatTurnFoldRow = { explainsTurn: boolean } +/** How the fold reads a transcript row, with the same derivation the row renders from. */ +export function nativeChatTurnFoldRow( + message: NativeChatMessage, + turnKey: string | undefined, + hasReceipt: boolean +): NativeChatTurnFoldRow { + return { + turnKey, + role: message.role, + rendersProse: nativeChatRowRendersProse(message), + draws: hasReceipt || nativeChatRowRendersContent(message.blocks), + // The raw blocks, not the renderable ones: a childless roster draws no row + // and its plain-text twin is then the only record the spawn happened. + outlivesTurn: message.blocks.some( + (block) => + isSubagentGroupBlock(block) || + isBackgroundTaskBlock(block) || + isStoppedBeforeStartBlock(block) + ), + // A row about Orca's own stop is stored red for clients that predate it; it reports no failure. + reportsFailure: message.blocks.some( + (block) => + block.type === 'text' && + block.tone === 'error' && + block.presentation !== AGENT_SESSION_ORCA_STOP_PRESENTATION + ), + explainsTurn: message.blocks.some( + (block) => + block.type === 'text' && + (block.presentation === 'compaction' || + block.presentation === AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION || + block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION) + ) + } +} + export type NativeChatTurnFold = { /** Rows a folded turn must not draw, by index. */ foldedRows: ReadonlySet