From 12f3089b9c07d91653cb445a0b194de8629421f3 Mon Sep 17 00:00:00 2001
From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:48:36 +0000
Subject: [PATCH] feat(mobile): draw chat tool runs the way desktop does
(#30135)
---
.../src/session/MobileNativeChatDiffCard.tsx | 207 ++++++
.../MobileNativeChatMessage.android.test.ts | 17 +-
...eNativeChatMessage.subagent-group.test.tsx | 12 +
.../session/MobileNativeChatMessage.test.ts | 247 ++-----
...tMessage.tool-result-availability.test.tsx | 124 ++--
.../src/session/MobileNativeChatMessage.tsx | 110 +++-
.../MobileNativeChatMessage.work-run.test.tsx | 174 +++++
.../src/session/MobileNativeChatTaskList.tsx | 176 +++++
.../src/session/MobileNativeChatToolIcon.tsx | 46 ++
.../src/session/MobileNativeChatToolLine.tsx | 261 ++++++++
.../MobileNativeChatToolRun.identity.test.tsx | 40 +-
.../session/MobileNativeChatToolRun.test.tsx | 600 +++++++++++++++++
.../src/session/MobileNativeChatToolRun.tsx | 616 +++++++++++-------
mobile/src/session/MobileNativeChatView.tsx | 78 ++-
mobile/src/session/mobile-file-syntax.ts | 1 +
.../mobile-native-chat-disclosure-store.ts | 92 +++
.../mobile-native-chat-message-styles.ts | 110 ----
...mobile-native-chat-message-visuals.test.ts | 16 +-
.../mobile-native-chat-opened-detail.ts | 15 +
.../mobile-native-chat-render-data.test.ts | 23 +-
.../session/mobile-native-chat-render-data.ts | 34 +-
...-native-chat-stopped-before-start.test.tsx | 18 +-
.../session/mobile-native-chat-tool-styles.ts | 249 +++++++
.../use-mobile-native-chat-row-renderer.tsx | 8 +
...obile-native-chat-transcript-rows.test.tsx | 109 ++++
.../use-mobile-native-chat-transcript-rows.ts | 148 +++++
...obile-native-chat-turn-disclosure.test.tsx | 20 +-
.../use-mobile-native-chat-turn-disclosure.ts | 40 +-
mobile/src/theme/mobile-theme.ts | 7 +
.../native-chat/NativeChatDiffCard.tsx | 28 +-
...iveChatMessageList.live-reasoning.test.tsx | 2 +-
.../native-chat/NativeChatMessageList.tsx | 2 +-
.../native-chat/NativeChatTaskList.tsx | 25 +-
.../native-chat/NativeChatToolIcon.tsx | 6 +-
.../native-chat/NativeChatToolRun.tsx | 2 +-
.../NativeChatToolRunMemberList.tsx | 4 +-
.../native-chat/NativeChatTranscriptRow.tsx | 2 +-
.../native-chat-rail-outline-parity.test.ts | 2 +-
...e-chat-shared-copy-matches-catalog.test.ts | 53 ++
.../native-chat-subagent-section-slots.ts | 5 +-
.../native-chat/native-chat-tool-category.ts | 34 -
.../native-chat-tool-line-label.ts | 176 ++---
.../native-chat/native-chat-tool-run-label.ts | 55 +-
.../native-chat-transcript-slots.ts | 47 +-
.../native-chat-turn-diffs.test.ts | 2 +-
.../native-chat/native-chat-turn-diffs.ts | 5 +-
.../native-chat-work-run-pairing.test.ts | 2 +-
.../use-native-chat-transcript-projection.ts | 2 +-
.../native-chat-command-language.test.ts | 0
.../native-chat-command-language.ts | 0
.../native-chat-edit-cards.ts | 26 +-
src/shared/native-chat-edit-patch-files.ts | 7 +-
.../native-chat-task-list-frames.ts | 4 +-
.../native-chat-task-list-history.test.ts | 2 +-
.../native-chat-task-list-history.ts | 4 +-
src/shared/native-chat-task-list.ts | 45 ++
src/shared/native-chat-tool-fold.ts | 2 +-
src/shared/native-chat-tool-icon.ts | 2 +
.../native-chat-tool-input-text.ts | 4 +-
.../native-chat-tool-line-identity.test.ts | 17 +-
src/shared/native-chat-tool-line-identity.ts | 26 +-
.../native-chat-tool-line-label.test.ts | 39 ++
src/shared/native-chat-tool-line-label.ts | 146 +++++
.../native-chat-tool-run-sentence.test.ts | 45 +-
src/shared/native-chat-tool-run-sentence.ts | 91 ++-
.../native-chat-trailing-run.ts | 6 +-
src/shared/native-chat-turn-fold.ts | 48 +-
67 files changed, 3542 insertions(+), 1024 deletions(-)
create mode 100644 mobile/src/session/MobileNativeChatDiffCard.tsx
create mode 100644 mobile/src/session/MobileNativeChatMessage.work-run.test.tsx
create mode 100644 mobile/src/session/MobileNativeChatTaskList.tsx
create mode 100644 mobile/src/session/MobileNativeChatToolIcon.tsx
create mode 100644 mobile/src/session/MobileNativeChatToolLine.tsx
create mode 100644 mobile/src/session/MobileNativeChatToolRun.test.tsx
create mode 100644 mobile/src/session/mobile-native-chat-disclosure-store.ts
create mode 100644 mobile/src/session/mobile-native-chat-opened-detail.ts
create mode 100644 mobile/src/session/mobile-native-chat-tool-styles.ts
create mode 100644 mobile/src/session/use-mobile-native-chat-transcript-rows.test.tsx
create mode 100644 mobile/src/session/use-mobile-native-chat-transcript-rows.ts
delete mode 100644 src/renderer/src/components/native-chat/native-chat-tool-category.ts
rename src/{renderer/src/components/native-chat => shared}/native-chat-command-language.test.ts (100%)
rename src/{renderer/src/components/native-chat => shared}/native-chat-command-language.ts (100%)
rename src/{renderer/src/components/native-chat => shared}/native-chat-edit-cards.ts (82%)
rename src/{renderer/src/components/native-chat => shared}/native-chat-task-list-frames.ts (87%)
rename src/{renderer/src/components/native-chat => shared}/native-chat-task-list-history.test.ts (98%)
rename src/{renderer/src/components/native-chat => shared}/native-chat-task-list-history.ts (96%)
rename src/{renderer/src/components/native-chat => shared}/native-chat-tool-input-text.ts (91%)
create mode 100644 src/shared/native-chat-tool-line-label.test.ts
create mode 100644 src/shared/native-chat-tool-line-label.ts
rename src/{renderer/src/components/native-chat => shared}/native-chat-trailing-run.ts (75%)
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