mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(mobile): draw chat tool runs the way desktop does (#30135)
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+53
@@ -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'
|
||||
|
||||
|
||||
+19
-7
@@ -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,
|
||||
{
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+2
-2
@@ -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>()
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type {
|
||||
NativeChatBlock,
|
||||
NativeChatMessage,
|
||||
NativeChatToolCallBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
} from './native-chat-types'
|
||||
import {
|
||||
buildNativeChatTaskListRows,
|
||||
nativeChatTaskListPredecessors
|
||||
+2
-2
@@ -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<
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'],
|
||||
|
||||
+2
-2
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
+3
-3
@@ -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. */
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user