feat(mobile): draw chat tool runs the way desktop does (#30135)

This commit is contained in:
Kelvin Amoaba
2026-10-18 01:48:36 +00:00
committed by GitHub
parent ce2d60834c
commit 12f3089b9c
67 changed files with 3542 additions and 1024 deletions
@@ -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 <Icon size={size} color={colors.textMuted} strokeWidth={2} />
}
function DiffRow({
line,
gutterChars,
type
}: {
line: NativeChatEditLine
gutterChars: number
type: ReturnType<typeof toolType>['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 (
<Text style={[styles.diffGap, type]} accessibilityLabel={NATIVE_CHAT_DIFF_CARD_COPY.diffGap}>
⋯
</Text>
)
}
return (
<View
style={[
styles.diffRow,
line.kind === 'add' && styles.diffAddGround,
line.kind === 'del' && styles.diffDelGround
]}
>
{gutterChars > 0 ? (
<Text
style={[
styles.codeText,
styles.diffGutter,
type,
{ width: gutterChars * type.fontSize * 0.62 },
line.kind === 'add' && styles.diffAddGutter,
line.kind === 'del' && styles.diffDelGutter
]}
>
{unifiedLineNumber(line) ?? ''}
</Text>
) : null}
<Text
style={[
styles.codeText,
styles.diffSign,
type,
line.kind === 'add' && styles.diffAddText,
line.kind === 'del' && styles.diffDelText
]}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
</Text>
<Text style={[styles.codeText, styles.diffText, type]}>{line.text}</Text>
</View>
)
}
/** 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<string, number>()
return (
<ScrollView
style={[styles.codeBox, styles.diffBody]}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
onLayout={onLayout}
>
{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 (
<DiffRow
key={`${signature}:${occurrence}`}
line={line}
gutterChars={gutterChars}
type={type}
/>
)
})}
</ScrollView>
)
}
/** 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 (
<View>
<View style={styles.cardHeader}>
<Pressable
style={[styles.line, styles.cardToggle]}
onPress={() => hasBody && setExpanded(!expanded)}
disabled={!hasBody}
accessibilityRole="button"
accessibilityState={hasBody ? { expanded } : undefined}
>
<View style={[styles.iconSlot, { width: iconSize }]}>
<VerbIcon kind={file.changeKind} size={iconSize} />
</View>
<Text style={[styles.lineWord, type.row]}>
{NATIVE_CHAT_TOOL_ROW_VERB_COPY[file.changeKind]}
</Text>
{file.oldPath ? (
<>
<Text
style={[styles.lineWord, styles.shrinkable, styles.struck, type.row]}
numberOfLines={1}
accessibilityLabel={file.oldPath}
>
{baseName(file.oldPath)}
</Text>
<Text style={[styles.lineWord, type.row]}>→</Text>
</>
) : null}
<Text
style={[styles.lineTarget, type.row, openFile && styles.lineLink]}
numberOfLines={1}
onPress={openFile ? () => openFile(file.path) : undefined}
suppressHighlighting={!openFile}
accessibilityRole={openFile ? 'link' : undefined}
>
{baseName(file.path)}
</Text>
<Text style={[styles.lineMeta, type.meta]}>
{file.added > 0 ? <Text style={styles.countAdded}>{`+${file.added}`}</Text> : null}
{file.added > 0 && file.removed > 0 ? ' ' : null}
{file.removed > 0 ? (
<Text style={styles.countRemoved}>{`-${file.removed}`}</Text>
) : null}
</Text>
{file.truncated ? (
<Text style={[styles.lineMeta, type.meta]}>
{NATIVE_CHAT_DIFF_CARD_COPY.diffTruncated}
</Text>
) : null}
{hasBody ? (
<View style={expanded ? styles.caretOpen : undefined}>
<ChevronRight size={14 * fontScale} color={colors.textMuted} strokeWidth={2} />
</View>
) : null}
</Pressable>
<MobileSourceCopyButton
text={copyText}
accessibilityLabel={NATIVE_CHAT_DIFF_CARD_COPY.copyDiff}
/>
</View>
{hasBody && expanded ? <DiffCardRows file={file} fontScale={fontScale} /> : null}
</View>
)
}
@@ -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', () => ({
@@ -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'
@@ -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<AgentJournalToolCallItem, 'endedAs'>): 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', () => {
@@ -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')
+82 -28
View File
@@ -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<string>
/** 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.
<MobileNativeChatReasoningRow
key={`thought:${thoughtMessage.id}`}
message={thoughtMessage}
markdown={deriveNativeChatRowContent(thoughtMessage.blocks).markdown}
fontScale={fontScale}
live={activeTurnIsWorking === true}
expanded={openReasoning?.has(nativeChatReasoningDisclosureKey(thoughtMessage.id)) === true}
onToggle={onToggleReasoning}
onOpenFile={onOpenFile}
// Its own actions, as a standalone thought's: the run's row is another message.
onLongPress={INLINE_TEXT_SELECTION ? undefined : () => openSheet(thoughtMessage)}
/>
)
const before = new Map<NativeChatBlock, React.JSX.Element[]>()
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 ? (
<MobileNativeChatAwaitingInputRow
subject={nativeChatAskRunSubject(asks)}
pending={askPending}
@@ -139,7 +183,6 @@ function MobileNativeChatMessageImpl({
/>
) : 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 ? (
<ToolRun
// Why: a global toggle intentionally resets all per-run/per-line
// overrides in one remount, avoiding an effect-driven second render.
key={`${toolsExpanded ? 'expanded' : 'collapsed'}:${turnExpanded ? 'turn' : 'flat'}`}
blocks={tools}
defaultExpanded={turnExpanded || toolsExpanded}
expandChildren={turnExpanded ? false : toolsExpanded}
activeCall={activeCall}
// Opening the turn reveals its runs folded, as on desktop; the Tools toggle is the
// default every run and line reads, and a reader's own choice is kept per default.
defaultExpanded={toolsExpanded}
expandChildren={toolsExpanded}
disclosureId={message.id}
asides={asides}
activeTurnIsWorking={structuredActivityUi ? activeTurnIsWorking : undefined}
trailing={trailingRun}
awaitingAnswer={askPending}
previousTodoWrite={previousTodoWrite}
previousUpdatePlan={previousUpdatePlan}
fontScale={fontScale}
onOpenFile={onOpenFile}
/>
) : null}
@@ -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)
})
@@ -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<NativeChatTask['status'], string> = {
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<T>(items: readonly T[], content: (item: T) => string): [T, string][] {
const seen = new Map<string, number>()
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 (
<View
style={styles.taskRow}
accessibilityLabel={`${STATUS_WORD[task.status]}: ${label ?? nativeChatTaskLabel(task)}`}
>
<View style={[styles.taskIcon, { height: toolType(fontScale).row.lineHeight }]}>
<Icon
size={14 * fontScale}
color={active ? colors.textPrimary : colors.textMuted}
strokeWidth={2}
/>
</View>
<Text
style={[
styles.taskText,
toolType(fontScale).row,
active && styles.taskActive,
!label && task.status === 'completed' && styles.struck
]}
>
{label ?? nativeChatTaskLabel(task)}
</Text>
</View>
)
}
function Checklist({ list, fontScale }: { list: TaskList; fontScale: number }) {
return list.tasks.length === 0 ? (
<Text style={[styles.lineMeta, toolType(fontScale).meta]}>
{NATIVE_CHAT_TASK_LIST_COPY.empty}
</Text>
) : (
<View style={styles.taskList}>
{occurrenceKeyed(list.tasks, (task) => task.content).map(([task, key]) => (
<TaskRow key={key} task={task} fontScale={fontScale} />
))}
</View>
)
}
/** 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 (
<View>
<Pressable
style={styles.taskDisclosure}
onPress={() => setOpen((value) => !value)}
accessibilityRole="button"
accessibilityState={{ expanded: open }}
>
<View style={open ? styles.caretOpen : undefined}>
<ChevronRight size={14 * fontScale} color={colors.textMuted} strokeWidth={2} />
</View>
<Text style={[styles.lineMeta, toolType(fontScale).meta]}>
{NATIVE_CHAT_TASK_LIST_COPY.showAll}
</Text>
</Pressable>
{open ? <OpenedChecklist list={list} fontScale={fontScale} /> : null}
</View>
)
}
function OpenedChecklist({ list, fontScale }: { list: TaskList; fontScale: number }) {
const onLayout = useMobileNativeChatOpenedDetail()
return (
<View onLayout={onLayout}>
<Checklist list={list} fontScale={fontScale} />
</View>
)
}
/** 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 (
<View style={styles.taskCard}>
<View
style={styles.taskHeader}
accessibilityLabel={NATIVE_CHAT_TASK_LIST_COPY.progress
.replace('{{completed}}', String(completed))
.replace('{{total}}', String(list.tasks.length))}
>
<ListChecks size={15 * fontScale} color={colors.textMuted} strokeWidth={2} />
<Text style={[styles.lineMeta, styles.taskTitle, type.meta]}>
{NATIVE_CHAT_TASK_LIST_COPY.title}
</Text>
<Text style={[styles.lineMeta, type.meta]}>{`${completed}/${list.tasks.length}`}</Text>
</View>
{changes ? (
<>
{changes.length > 0 ? (
<View style={styles.taskList}>
{occurrenceKeyed(changes, (change) => `${change.kind}:${change.task.content}`).map(
([change, rowKey]) => {
const { key, task } = nativeChatTaskChangeWords(change)
return (
<TaskRow
key={rowKey}
task={change.task}
label={NATIVE_CHAT_TASK_LIST_COPY[key].replace('{{task}}', task)}
fontScale={fontScale}
/>
)
}
)}
</View>
) : (
<Text style={[styles.lineMeta, type.meta]}>{NATIVE_CHAT_TASK_LIST_COPY.unchanged}</Text>
)}
<FullList list={list} fontScale={fontScale} />
</>
) : (
<Checklist list={list} fontScale={fontScale} />
)}
{list.explanation ? (
<Text style={[styles.lineMeta, type.meta]}>{list.explanation}</Text>
) : null}
</View>
)
}
@@ -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<NativeChatToolIconName, LucideIcon> = {
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 <Glyph size={size} color={color} strokeWidth={2} />
}
@@ -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 (
<View style={[styles.codeBox, styles.searchResults]}>
{results.map((hit) => (
<Text
key={hit.url}
style={[styles.codeText, styles.searchHit, type]}
numberOfLines={1}
onPress={() => openExternalLink(hit.url)}
accessibilityRole="link"
>
{hit.title}
{hit.title !== hit.url ? <Text style={styles.searchUrl}>{` ${hit.url}`}</Text> : null}
</Text>
))}
</View>
)
}
function DiffView({ lines, fontScale }: { lines: NativeChatDiffLine[]; fontScale: number }) {
const type = toolType(fontScale).code
return (
<View style={styles.codeBox}>
{lines.map((line, index) => (
<Text
key={index}
style={[
styles.codeText,
type,
line.kind === 'add' && styles.diffAdd,
line.kind === 'del' && styles.diffDel,
line.kind === 'meta' && styles.diffMeta
]}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
{line.text}
</Text>
))}
</View>
)
}
/** 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 (
<View style={styles.detail} onLayout={onLayout}>
{results.length > 0 ? <SearchResults results={results} fontScale={fontScale} /> : null}
{diff ? <DiffView lines={diff} fontScale={fontScale} /> : null}
{!diff && fullCommand ? (
<ScrollView
style={[styles.codeBox, styles.codeScroll]}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
<Text selectable={INLINE_TEXT_SELECTION} style={[styles.codeText, type]}>
{command ? <MobileSyntaxSegments segments={command} /> : fullCommand}
</Text>
</ScrollView>
) : null}
{body ? (
<ScrollView
style={[styles.codeBox, styles.codeScroll]}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.codeText, type, body.isError && styles.errorText]}
>
{truncateToolDetail(body.output)}
</Text>
</ScrollView>
) : null}
</View>
)
}
/** 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 (
<View>
<Pressable
style={styles.line}
onPress={() => hasDetail && setExpanded(!expanded)}
disabled={!hasDetail}
accessibilityRole="button"
accessibilityState={hasDetail ? { expanded } : undefined}
>
<View style={[styles.iconSlot, { width: iconSize }]}>
{call ? (
<MobileNativeChatToolGlyph
name={nativeChatToolIconName(call.name, call.mcpIdentity)}
size={iconSize}
color={failed ? colors.statusRed : colors.textMuted}
/>
) : null}
</View>
{commandAlone ? null : (
<Text style={[styles.lineWord, type.row, !verb && styles.lineName]} numberOfLines={1}>
{verb ?? name}
</Text>
)}
{target ? (
<Text
style={[styles.lineTarget, type.row, filePath && onOpenFile && styles.lineLink]}
numberOfLines={1}
onPress={filePath && onOpenFile ? () => onOpenFile(filePath) : undefined}
suppressHighlighting={!(filePath && onOpenFile)}
accessibilityRole={filePath && onOpenFile ? 'link' : undefined}
>
{target}
</Text>
) : null}
{/* Held at the row's right edge, so the carets of a run line up in one column. */}
<View style={styles.lineTrailing}>
{exitCode !== undefined ? (
<Text style={[styles.lineMeta, type.meta, exitCode !== 0 && styles.errorText]}>
{`exit ${exitCode}`}
</Text>
) : null}
{duration ? <Text style={[styles.lineMeta, type.meta]}>{duration}</Text> : null}
{hasDetail ? (
<View style={expanded ? styles.caretOpen : undefined}>
<ChevronRight size={14 * fontScale} color={colors.textMuted} strokeWidth={2} />
</View>
) : null}
</View>
</Pressable>
{hasDetail && expanded ? (
<ToolLineDetail
call={call}
body={body}
fullCommand={fullCommand}
commandLanguage={label?.commandLanguage ?? null}
results={results}
fontScale={fontScale}
/>
) : null}
</View>
)
}
@@ -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<ReactTestRenderer> {
const element = createElement(ToolRun, {
blocks: next,
defaultExpanded: true,
expandChildren: false,
activeCall: null
expandChildren: false
})
if (tree) {
tree.update(element)
@@ -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<string, unknown> =>
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<AgentJournalToolCallItem, 'endedAs'>): 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()
})
})
+381 -235
View File
@@ -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 (
<View style={styles.diff}>
{lines.map((line, i) => (
<Text
key={i}
style={[
styles.diffLine,
line.kind === 'add' && styles.diffAdd,
line.kind === 'del' && styles.diffDel,
line.kind === 'meta' && styles.diffMeta
]}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
{line.text}
</Text>
))}
</View>
)
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 <DiffView lines={diff} />
}
return (
<View style={[styles.toolResult, isError && styles.toolResultError]}>
<Text style={styles.mono}>{truncateToolDetail(output)}</Text>
</View>
)
}
/** 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 (
<View>
<Pressable
style={styles.toolLine}
onPress={() => hasDetail && setExpanded((v) => !v)}
hitSlop={6}
>
{showDetail ? (
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
) : (
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
)}
<Text style={styles.toolName}>{name}</Text>
{preview ? (
<Text
style={[styles.toolPreview, openable && styles.toolPreviewLink]}
numberOfLines={1}
onPress={openable ? () => onOpenFile!(filePath!) : undefined}
suppressHighlighting={!openable}
>
{preview}
</Text>
) : null}
</Pressable>
{showDetail ? (
<View style={styles.toolDetail}>
{callDiff ? <DiffView lines={callDiff} /> : null}
{callDetail ? <Text style={styles.mono}>{callDetail}</Text> : null}
{result ? (
<ResultBody output={result.output} isError={result.isError} diff={resultDiff} />
) : null}
</View>
) : null}
</View>
)
}
/** 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<typeof Animated.Text>['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 (
<Animated.Text style={[style, { opacity: pulse }]} numberOfLines={numberOfLines}>
<Animated.Text style={[style, { opacity }]} numberOfLines={2}>
{children}
</Animated.Text>
)
}
/** 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<NativeChatBlock, readonly React.JSX.Element[]>
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<ScrollView>(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<string, number>())
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<NativeScrollEvent>) => {
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 (
<MobileNativeChatOpenedDetailContext.Provider value={reportDetail}>
<ScrollView
ref={scroller}
style={[
styles.members,
{ maxHeight: Math.min(height / 2, MEMBERS_MIN_HEIGHT + openedHeight) }
]}
nestedScrollEnabled
// As the transcript's own list: a row or file tap lands while the composer's keyboard is up.
keyboardShouldPersistTaps="handled"
scrollEventThrottle={32}
onScroll={onScroll}
onContentSizeChange={onContentSizeChange}
>
{children}
</ScrollView>
</MobileNativeChatOpenedDetailContext.Provider>
)
}
/** 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<typeof selectActiveToolCall>
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<string, number>()
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 (
<View style={styles.toolRun}>
<View style={styles.toolRunHeader}>
{activeCall ? (
<Pressable
style={styles.toolRunActive}
onPress={() => setOpen((v) => !v)}
hitSlop={6}
accessibilityRole="button"
accessibilityState={{ expanded: open }}
accessibilityLiveRegion="polite"
>
<ActiveToolIcon size={15} color={colors.textMuted} strokeWidth={2} />
<PulsingText style={styles.toolRunActiveLabel} numberOfLines={1}>
{formatActiveToolLabel(describeActiveToolCall(activeCall))}
</PulsingText>
{open ? <ChevronDown size={15} color={colors.textMuted} strokeWidth={2} /> : null}
</Pressable>
) : (
<Pressable style={styles.toolRunToggle} onPress={() => setOpen((v) => !v)} hitSlop={6}>
{open ? (
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
) : (
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
)}
<Text style={styles.toolRunCount}>{count}×</Text>
<Text style={styles.toolRunLabel} numberOfLines={1}>
{summary || formatToolCallCount(count)}
</Text>
</Pressable>
)}
</View>
<View style={styles.run}>
{/* One element for the run's whole life: live and settled are states of this
button, not two buttons. */}
<Pressable
style={styles.header}
onPress={() => setOpen(!open)}
accessibilityRole="button"
accessibilityState={{ expanded: open }}
accessibilityLiveRegion="polite"
>
{headerIcon ? (
<MobileNativeChatToolGlyph name={headerIcon} size={iconSize} color={colors.textMuted} />
) : null}
<PulsingText
pulsing={live}
style={[styles.headerSentence, type.row, live && styles.headerSentenceLive]}
>
{sentence ?? fallbackLabel}
</PulsingText>
{marks.length > 0 ? (
// Never shrinks: it is the one thing the reader cannot afford to miss.
<Text style={[styles.lineMeta, styles.headerMarks, type.meta]} numberOfLines={1}>
{` · ${marks.join(', ')}`}
</Text>
) : null}
{/* Only a stated success is marked done, and never while live. */}
{!live && outcome.succeeded ? (
<Check size={12 * fontScale} color={colors.textMuted} strokeWidth={2} />
) : null}
{latestCallLabel ? (
<Text style={[styles.headerLatest, type.meta]} numberOfLines={1}>
{latestCallLabel}
</Text>
) : null}
</Pressable>
{open ? (
<View style={styles.toolRunBody}>
{visiblePairs.map((pair) => (
<ToolLine
key={nativeChatToolLineIdentity(pair.call ?? pair.result!, seen, true)}
pair={pair}
defaultExpanded={expandChildren}
diffLineLimit={diffLineLimit}
onOpenFile={onOpenFile}
/>
))}
{pairs.length > visiblePairs.length ? (
<Pressable
style={styles.toolLine}
accessibilityRole="button"
accessibilityState={{ expanded: visibleCount > TOOL_PAIR_REVEAL_PAGE }}
onPress={() => setVisibleCount((count) => count + TOOL_PAIR_REVEAL_PAGE)}
hitSlop={6}
>
<Text style={styles.toolPreview}>Show more</Text>
</Pressable>
) : null}
{visiblePairs.length > TOOL_PAIR_REVEAL_PAGE ? (
<Pressable
style={styles.toolLine}
accessibilityRole="button"
accessibilityState={{ expanded: true }}
onPress={() => setVisibleCount(TOOL_PAIR_REVEAL_PAGE)}
hitSlop={6}
>
<Text style={styles.toolPreview}>Show less</Text>
</Pressable>
) : null}
</View>
<ToolRunMembers
followKey={blocks.length + asideCount}
startsAtEnd={live}
memoryKey={runKey}
>
<ToolRunMemberList
blocks={blocks}
previousTodoWrite={previousTodoWrite}
previousUpdatePlan={previousUpdatePlan}
expandChildren={expandChildren}
disclosureId={disclosureId}
asides={asides}
fontScale={fontScale}
onOpenFile={onOpenFile}
/>
</ToolRunMembers>
) : null}
</View>
)
}
/** 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<string, number>()
const member = (block: NativeChatBlock, blockIndex: number): React.ReactNode => {
const taskList = taskLists.rows.get(block)
if (taskList) {
return (
<MobileNativeChatTaskList key={`tasks:${blockIndex}`} {...taskList} fontScale={fontScale} />
)
}
const edit = editCards.get(block)
if (edit) {
return (
<View key={`edit:${edit.key}`}>
{edit.files.map((file, fileIndex) => (
<MobileNativeChatDiffCard
key={`${edit.key}:${fileIndex}`}
file={file}
defaultExpanded={expandChildren}
disclosureKey={
disclosureId === undefined
? undefined
: `diff:${disclosureId}:${expandChildren}:${edit.key}:${fileIndex}`
}
fontScale={fontScale}
onOpenFile={onOpenFile}
/>
))}
</View>
)
}
// 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 (
<MobileNativeChatToolLine
key={lineIdentity}
block={block}
result={block.type === 'tool-call' ? resultByCall.get(block) : undefined}
defaultExpanded={expandChildren}
disclosureKey={
disclosureId === undefined
? undefined
: `line:${disclosureId}:${expandChildren}:${lineIdentity}`
}
fontScale={fontScale}
onOpenFile={onOpenFile}
/>
)
}
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}
</>
)
}
+45 -33
View File
@@ -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({
</View>
) : (
<GestureHandlerRootView style={styles.listWrap}>
<MobileNativeChatScrollHold headKey={turns.listMessages[0]?.id} listRef={listRef} />
<GestureDetector gesture={pinchGesture}>
<FlatList
ref={listRef}
data={history.listRows}
keyExtractor={(item) => 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}
/>
</GestureDetector>
<MobileNativeChatDisclosureContext.Provider value={disclosures}>
<MobileNativeChatScrollHold headKey={turns.listMessages[0]?.id} listRef={listRef} />
<GestureDetector gesture={pinchGesture}>
<FlatList
ref={listRef}
data={history.listRows}
keyExtractor={(item) => 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}
/>
</GestureDetector>
</MobileNativeChatDisclosureContext.Provider>
<MobileNativeChatFloatingControls
toolCallCount={toolCallCount}
toolsExpanded={toolsExpanded}
+1
View File
@@ -46,6 +46,7 @@ const LANGUAGE_ALIASES: Record<string, string> = {
tsx: 'typescript',
sh: 'bash',
shell: 'bash',
shellscript: 'bash',
zsh: 'bash',
fish: 'bash',
yml: 'yaml'
@@ -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<string, MobileNativeChatRunScroll>
}
export const MobileNativeChatDisclosureContext =
createContext<MobileNativeChatDisclosureStore | null>(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<string, boolean>
runScroll: Map<string, MobileNativeChatRunScroll>
}
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]
}
@@ -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
}
})
@@ -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', () => ({
@@ -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<ReportDetail | null>(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)
}
@@ -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', () => {
@@ -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. */
@@ -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', () => ({
@@ -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
}
})
@@ -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<string, NativeChatTaskListPredecessors>
fontScale: number
onOpenFile?: (relativePath: string) => void
structuredActivityUi: boolean
@@ -38,6 +42,9 @@ export function useMobileNativeChatRowRenderer({
<MobileNativeChatMessage
message={item}
toolsExpanded={toolsExpanded}
// The calls, not the record: a rebuilt record would defeat the row's memo.
previousTodoWrite={taskListPredecessors.get(item.id)?.todowrite}
previousUpdatePlan={taskListPredecessors.get(item.id)?.update_plan}
fontScale={fontScale}
onOpenFile={onOpenFile}
structuredActivityUi={structuredActivityUi}
@@ -50,6 +57,7 @@ export function useMobileNativeChatRowRenderer({
),
[
toolsExpanded,
taskListPredecessors,
fontScale,
onOpenFile,
structuredActivityUi,
@@ -0,0 +1,109 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { Harness, Result, userMessage } from './use-mobile-native-chat-turn-disclosure.test-fixture'
function toolRow(id: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'tool-call', name: 'Bash', input: { command: id }, state: 'completed' }],
timestamp: null,
source: 'transcript'
}
}
function thought(id: string): NativeChatMessage {
return {
id,
role: 'reasoning',
state: 'completed',
blocks: [{ type: 'text', text: 'Considering the next step.' }],
timestamp: null,
source: 'transcript'
}
}
function answer(id: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text: 'Done.' }],
timestamp: null,
source: 'transcript'
}
}
describe('how the structured lane draws its rows, as desktop does', () => {
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)
})
})
@@ -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<string, NativeChatResolvedPrompt> | null | undefined
}
export type MobileNativeChatWorkRuns = {
/** Each run's first row, with every member in order. */
heads: ReadonlyMap<number, readonly NativeChatMessage[]>
/** The later members, which their first row draws. */
joined: ReadonlySet<number>
}
/** A settled turn folds to its answer until the reader opens it. */
function mobileNativeChatTurnFold({
rows,
turnKeys,
receipts,
settledTurnKeys,
expandedTurnKeys
}: TranscriptRows & {
settledTurnKeys: ReadonlySet<string>
expandedTurnKeys: ReadonlySet<string>
}): 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<number, readonly NativeChatMessage[]>()
const joined = new Set<number>()
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<string, NativeChatResolvedPrompt> = 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<Record<string, NativeChatTurnStatus>>
expandedTurnKeys: ReadonlySet<string>
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 }
}
@@ -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
@@ -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<string>
/** 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<string, NativeChatResolvedPrompt> | 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,
+7
View File
@@ -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',
@@ -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({
<span aria-hidden className="flex size-4 shrink-0 items-center justify-center">
<VerbIcon kind={file.changeKind} />
</span>
<span className="shrink-0 text-chat-foreground-faint">{verbLabel(file)}</span>
<span className="shrink-0 text-chat-foreground-faint">
{nativeChatToolRowVerb(file.changeKind)}
</span>
{file.oldPath ? (
<>
<span
@@ -13,7 +13,7 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection'
import { NativeChatMessageList } from './NativeChatMessageList'
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames'
import { projectNativeChatTaskListFrames } from '../../../../shared/native-chat-task-list-frames'
import { openToolRunMembers } from './native-chat-tool-run-members-test-support'
import { projectStructuredQuestionMessages } from '../../../../shared/structured-agent-question-projection'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
@@ -11,7 +11,7 @@ import { translate } from '@/i18n/i18n'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { useNativeChatTranscriptProjection } from './use-native-chat-transcript-projection'
import { structuredQuestionTranscript } from '../../../../shared/structured-agent-question-projection'
import { nativeChatTaskListPredecessors } from './native-chat-task-list-history'
import { nativeChatTaskListPredecessors } from '../../../../shared/native-chat-task-list-history'
import { NativeChatDockedTaskList } from './NativeChatDockedTaskList'
import { useNativeChatTurnStatus } from './use-native-chat-turn-status'
import { NativeChatAwaitingInputRow } from './NativeChatAwaitingInputRow'
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
diffNativeChatTaskLists,
nativeChatTaskChangeWords,
nativeChatTaskLabel,
type NativeChatTask,
type NativeChatTaskChange,
@@ -21,22 +22,22 @@ function statusLabel(task: NativeChatTask): string {
}
function changeLabel(change: NativeChatTaskChange): string {
const values = { task: change.task.content }
switch (change.kind) {
const { key, task } = nativeChatTaskChangeWords(change)
switch (key) {
case 'added':
return translate('components.native-chat.taskList.added', 'Added {{task}}', values)
return translate('components.native-chat.taskList.added', 'Added {{task}}', { task })
case 'removed':
return translate('components.native-chat.taskList.removed', 'Removed {{task}}', values)
return translate('components.native-chat.taskList.removed', 'Removed {{task}}', { task })
case 'started':
return translate('components.native-chat.taskList.started', 'Started {{task}}', values)
case 'completed':
return translate('components.native-chat.taskList.finished', 'Completed {{task}}', values)
case 'pending':
return translate('components.native-chat.taskList.reset', 'Marked pending: {{task}}', values)
case 'updated':
return translate('components.native-chat.taskList.updated', 'Updated {{task}}', {
task: nativeChatTaskLabel(change.task)
return translate('components.native-chat.taskList.started', 'Started {{task}}', { task })
case 'finished':
return translate('components.native-chat.taskList.finished', 'Completed {{task}}', { task })
case 'reset':
return translate('components.native-chat.taskList.reset', 'Marked pending: {{task}}', {
task
})
case 'updated':
return translate('components.native-chat.taskList.updated', 'Updated {{task}}', { task })
}
}
@@ -15,8 +15,10 @@ import {
import type { NativeChatMcpIdentity } from '../../../../shared/native-chat-tool-identity'
import type { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { NativeChatToolIconName } from '../../../../shared/native-chat-tool-icon'
import { nativeChatToolIconName } from './native-chat-tool-category'
import {
nativeChatToolIconName,
type NativeChatToolIconName
} from '../../../../shared/native-chat-tool-icon'
/** Glyph name to component. */
const NATIVE_CHAT_TOOL_GLYPHS: Record<NativeChatToolIconName, LucideIcon> = {
@@ -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,
@@ -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'
@@ -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'
@@ -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'
@@ -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<string, string> = en.components['native-chat'].tool.row
const taskListCatalog: Record<string, string> = 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}}')
@@ -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. */
@@ -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))
}
@@ -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<NativeChatToolLineLabel, 'verb'> & { verb: string | null } {
const label = sharedToolLineLabel(call, result)
return { ...label, verb: label.verb === null ? null : nativeChatToolRowVerb(label.verb) }
}
@@ -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)
}
@@ -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.
@@ -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 {
@@ -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
@@ -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(
@@ -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'
@@ -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,
{
+6 -1
View File
@@ -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
}
@@ -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<NativeChatMessage, NativeChatMessage>()
@@ -3,7 +3,7 @@ import type {
NativeChatBlock,
NativeChatMessage,
NativeChatToolCallBlock
} from '../../../../shared/native-chat-types'
} from './native-chat-types'
import {
buildNativeChatTaskListRows,
nativeChatTaskListPredecessors
@@ -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<
+45
View File
@@ -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
}
+1 -1
View File
@@ -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(
+2
View File
@@ -87,6 +87,8 @@ const CATEGORY_BY_ROW_WORD = new Map<string, NativeChatToolCategory>([
// 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'],
@@ -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<Record<NativeChatTextInputKey, unknown>>
@@ -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<string, number>()
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',
+11 -15
View File
@@ -1,36 +1,32 @@
import type { NativeChatBlock } from './native-chat-types'
import { summarizeToolInput } from './native-chat-tool-summary'
const anonymousRows = new WeakMap<NativeChatBlock, number>()
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<string, number>,
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)
@@ -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 })
})
})
+146
View File
@@ -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<T extends NativeChatToolRowVerb>(
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 }
}
@@ -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()
})
})
+73 -18
View File
@@ -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])
}
@@ -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. */
+47 -1
View File
@@ -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<number>