feat(mobile): port the restructured native-chat turn status and live tool progress

Mobile chat had a single static "Agent is working" row and no live tool
activity, while the desktop restructure (#17597, #18705) replaced that with a
per-turn status row and a running-tool label. This brings mobile to parity and
puts the derivation in one place instead of two.

Shared (new, pure, RN-safe — desktop uses them as i18n fallbacks, mobile
directly, matching the native-chat-empty-state pattern):
- `native-chat-turn-status.ts`: duration formatting, label selection, the
  turn-timing state machine, and the active/settled split.
- `native-chat-tool-activity.ts`: command-tool classification, the running-tool
  label descriptor, and running-call selection.

Desktop now consumes both; `NativeChatWorkingStatus`, `NativeChatToolRun` and
`use-native-chat-turn-status` keep their existing behavior and strings.

Mobile gains the "Thinking" / "Working for 12s" / "Worked for 3m 4s" row with a
caret that discloses the turn's tool activity, the pulsing "Running npm test"
row with terminal-vs-wrench glyphs, and desktop's rule that a completed turn's
tool run hides behind the turn caret. The bridge lane is untouched and keeps its
three-dot indicator. Headings, quotes, code, lists and table cells are now
selectable.

Files at their max-lines cap were split rather than bumped: the tool-run subtree,
the prompt card, the session-lane wiring, and the turn-disclosure state each move
to their own module.
This commit is contained in:
Merge Sim
2026-09-04 19:21:16 -07:00
parent e2b70a5eba
commit 6abd8fa92a
22 changed files with 1770 additions and 467 deletions
+10 -5
View File
@@ -192,6 +192,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
return (
<Text
key={index}
selectable
style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]}
>
{renderInline(block.text, onOpenFile)}
@@ -201,7 +202,9 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
if (block.type === 'quote') {
return (
<View key={index} style={styles.quote}>
<Text style={styles.quoteText}>{renderInline(block.text, onOpenFile)}</Text>
<Text selectable style={styles.quoteText}>
{renderInline(block.text, onOpenFile)}
</Text>
</View>
)
}
@@ -223,7 +226,9 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
return (
<View key={index} style={styles.codeBlock}>
{block.language ? <Text style={styles.codeLanguage}>{block.language}</Text> : null}
<Text style={styles.codeText}>{block.text}</Text>
<Text selectable style={styles.codeText}>
{block.text}
</Text>
</View>
)
}
@@ -251,7 +256,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
<View style={styles.table}>
<View style={styles.tableRow}>
{visibleHeaders.map((header, cellIndex) => (
<Text key={cellIndex} style={[styles.tableCell, styles.tableHeader]}>
<Text key={cellIndex} selectable style={[styles.tableCell, styles.tableHeader]}>
{renderInline(header, onOpenFile)}
</Text>
))}
@@ -259,7 +264,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
{visibleRows.map((row, rowIndex) => (
<View key={rowIndex} style={styles.tableRow}>
{visibleHeaders.map((_, cellIndex) => (
<Text key={cellIndex} style={styles.tableCell}>
<Text key={cellIndex} selectable style={styles.tableCell}>
{renderInline(row[cellIndex] ?? '', onOpenFile)}
</Text>
))}
@@ -290,7 +295,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
? '[x]'
: '[ ]'}
</Text>
<Text style={[styles.listText, listScale]}>
<Text selectable style={[styles.listText, listScale]}>
{renderInline(item.text, onOpenFile)}
</Text>
</View>
@@ -6,11 +6,21 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
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() })
},
Image: 'Image',
Pressable: 'Pressable',
Text: ({ children, ...props }: { children?: unknown }) =>
React.createElement('Text', props, children),
Text,
View: ({ children, ...props }: { children?: unknown }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
@@ -21,7 +31,10 @@ vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
ChevronDown: 'ChevronDown',
Copy: 'Copy',
SquareChevronRight: 'SquareChevronRight'
SquareChevronRight: 'SquareChevronRight',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench',
ChevronRight: 'ChevronRight'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
@@ -45,7 +58,18 @@ describe('MobileNativeChatMessage', () => {
function render(
message: NativeChatMessage,
props: { toolsExpanded?: boolean } = {}
props: {
toolsExpanded?: boolean
structuredActivityUi?: boolean
activeTurnIsWorking?: boolean
turnExpanded?: boolean
turnStatus?: {
startedAt: number | null
thinking: boolean
workedSeconds: number | null
} | null
onToggleTurn?: () => void
} = {}
): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
@@ -152,4 +176,86 @@ describe('MobileNativeChatMessage', () => {
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
})
describe('structured activity UI', () => {
const runningCall = {
type: 'tool-call' as const,
name: 'Bash',
input: { command: 'npm test' },
state: 'running' as const
}
const settledCall = {
type: 'tool-call' as const,
name: 'Read',
input: { file_path: 'a/b.ts' },
state: 'completed' as const
}
it('shows the live tool label with a terminal glyph while a command runs', () => {
const tree = render(toolMessage([runningCall]), {
structuredActivityUi: true,
activeTurnIsWorking: true
})
expect(textIn(tree.root)).toContain('Running 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 }
)
expect(textIn(tree.root)).toContain('Running Read a/b.ts')
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(1)
})
it('falls back to the collapsed count row once the run settles', () => {
const tree = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: true
})
expect(textIn(tree.root)).not.toContain('Running Read a/b.ts')
expect(textIn(tree.root)).toContain('1×')
})
it("hides a completed turn's activity until the turn caret discloses it", () => {
const collapsed = render(toolMessage([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('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)
})
it('renders the turn status row under a user message', () => {
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
structuredActivityUi: true,
turnStatus: { startedAt: Date.now(), thinking: true, workedSeconds: null }
})
expect(textIn(tree.root)).toContain('Thinking')
})
it('does not render a turn status row without one', () => {
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
structuredActivityUi: true
})
expect(textIn(tree.root)).toEqual(['go'])
})
})
})
+77 -220
View File
@@ -1,139 +1,20 @@
import { memo, useEffect, useRef, useState } from 'react'
import { Image, Pressable, Text, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { ArrowUp, ChevronDown, Copy, SquareChevronRight } 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, splitNativeChatBlocks } 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'
import { ArrowUp, Copy } from 'lucide-react-native'
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
import { ToolRun } from './MobileNativeChatToolRun'
import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status'
import { colors } from '../theme/mobile-theme'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
const MAX_VISIBLE_TOOL_PAIRS = 6
const MAX_TOOL_RUN_DIFF_ROWS = 240
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>
)
}
/** 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>
)
}
function Prose({
block,
invert,
@@ -150,7 +31,9 @@ function Prose({
// markdown renderer's light-on-dark palette.
if (invert) {
return (
<Text style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>{block.text}</Text>
<Text selectable style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>
{block.text}
</Text>
)
}
return (
@@ -180,67 +63,6 @@ function Prose({
return null
}
/** 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. */
function ToolRun({
blocks,
defaultExpanded,
trailing,
onOpenFile
}: {
blocks: NativeChatBlock[]
defaultExpanded: boolean
trailing?: React.ReactNode
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element {
const [open, setOpen] = useState(defaultExpanded)
const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS)
const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1)))
let callCount = 0
for (const block of blocks) {
if (block.type === 'tool-call') {
callCount++
}
}
callCount ||= pairs.length
const summary = summarizeToolRun(blocks)
return (
<View style={styles.toolRun}>
<View style={styles.toolRunHeader}>
<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}>{callCount}×</Text>
<Text style={styles.toolRunLabel} numberOfLines={1}>
{summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`}
</Text>
</Pressable>
{trailing}
</View>
{open ? (
<View style={styles.toolRunBody}>
{pairs.map((pair, i) => (
<ToolLine
key={i}
pair={pair}
defaultExpanded={defaultExpanded}
diffLineLimit={diffLineLimit}
onOpenFile={onOpenFile}
/>
))}
{callCount > pairs.length ? (
<Text style={styles.toolPreview}>… {callCount - pairs.length} more tool calls</Text>
) : null}
</View>
) : null}
</View>
)
}
/** Subtle top-right controls for an agent message: copy its prose, or scroll so
* this message's top aligns to the top of the viewport. */
function AgentControls({
@@ -280,7 +102,12 @@ function MobileNativeChatMessageImpl({
fontScale = 1,
messageIndex,
onScrollToMessage,
onOpenFile
onOpenFile,
turnStatus,
turnExpanded,
onToggleTurn,
activeTurnIsWorking,
structuredActivityUi = false
}: {
message: NativeChatMessage
toolsExpanded?: boolean
@@ -291,6 +118,15 @@ function MobileNativeChatMessageImpl({
/** Ask the list to align this message's top to the top of the viewport. */
onScrollToMessage?: (index: number) => void
onOpenFile?: (relativePath: string) => void
/** This turn's status row, rendered under a user message (desktop parity). */
turnStatus?: NativeChatTurnStatus | null
/** Whether the turn caret has disclosed this turn's activity. */
turnExpanded?: boolean
onToggleTurn?: () => void
/** Session-level working state for this message's turn; gates the live tool row. */
activeTurnIsWorking?: boolean
/** Structured lane only: live tool progress plus the turn-status disclosure. */
structuredActivityUi?: boolean
}): React.JSX.Element {
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
@@ -310,6 +146,14 @@ 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 } = splitNativeChatBlocks(message.blocks)
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.
const settledToolsHidden =
structuredActivityUi && activeCall == null && activeTurnIsWorking === false && !turnExpanded
const showToolRun = tools.length > 0 && !settledToolsHidden
const handleCopy = (): void => {
const text = nativeChatMessageText(message.blocks)
@@ -338,39 +182,52 @@ function MobileNativeChatMessageImpl({
) : null
return (
<View style={[styles.row, isUser && styles.rowUser]}>
<View
style={[
styles.content,
isUser && styles.userBubble,
isReasoning && styles.reasoning,
copied && styles.copied
]}
>
{prose.map((block, index) => (
<Prose
key={index}
block={block}
invert={isUser}
fontScale={fontScale}
onOpenFile={onOpenFile}
/>
))}
{tools.length > 0 ? (
<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'}
blocks={tools}
defaultExpanded={toolsExpanded}
trailing={controls}
onOpenFile={onOpenFile}
/>
) : controls ? (
<View style={styles.controlsRow}>{controls}</View>
) : null}
<>
<View style={[styles.row, isUser && styles.rowUser]}>
<View
style={[
styles.content,
isUser && styles.userBubble,
isReasoning && styles.reasoning,
copied && styles.copied
]}
>
{prose.map((block, index) => (
<Prose
key={index}
block={block}
invert={isUser}
fontScale={fontScale}
onOpenFile={onOpenFile}
/>
))}
{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}
trailing={controls}
onOpenFile={onOpenFile}
/>
) : controls ? (
<View style={styles.controlsRow}>{controls}</View>
) : null}
</View>
</View>
</View>
{turnStatus ? (
<MobileNativeChatTurnStatus
startedAt={turnStatus.startedAt}
thinking={turnStatus.thinking}
workedSeconds={turnStatus.workedSeconds}
expanded={turnExpanded ?? false}
onToggleExpanded={turnStatus.workedSeconds != null ? onToggleTurn : undefined}
/>
) : null}
</>
)
}
@@ -71,6 +71,7 @@ export function MobileNativeChatOverlay({
error={session.error}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
structuredActivityUi={controller.nativeChatStructured}
streaming={streaming}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
@@ -0,0 +1,74 @@
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
/** The one pending agent prompt shown above the composer: a structured
* AskUserQuestion wins, then a heuristic permission, then a heuristic question.
* The controller owns dismissal (it must survive this subtree unmounting on a
* view toggle); `ask` arrives already nulled while dismissed. */
export function MobileNativeChatPromptCard({
ask,
askKey,
onDismissAsk,
onAnswerAsk,
onCancelAsk,
permission,
onRespondPermission,
question,
onAnswerQuestion
}: {
ask?: AskPrompt | null
askKey?: string | null
onDismissAsk?: () => void
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
onCancelAsk?: () => Promise<boolean>
permission?: MobileChatPermission | null
onRespondPermission?: (send: string) => Promise<boolean>
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
}): React.JSX.Element | null {
if (ask) {
return (
<MobileNativeChatAsk
key={askKey ?? 'ask'}
prompt={ask}
onAnswer={async (selections) => {
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
if (accepted) {
onDismissAsk?.()
}
return accepted
}}
onCancel={async () => {
const accepted = (await onCancelAsk?.()) ?? false
if (accepted) {
onDismissAsk?.()
}
return accepted
}}
/>
)
}
if (permission) {
return (
<MobileNativeChatPermission
key={JSON.stringify(permission)}
permission={permission}
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
/>
)
}
if (question) {
return (
<MobileNativeChatQuestion
key={mobileChatQuestionKey(question)}
question={question}
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
/>
)
}
return null
}
@@ -0,0 +1,250 @@
import { useEffect, 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'
import {
describeActiveToolCall,
formatActiveToolLabel,
formatToolCallCount,
isCommandToolName,
selectActiveToolCall
} from '../../../src/shared/native-chat-tool-activity'
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-message-styles'
const MAX_VISIBLE_TOOL_PAIRS = 6
const MAX_TOOL_RUN_DIFF_ROWS = 240
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>
)
}
/** 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`. */
function PulsingText({
style,
numberOfLines,
children
}: {
style?: React.ComponentProps<typeof Animated.Text>['style']
numberOfLines?: number
children: React.ReactNode
}): React.JSX.Element {
const pulse = useRef(new Animated.Value(1)).current
useEffect(() => {
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 })
])
)
animation.start()
return () => animation.stop()
}, [pulse])
return (
<Animated.Text style={[style, { opacity: pulse }]} numberOfLines={numberOfLines}>
{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. */
export function ToolRun({
blocks,
defaultExpanded,
expandChildren,
activeCall,
trailing,
onOpenFile
}: {
blocks: NativeChatBlock[]
defaultExpanded: boolean
/** Child tool lines stay collapsed when the turn caret drove the run open. */
expandChildren: boolean
/** The still-running call, when the turn is live (desktop parity). */
activeCall: ReturnType<typeof selectActiveToolCall>
trailing?: React.ReactNode
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element {
const [open, setOpen] = useState(defaultExpanded)
const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS)
const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1)))
let callCount = 0
for (const block of blocks) {
if (block.type === 'tool-call') {
callCount++
}
}
callCount ||= pairs.length
const summary = summarizeToolRun(blocks)
const ActiveToolIcon = activeCall && isCommandToolName(activeCall.name) ? SquareTerminal : Wrench
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}>{callCount}×</Text>
<Text style={styles.toolRunLabel} numberOfLines={1}>
{summary || formatToolCallCount(callCount)}
</Text>
</Pressable>
)}
{trailing}
</View>
{open ? (
<View style={styles.toolRunBody}>
{pairs.map((pair, i) => (
<ToolLine
key={i}
pair={pair}
defaultExpanded={expandChildren}
diffLineLimit={diffLineLimit}
onOpenFile={onOpenFile}
/>
))}
{callCount > pairs.length ? (
<Text style={styles.toolPreview}>… {callCount - pairs.length} more tool calls</Text>
) : null}
</View>
) : null}
</View>
)
}
@@ -0,0 +1,112 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
React.createElement('Text', props, children)
return {
Animated: {
Text,
Value: class {
constructor(private value: number) {}
setValue(next: number): void {
this.value = next
}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Pressable: ({ children, ...props }: { children?: unknown }) =>
React.createElement('Pressable', props, children),
Text,
View: ({ children, ...props }: { children?: unknown }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' }))
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
describe('MobileNativeChatTurnStatus', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-04T00:00:00Z'))
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
function render(props: {
startedAt: number | null
thinking: boolean
workedSeconds?: number | null
expanded?: boolean
onToggleExpanded?: () => void
}): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatTurnStatus, props))
})
return renderer!
}
const labels = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
it('reads "Thinking" before the turn produces output', () => {
const tree = render({ startedAt: Date.now(), thinking: true })
expect(labels(tree.root)).toEqual(['Thinking'])
})
it('counts up once the turn is producing output', () => {
const startedAt = Date.now()
const tree = render({ startedAt, thinking: false })
expect(labels(tree.root)).toEqual(['Working for 0s'])
act(() => {
vi.advanceTimersByTime(12_000)
})
expect(labels(tree.root)).toEqual(['Working for 12s'])
})
it('settles to a tappable "Worked for" row that toggles the turn', () => {
const onToggleExpanded = vi.fn()
const tree = render({
startedAt: Date.now(),
thinking: false,
workedSeconds: 184,
onToggleExpanded
})
expect(labels(tree.root)).toEqual(['Worked for 3m 4s'])
const button = tree.root.findByType('Pressable' as never)
expect(button.props.accessibilityLabel).toBe('Toggle turn details')
expect(button.props.accessibilityState).toEqual({ expanded: false })
act(() => button.props.onPress())
expect(onToggleExpanded).toHaveBeenCalledOnce()
})
it('stays a plain row when the settled turn has nothing to disclose', () => {
const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
expect(tree.root.findAllByType('Pressable' as never)).toHaveLength(0)
expect(labels(tree.root)).toEqual(['Worked for 5s'])
})
it('holds no interval once the turn has settled', () => {
render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 })
expect(vi.getTimerCount()).toBe(0)
})
it('announces the live row to assistive tech', () => {
const tree = render({ startedAt: Date.now(), thinking: true })
const row = tree.root.findByType('View' as never)
expect(row.props.accessibilityLiveRegion).toBe('polite')
expect(row.props.accessibilityLabel).toBe('Agent is responding')
})
})
@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from 'react'
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronRight } from 'lucide-react-native'
import {
formatNativeChatTurnStatusLabel,
NATIVE_CHAT_TURN_STATUS_COPY,
nativeChatElapsedSeconds
} from '../../../src/shared/native-chat-turn-status'
import { colors, spacing, typography } from '../theme/mobile-theme'
/** Seconds tick only while a turn is actually counting, so a settled transcript
* holds no timers. */
function useElapsedSeconds(startedAt: number | null, counting: boolean): number {
// Preserves the pre-stamp epoch for the frame before the turn's startedAt lands.
const [mountedAt] = useState(() => Date.now())
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!counting) {
return
}
setNow(Date.now())
const timer = setInterval(() => setNow(Date.now()), 1_000)
return () => clearInterval(timer)
}, [counting])
return counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0
}
/** The per-turn status row — "Thinking", then "Working for 12s" while the turn
* runs, settling to a tappable "Worked for 3m 4s" that discloses the turn's
* tool activity. Desktop parity: `NativeChatWorkingStatus`. */
export function MobileNativeChatTurnStatus({
startedAt,
thinking,
workedSeconds,
expanded = false,
onToggleExpanded
}: {
startedAt: number | null
thinking: boolean
workedSeconds?: number | null
expanded?: boolean
onToggleExpanded?: () => void
}): React.JSX.Element {
const counting = !thinking && workedSeconds == null
const elapsedSeconds = useElapsedSeconds(startedAt, counting)
const label = formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds })
const pulse = useRef(new Animated.Value(1)).current
useEffect(() => {
if (!thinking) {
pulse.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 })
])
)
animation.start()
return () => animation.stop()
}, [pulse, thinking])
const rowStyle = [styles.row, thinking ? null : styles.rowSettled]
if (workedSeconds != null && onToggleExpanded) {
return (
<Pressable
style={({ pressed }) => [...rowStyle, pressed && styles.pressed]}
onPress={onToggleExpanded}
hitSlop={6}
accessibilityRole="button"
accessibilityState={{ expanded }}
accessibilityLabel={NATIVE_CHAT_TURN_STATUS_COPY.toggleDetails}
>
<Text style={styles.label}>{label}</Text>
<View style={expanded ? styles.caretOpen : undefined}>
<ChevronRight size={14} color={colors.textMuted} strokeWidth={2} />
</View>
</Pressable>
)
}
return (
<View
style={rowStyle}
accessibilityLiveRegion="polite"
accessibilityLabel={NATIVE_CHAT_TURN_STATUS_COPY.responding}
>
<Animated.Text style={[styles.label, thinking && { opacity: pulse }]}>{label}</Animated.Text>
</View>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
minHeight: 28,
paddingHorizontal: spacing.md
},
rowSettled: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
pressed: {
opacity: 0.6
},
label: {
color: colors.textMuted,
fontSize: typography.bodySize
},
caretOpen: {
transform: [{ rotate: '90deg' }]
}
})
+41 -43
View File
@@ -21,16 +21,16 @@ import {
type MobileNativeChatPendingItem
} from './mobile-native-chat-render-data'
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import type { MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
const INPUT_LOCK_SETTLE_MS = 600
@@ -49,6 +49,9 @@ type Props = {
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
agent?: string | null
agentWorking?: boolean
/** Structured lane: per-turn "Working for N" status plus live tool progress,
* replacing the bridge lane's static three-dot working row (desktop parity). */
structuredActivityUi?: boolean
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
onStop?: () => void
/** Live partial assistant text to show as an in-progress bubble, already gated
@@ -126,6 +129,7 @@ export function MobileNativeChatView({
error,
agent,
agentWorking,
structuredActivityUi = false,
onStop,
streaming,
hasMore,
@@ -252,6 +256,14 @@ export function MobileNativeChatView({
listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true })
}, [])
// Per-turn "Thinking / Working for N / Worked for N" rows. The structured lane
// owns them; the bridge lane keeps its three-dot indicator.
const turns = useMobileNativeChatTurnDisclosure({
messages: data,
enabled: structuredActivityUi,
isWorking: agentWorking === true
})
const renderItem = useCallback(
({ item, index }: { item: NativeChatMessage; index: number }) => (
<MobileNativeChatMessage
@@ -261,9 +273,11 @@ export function MobileNativeChatView({
messageIndex={index}
onScrollToMessage={onScrollToMessage}
onOpenFile={onOpenFile}
structuredActivityUi={structuredActivityUi}
{...turns.resolveRow(index, item)}
/>
),
[toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
[toolsExpanded, fontScale, onScrollToMessage, onOpenFile, structuredActivityUi, turns]
)
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
@@ -337,6 +351,15 @@ export function MobileNativeChatView({
</Pressable>
) : null
}
ListFooterComponent={
turns.activeTurnIsUnanchored && turns.active ? (
<MobileNativeChatTurnStatus
startedAt={turns.active.startedAt}
thinking={turns.active.thinking}
workedSeconds={turns.active.workedSeconds}
/>
) : null
}
ListEmptyComponent={
emptyState ? (
<View style={styles.center}>
@@ -360,47 +383,22 @@ export function MobileNativeChatView({
) : null}
</GestureHandlerRootView>
)}
{/* Pending agent prompt: a structured AskUserQuestion wins, then a
heuristic permission, then a heuristic question. The controller owns
dismissal (it must survive this subtree unmounting on a view toggle);
`ask` arrives already nulled while dismissed. */}
{ask ? (
<MobileNativeChatAsk
key={askKey ?? 'ask'}
prompt={ask}
onAnswer={async (selections) => {
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
if (accepted) {
onDismissAsk?.()
}
return accepted
}}
onCancel={async () => {
const accepted = (await onCancelAsk?.()) ?? false
if (accepted) {
onDismissAsk?.()
}
return accepted
}}
/>
) : permission ? (
<MobileNativeChatPermission
key={JSON.stringify(permission)}
permission={permission}
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
/>
) : question ? (
<MobileNativeChatQuestion
key={mobileChatQuestionKey(question)}
question={question}
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
/>
) : null}
<MobileNativeChatPromptCard
ask={ask}
askKey={askKey}
onDismissAsk={onDismissAsk}
onAnswerAsk={onAnswerAsk}
onCancelAsk={onCancelAsk}
permission={permission}
onRespondPermission={onRespondPermission}
question={question}
onAnswerQuestion={onAnswerQuestion}
/>
{/* Chrome row above the composer: the working indicator and the global
tool-calls expand/collapse toggle on the left, Stop in the far corner. */}
<View style={styles.chromeRow}>
<View style={styles.chromeLeft}>
{agentWorking ? <MobileAgentWorkingIndicator /> : null}
{agentWorking && !structuredActivityUi ? <MobileAgentWorkingIndicator /> : null}
<Pressable
style={({ pressed }) => [styles.chromeToggle, pressed && styles.pressed]}
onPress={() => setToolsExpanded((v) => !v)}
@@ -25,6 +25,8 @@ export type MobileNativeChatController = {
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
/** Structured lane: drives the per-turn status row and live tool progress. */
nativeChatStructured: boolean
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
/** Agent mid-turn, regardless of whether chat is the visible view. */
@@ -80,6 +80,18 @@ export const styles = StyleSheet.create({
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,
@@ -10,9 +10,8 @@ import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
import { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
import { useMobileNativeChatSessionLane } from './use-mobile-native-chat-session-lane'
import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
@@ -82,27 +81,19 @@ export function useMobileNativeChatController(args: {
nativeChatTranscriptIsLocalReadable
})
const legacyNativeChatSession = useMobileNativeChatSession({
client,
sourceIdentity,
agent: activeChatStructured ? null : (activeChatResolution?.agent ?? null),
sessionId: activeChatStructured ? null : activeChatSessionId,
transcriptPath: activeChatStructured ? null : (activeChatResolution?.transcriptPath ?? null)
})
const structuredNativeChat = useMobileStructuredAgentSession({
client,
sessionId: activeChatStructured ? activeChatSessionId : null,
sourceIdentity,
enabled: showNativeChat,
// Holds are connection-scoped; dropping this on transport loss lets the hook
// reacquire the provider without clearing the cached transcript.
connected: connState === 'connected',
agent: activeChatStructured ? activeChatAgent : null,
onSendError
})
const nativeChatSession = activeChatStructured
? structuredNativeChat.session
: legacyNativeChatSession
const { structuredSession: structuredNativeChat, session: nativeChatSession } =
useMobileNativeChatSessionLane({
client,
structured: activeChatStructured,
agent: activeChatAgent,
resolvedAgent: activeChatResolution?.agent ?? null,
transcriptPath: activeChatResolution?.transcriptPath ?? null,
sessionId: activeChatSessionId,
sourceIdentity,
enabled: showNativeChat,
connState,
onSendError
})
const {
composerText: chatComposerText,
setComposerText: setChatComposerText,
@@ -303,6 +294,8 @@ export function useMobileNativeChatController(args: {
chatPending,
chatImagePreviewsByMessageId,
nativeChatSession,
/** Structured lane: drives the per-turn status row and live tool progress. */
nativeChatStructured: activeChatStructured,
nativeChatAgentWorking,
nativeChatStreamingText,
nativeChatStreamLive,
@@ -0,0 +1,59 @@
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
/** Mounts both transcript sources and hands back the one this tab's lane owns.
* Both hooks always run (hook order is fixed); the inactive lane is starved of
* its identity inputs rather than unmounted, so a lane flip keeps its cache. */
export function useMobileNativeChatSessionLane({
client,
structured,
agent,
resolvedAgent,
transcriptPath,
sessionId,
sourceIdentity,
enabled,
connState,
onSendError
}: {
client: RpcClient | null
structured: boolean
/** Agent id for the structured provider session. */
agent: string | null
/** Agent resolved from the terminal, for the bridge transcript reader. */
resolvedAgent: string | null
transcriptPath: string | null
sessionId: string | null
sourceIdentity: Parameters<typeof useMobileNativeChatSession>[0]['sourceIdentity']
enabled: boolean
connState: ConnectionState
onSendError: (message: string) => void
}): {
structuredSession: ReturnType<typeof useMobileStructuredAgentSession>
session: ReturnType<typeof useMobileNativeChatSession>
} {
const bridgeSession = useMobileNativeChatSession({
client,
sourceIdentity,
agent: structured ? null : resolvedAgent,
sessionId: structured ? null : sessionId,
transcriptPath: structured ? null : transcriptPath
})
const structuredSession = useMobileStructuredAgentSession({
client,
sessionId: structured ? sessionId : null,
sourceIdentity,
enabled,
// Holds are connection-scoped; dropping this on transport loss lets the hook
// reacquire the provider without clearing the cached transcript.
connected: connState === 'connected',
agent: structured ? agent : null,
onSendError
})
return {
structuredSession,
session: structured ? structuredSession.session : bridgeSession
}
}
@@ -0,0 +1,100 @@
import { useCallback, useMemo, useState } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
MOBILE_UNANCHORED_TURN_KEY,
useMobileNativeChatTurnStatus,
type NativeChatTurnStatus
} from './use-mobile-native-chat-turn-status'
export type MobileNativeChatTurnRow = {
turnStatus: NativeChatTurnStatus | null
turnExpanded: boolean
onToggleTurn?: () => void
activeTurnIsWorking: boolean
}
/** Owns the transcript's per-turn status rows and their disclosure state, and
* resolves what one list row needs. Bridge-lane chats pass `enabled: false` and
* keep their single three-dot working indicator instead. */
export function useMobileNativeChatTurnDisclosure({
messages,
enabled,
isWorking
}: {
messages: readonly NativeChatMessage[]
enabled: boolean
isWorking: boolean
}): {
active: NativeChatTurnStatus | null
/** True when the live turn has no user message to hang its status row under. */
activeTurnIsUnanchored: boolean
resolveRow: (index: number, message: NativeChatMessage) => MobileNativeChatTurnRow
} {
const turnStatuses = useMobileNativeChatTurnStatus({
messages,
isWorking: enabled && isWorking
})
const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<string>>(() => new Set())
const toggleExpandedTurn = useCallback((turnKey: string) => {
setExpandedTurnIds((current) => {
const next = new Set(current)
if (!next.delete(turnKey)) {
next.add(turnKey)
}
return next
})
}, [])
// Resolve each row's turn boundary once — a findLast per row is quadratic on a
// long transcript.
const turnKeys = useMemo(() => {
let turnKey: string | undefined
return messages.map((message) => {
if (message.role === 'user') {
turnKey = message.id
}
return turnKey
})
}, [messages])
const { active, activeTurnKey, completedByTurn } = turnStatuses
const resolveRow = useCallback(
(index: number, message: NativeChatMessage): MobileNativeChatTurnRow => {
const turnKey = turnKeys[index]
const turnStatus =
!enabled || message.role !== 'user'
? null
: turnKey === activeTurnKey
? active
: turnKey
? (completedByTurn[turnKey] ?? null)
: null
return {
turnStatus,
turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false,
onToggleTurn: turnKey ? () => toggleExpandedTurn(turnKey) : undefined,
// A missing turn boundary is not evidence the turn ended; the session's
// own working state stays authoritative.
activeTurnIsWorking:
enabled && (turnKey === undefined || turnKey === activeTurnKey) && isWorking
}
},
[
turnKeys,
enabled,
activeTurnKey,
active,
completedByTurn,
expandedTurnIds,
toggleExpandedTurn,
isWorking
]
)
return {
active,
activeTurnIsUnanchored:
enabled && active != null && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY,
resolveRow
}
}
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
nativeChatTurnHasResponse,
reduceNativeChatTurnTiming,
selectNativeChatTurnStatuses,
type NativeChatTurnStatus,
type NativeChatTurnTimingByTurn
} from '../../../src/shared/native-chat-turn-status'
export type { NativeChatTurnStatus }
export const MOBILE_UNANCHORED_TURN_KEY = '__unanchored__'
/** Per-turn "Thinking / Working for N / Worked for N" timing, on the same shared
* state machine the desktop renderer uses so the two surfaces stamp turns alike. */
export function useMobileNativeChatTurnStatus({
messages,
isWorking,
workingStartedAt
}: {
messages: readonly NativeChatMessage[]
isWorking: boolean
workingStartedAt?: number | null
}): {
active: NativeChatTurnStatus | null
completedByTurn: Readonly<Record<string, NativeChatTurnStatus>>
activeTurnKey: string
} {
const latestUserIndex = messages.findLastIndex((message) => message.role === 'user')
const hasCurrentTurnResponse = nativeChatTurnHasResponse(messages, latestUserIndex)
const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null
const activeTurnKey = latestUserId ?? MOBILE_UNANCHORED_TURN_KEY
const [timingByTurn, setTimingByTurn] = useState<NativeChatTurnTimingByTurn>({})
useEffect(() => {
const validTurnKeys = new Set(
messages.filter((message) => message.role === 'user').map((message) => message.id)
)
setTimingByTurn((current) =>
reduceNativeChatTurnTiming(current, {
activeTurnKey,
validTurnKeys,
isWorking,
workingStartedAt,
now: Date.now()
})
)
}, [activeTurnKey, isWorking, messages, workingStartedAt])
return {
...selectNativeChatTurnStatuses(timingByTurn, {
activeTurnKey,
isWorking,
workingStartedAt,
hasCurrentTurnResponse
}),
activeTurnKey
}
}
@@ -14,47 +14,24 @@ import {
summarizeToolRun,
truncateToolDetail
} from './native-chat-tool-summary'
import {
describeActiveToolCall,
isCommandToolName,
NATIVE_CHAT_TOOL_ACTIVITY_COPY,
selectActiveToolCall
} from '../../../../shared/native-chat-tool-activity'
import { NativeChatDiffView } from './NativeChatDiffView'
const COMMAND_TOOL_NAMES = new Set([
'bash',
'shell',
'powershell',
'terminal',
'execute',
'run_command',
'run_shell_command',
'shell_command',
'exec_command',
'run_terminal_cmd',
'run_terminal_command'
])
function normalizedToolName(name: string): string {
return name.trim().toLowerCase()
}
function activeToolLabel(call: Extract<NativeChatBlock, { type: 'tool-call' }>): string {
const preview = createToolInputDisplay(call.input).label
if (COMMAND_TOOL_NAMES.has(normalizedToolName(call.name))) {
return preview
? translate('components.native-chat.tool.runningPreview', 'Running {{preview}}', {
preview
})
: translate('components.native-chat.tool.runningCommand', 'Running command')
}
return preview
? translate(
'components.native-chat.tool.runningNamedPreview',
'Running {{toolName}} {{preview}}',
{
toolName: call.name,
preview
}
)
: translate('components.native-chat.tool.runningNamed', 'Running {{toolName}}', {
toolName: call.name
})
const { key, toolName, preview } = describeActiveToolCall(call)
const copy = NATIVE_CHAT_TOOL_ACTIVITY_COPY[key]
return key === 'runningPreview'
? translate('components.native-chat.tool.runningPreview', copy, { preview })
: key === 'runningCommand'
? translate('components.native-chat.tool.runningCommand', copy)
: key === 'runningNamedPreview'
? translate('components.native-chat.tool.runningNamedPreview', copy, { toolName, preview })
: translate('components.native-chat.tool.runningNamed', copy, { toolName })
}
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
@@ -176,27 +153,19 @@ export function NativeChatToolRun({
const callCount = countToolCalls(blocks) || blocks.length
const summary = summarizeToolRun(blocks)
const calls = blocks.filter(isToolCallBlock)
const activeCalls = structuredActivityUi
? calls.filter(
(call) =>
(call.state === 'running' || (call.state == null && activeTurnIsWorking === true)) &&
activeTurnIsWorking !== false
)
: []
const latestActiveCall = activeCalls.at(-1)
const latestActiveCall = structuredActivityUi
? selectActiveToolCall(blocks, { activeTurnIsWorking })
: null
const isSettled = latestActiveCall == null
// The turn caret opens the activity group, while each child tool remains
// collapsed. The global expand toolbar still opens child details together.
const expandToolLines = expandOverride === undefined ? open : false
const ActiveToolIcon =
latestActiveCall && COMMAND_TOOL_NAMES.has(normalizedToolName(latestActiveCall.name))
? SquareTerminal
: Wrench
latestActiveCall && isCommandToolName(latestActiveCall.name) ? SquareTerminal : Wrench
const fallbackLabel =
callCount === 1
? translate('components.native-chat.tool.countOne', '1 tool call')
: translate('components.native-chat.tool.countN', '{{value0}} tool calls', {
? translate('components.native-chat.tool.countOne', NATIVE_CHAT_TOOL_ACTIVITY_COPY.countOne)
: translate('components.native-chat.tool.countN', NATIVE_CHAT_TOOL_ACTIVITY_COPY.countN, {
value0: callCount
})
@@ -2,21 +2,14 @@ import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { useNow } from '@/hooks/use-now'
import {
describeNativeChatTurnStatus,
formatNativeChatDuration,
NATIVE_CHAT_TURN_STATUS_COPY,
nativeChatElapsedSeconds
} from '../../../../shared/native-chat-turn-status'
/** Format turn time without exposing an ever-growing raw seconds count. */
export function formatNativeChatDuration(seconds: number): string {
const totalSeconds = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0
if (totalSeconds < 60) {
return `${totalSeconds}s`
}
const minutes = Math.floor(totalSeconds / 60)
const remainingSeconds = totalSeconds % 60
if (minutes < 60) {
return `${minutes}m ${remainingSeconds}s`
}
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m ${remainingSeconds}s`
}
export { formatNativeChatDuration }
export function NativeChatWorkingStatus({
startedAt,
@@ -39,21 +32,29 @@ export function NativeChatWorkingStatus({
// Why: preserves the old effect's `startedAt ?? Date.now()` epoch for the
// single frame before the turn's startedAt lands.
const [mountedAt] = useState(() => Date.now())
const elapsedSeconds = counting
? Math.max(0, Math.floor((now - (startedAt ?? mountedAt)) / 1000))
: 0
const elapsedSeconds = counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0
const { key, duration } = describeNativeChatTurnStatus({
thinking,
workedSeconds,
elapsedSeconds
})
const label =
workedSeconds != null
? translate('components.native-chat.status.workedFor', 'Worked for {{value0}}', {
value0: formatNativeChatDuration(workedSeconds)
})
: thinking
? translate('components.native-chat.status.thinking', 'Thinking')
: translate('components.native-chat.status.workingFor', 'Working for {{value0}}', {
value0: formatNativeChatDuration(elapsedSeconds)
})
key === 'workedFor'
? translate(
'components.native-chat.status.workedFor',
NATIVE_CHAT_TURN_STATUS_COPY.workedFor,
{
value0: duration
}
)
: key === 'thinking'
? translate('components.native-chat.status.thinking', NATIVE_CHAT_TURN_STATUS_COPY.thinking)
: translate(
'components.native-chat.status.workingFor',
NATIVE_CHAT_TURN_STATUS_COPY.workingFor,
{ value0: duration }
)
const className = `flex min-h-8 items-center gap-1 text-sm text-muted-foreground${thinking ? '' : ' border-b border-border'}`
const caret =
workedSeconds != null ? (
@@ -67,7 +68,10 @@ export function NativeChatWorkingStatus({
<button
type="button"
className={`${className} w-full text-left hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70`}
aria-label={translate('components.native-chat.status.toggleDetails', 'Toggle turn details')}
aria-label={translate(
'components.native-chat.status.toggleDetails',
NATIVE_CHAT_TURN_STATUS_COPY.toggleDetails
)}
aria-expanded={expanded}
onClick={onToggleExpanded}
>
@@ -80,7 +84,10 @@ export function NativeChatWorkingStatus({
return (
<div
className={className}
aria-label={translate('components.native-chat.status.responding', 'Agent is responding')}
aria-label={translate(
'components.native-chat.status.responding',
NATIVE_CHAT_TURN_STATUS_COPY.responding
)}
aria-live="polite"
>
<span className={thinking ? 'animate-pulse' : undefined}>{label}</span>
@@ -1,16 +1,14 @@
import { useLayoutEffect, useState } from 'react'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import {
nativeChatTurnHasResponse,
reduceNativeChatTurnTiming,
selectNativeChatTurnStatuses,
type NativeChatTurnStatus,
type NativeChatTurnTimingByTurn
} from '../../../../shared/native-chat-turn-status'
type NativeChatTurnTiming = {
startedAt: number
workedSeconds: number | null
}
export type NativeChatTurnStatus = {
startedAt: number | null
thinking: boolean
workedSeconds: number | null
}
export type { NativeChatTurnStatus }
export function useNativeChatTurnStatus({
messages,
@@ -26,95 +24,30 @@ export function useNativeChatTurnStatus({
active: NativeChatTurnStatus | null
completedByTurn: Readonly<Record<string, NativeChatTurnStatus>>
} {
const currentTurnMessages = messages.slice(latestUserIndex + 1)
const hasCurrentTurnResponse = currentTurnMessages.some(
(message) =>
(message.role === 'assistant' || message.role === 'tool') &&
message.blocks.some(
(block) =>
block.type === 'tool-call' ||
block.type === 'tool-result' ||
(block.type === 'text' && block.text.trim().length > 0)
)
)
const hasCurrentTurnResponse = nativeChatTurnHasResponse(messages, latestUserIndex)
const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null
const activeTurnKey = latestUserId ?? '__unanchored__'
const [timingByTurn, setTimingByTurn] = useState<Record<string, NativeChatTurnTiming>>({})
const [timingByTurn, setTimingByTurn] = useState<NativeChatTurnTimingByTurn>({})
useLayoutEffect(() => {
const validTurnKeys = new Set(
messages.filter((message) => message.role === 'user').map((message) => message.id)
)
validTurnKeys.add(activeTurnKey)
if (isWorking) {
setTimingByTurn((current) => {
let retained = current
for (const turnKey of Object.keys(current)) {
if (!validTurnKeys.has(turnKey)) {
if (retained === current) {
retained = { ...current }
}
delete retained[turnKey]
}
}
const timing = retained[activeTurnKey]
const startedAt =
workingStartedAt ??
(timing?.workedSeconds == null && timing ? timing.startedAt : Date.now())
if (timing?.startedAt === startedAt && timing.workedSeconds == null) {
return retained
}
const next = { ...retained }
next[activeTurnKey] = { startedAt, workedSeconds: null }
return next
setTimingByTurn((current) =>
reduceNativeChatTurnTiming(current, {
activeTurnKey,
validTurnKeys,
isWorking,
workingStartedAt,
now: Date.now()
})
return
}
setTimingByTurn((current) => {
let retained = current
for (const turnKey of Object.keys(current)) {
if (!validTurnKeys.has(turnKey)) {
if (retained === current) {
retained = { ...current }
}
delete retained[turnKey]
}
}
const timing = retained[activeTurnKey]
if (timing?.workedSeconds != null) {
return retained
}
const startedAt = timing?.startedAt ?? workingStartedAt
if (startedAt == null) {
return retained
}
return {
...retained,
[activeTurnKey]: {
startedAt,
workedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
}
}
})
)
}, [activeTurnKey, isWorking, messages, workingStartedAt])
const currentTiming = timingByTurn[activeTurnKey]
const completedByTurn = Object.fromEntries(
Object.entries(timingByTurn)
.filter(([, timing]) => timing.workedSeconds != null)
.map(([turnKey, timing]) => [
turnKey,
{ startedAt: timing.startedAt, thinking: false, workedSeconds: timing.workedSeconds }
])
)
return {
active: isWorking
? {
startedAt: workingStartedAt ?? currentTiming?.startedAt ?? null,
thinking: !hasCurrentTurnResponse,
workedSeconds: null
}
: (completedByTurn[activeTurnKey] ?? null),
completedByTurn
}
return selectNativeChatTurnStatuses(timingByTurn, {
activeTurnKey,
isWorking,
workingStartedAt,
hasCurrentTurnResponse
})
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatBlock } from './native-chat-types'
import {
describeActiveToolCall,
formatActiveToolLabel,
formatToolCallCount,
isCommandToolName,
selectActiveToolCall
} from './native-chat-tool-activity'
function call(
name: string,
input: unknown,
state?: 'running' | 'completed' | 'failed'
): Extract<NativeChatBlock, { type: 'tool-call' }> {
return { type: 'tool-call', name, input, ...(state ? { state } : {}) } as Extract<
NativeChatBlock,
{ type: 'tool-call' }
>
}
describe('isCommandToolName', () => {
it('matches the shell-running tools regardless of case or padding', () => {
expect(isCommandToolName('bash')).toBe(true)
expect(isCommandToolName(' Bash ')).toBe(true)
expect(isCommandToolName('run_terminal_cmd')).toBe(true)
})
it('does not match a named tool', () => {
expect(isCommandToolName('Read')).toBe(false)
expect(isCommandToolName('')).toBe(false)
})
})
describe('describeActiveToolCall / formatActiveToolLabel', () => {
it('drops the tool name for a shell command with a preview', () => {
const descriptor = describeActiveToolCall(call('Bash', { command: 'npm test' }))
expect(descriptor.key).toBe('runningPreview')
expect(descriptor.isCommand).toBe(true)
expect(formatActiveToolLabel(descriptor)).toBe('Running npm test')
})
it('falls back to a generic command label when there is no preview', () => {
const descriptor = describeActiveToolCall(call('bash', null))
expect(descriptor.key).toBe('runningCommand')
expect(formatActiveToolLabel(descriptor)).toBe('Running command')
})
it('keeps the tool name for a non-command tool', () => {
const descriptor = describeActiveToolCall(call('Read', { file_path: 'a/b.ts' }))
expect(descriptor.key).toBe('runningNamedPreview')
expect(descriptor.isCommand).toBe(false)
expect(formatActiveToolLabel(descriptor)).toBe('Running Read a/b.ts')
})
it('names a previewless tool on its own', () => {
const descriptor = describeActiveToolCall(call('Think', null))
expect(descriptor.key).toBe('runningNamed')
expect(formatActiveToolLabel(descriptor)).toBe('Running Think')
})
})
describe('selectActiveToolCall', () => {
it('returns nothing once the turn is known to have ended', () => {
const blocks = [call('Bash', { command: 'x' }, 'running')]
expect(selectActiveToolCall(blocks, { activeTurnIsWorking: false })).toBeNull()
})
it('picks the latest explicitly running call', () => {
const blocks = [
call('Read', { file_path: 'a' }, 'completed'),
call('Bash', { command: 'x' }, 'running'),
call('Grep', { pattern: 'y' }, 'running')
]
expect(selectActiveToolCall(blocks, { activeTurnIsWorking: true })?.name).toBe('Grep')
})
it('ignores settled calls even while the turn works', () => {
const blocks = [call('Read', { file_path: 'a' }, 'completed')]
expect(selectActiveToolCall(blocks, { activeTurnIsWorking: true })).toBeNull()
})
it('treats a lifecycle-less call as running only while the turn works', () => {
const blocks = [call('Read', { file_path: 'a' })]
expect(selectActiveToolCall(blocks, { activeTurnIsWorking: true })?.name).toBe('Read')
expect(selectActiveToolCall(blocks, { activeTurnIsWorking: undefined })).toBeNull()
})
it('still surfaces an explicitly running call when the turn state is unknown', () => {
const blocks = [call('Bash', { command: 'x' }, 'running')]
expect(selectActiveToolCall(blocks, { activeTurnIsWorking: undefined })?.name).toBe('Bash')
})
it('skips non-tool-call blocks', () => {
const blocks: NativeChatBlock[] = [
{ type: 'text', text: 'hello' },
call('Bash', { command: 'x' }, 'running')
]
expect(selectActiveToolCall(blocks, { activeTurnIsWorking: true })?.name).toBe('Bash')
})
})
describe('formatToolCallCount', () => {
it('singularizes one call', () => {
expect(formatToolCallCount(1)).toBe('1 tool call')
expect(formatToolCallCount(4)).toBe('4 tool calls')
expect(formatToolCallCount(0)).toBe('0 tool calls')
})
})
+97
View File
@@ -0,0 +1,97 @@
// Live tool-activity derivation and copy for the native-chat "Running …" row,
// shared by the desktop renderer (as its i18n fallback strings) and the mobile
// app (used directly — mobile ships English only) so the two surfaces never drift.
import { createToolInputDisplay } from './native-chat-tool-summary'
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
type NativeChatToolCallBlock = Extract<NativeChatBlock, { type: 'tool-call' }>
export const NATIVE_CHAT_TOOL_ACTIVITY_COPY = {
runningPreview: 'Running {{preview}}',
runningCommand: 'Running command',
runningNamedPreview: 'Running {{toolName}} {{preview}}',
runningNamed: 'Running {{toolName}}',
countOne: '1 tool call',
countN: '{{value0}} tool calls'
} as const
/** Tools whose call is a shell command, so the row reads as terminal activity
* (and takes the terminal glyph) rather than a named tool invocation. */
export const COMMAND_TOOL_NAMES: ReadonlySet<string> = new Set([
'bash',
'shell',
'powershell',
'terminal',
'execute',
'run_command',
'run_shell_command',
'shell_command',
'exec_command',
'run_terminal_cmd',
'run_terminal_command'
])
export function isCommandToolName(name: string): boolean {
return COMMAND_TOOL_NAMES.has(name.trim().toLowerCase())
}
export type NativeChatActiveToolDescriptor = {
key: 'runningPreview' | 'runningCommand' | 'runningNamedPreview' | 'runningNamed'
toolName: string
preview: string
isCommand: boolean
}
/** Which copy key and arguments the active-tool row renders for a running call. */
export function describeActiveToolCall(
call: NativeChatToolCallBlock
): NativeChatActiveToolDescriptor {
const preview = createToolInputDisplay(call.input).label
const isCommand = isCommandToolName(call.name)
const key = isCommand
? preview
? 'runningPreview'
: 'runningCommand'
: preview
? 'runningNamedPreview'
: 'runningNamed'
return { key, toolName: call.name, preview, isCommand }
}
/** Resolve the active-tool label in English. For platforms without i18n (mobile). */
export function formatActiveToolLabel(descriptor: NativeChatActiveToolDescriptor): string {
return NATIVE_CHAT_TOOL_ACTIVITY_COPY[descriptor.key]
.replaceAll('{{preview}}', descriptor.preview)
.replaceAll('{{toolName}}', descriptor.toolName)
}
/** The most recent still-running call in a run, or null once the run is settled.
* A block without lifecycle `state` only counts while the turn is known to be
* working, so a restored transcript never spins on an orphaned call. */
export function selectActiveToolCall(
blocks: readonly NativeChatBlock[],
{ activeTurnIsWorking }: { activeTurnIsWorking?: boolean }
): NativeChatToolCallBlock | null {
if (activeTurnIsWorking === false) {
return null
}
const calls = blocks.filter(isToolCallBlock)
for (let index = calls.length - 1; index >= 0; index--) {
const call = calls[index]
if (
call &&
(call.state === 'running' || (call.state == null && activeTurnIsWorking === true))
) {
return call
}
}
return null
}
/** Fallback summary when no per-tool summary is available. */
export function formatToolCallCount(callCount: number): string {
return callCount === 1
? NATIVE_CHAT_TOOL_ACTIVITY_COPY.countOne
: NATIVE_CHAT_TOOL_ACTIVITY_COPY.countN.replaceAll('{{value0}}', String(callCount))
}
+248
View File
@@ -0,0 +1,248 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from './native-chat-types'
import {
describeNativeChatTurnStatus,
formatNativeChatDuration,
formatNativeChatTurnStatusLabel,
nativeChatElapsedSeconds,
nativeChatTurnHasResponse,
reduceNativeChatTurnTiming,
selectNativeChatTurnStatuses,
type NativeChatTurnTimingByTurn
} from './native-chat-turn-status'
function message(
id: string,
role: NativeChatMessage['role'],
blocks: NativeChatMessage['blocks']
): NativeChatMessage {
return { id, role, blocks, timestamp: null, source: 'transcript' }
}
describe('formatNativeChatDuration', () => {
it.each([
[0, '0s'],
[12, '12s'],
[59, '59s'],
[60, '1m 0s'],
[184, '3m 4s'],
[3600, '1h 0m 0s'],
[3723, '1h 2m 3s']
])('formats %i seconds as %s', (seconds, expected) => {
expect(formatNativeChatDuration(seconds)).toBe(expected)
})
it('floors a fractional count and clamps a negative or non-finite one', () => {
expect(formatNativeChatDuration(12.9)).toBe('12s')
expect(formatNativeChatDuration(-5)).toBe('0s')
expect(formatNativeChatDuration(Number.NaN)).toBe('0s')
})
})
describe('describeNativeChatTurnStatus', () => {
it('prefers the settled duration over the thinking and counting labels', () => {
expect(
describeNativeChatTurnStatus({ thinking: true, workedSeconds: 184, elapsedSeconds: 9 })
).toEqual({ key: 'workedFor', duration: '3m 4s' })
})
it('reports thinking before the turn produces output', () => {
expect(
describeNativeChatTurnStatus({ thinking: true, workedSeconds: null, elapsedSeconds: 9 })
).toEqual({ key: 'thinking', duration: null })
})
it('counts once the turn has output', () => {
expect(
describeNativeChatTurnStatus({ thinking: false, workedSeconds: null, elapsedSeconds: 12 })
).toEqual({ key: 'workingFor', duration: '12s' })
})
})
describe('formatNativeChatTurnStatusLabel', () => {
it('renders each state in English for platforms without i18n', () => {
expect(
formatNativeChatTurnStatusLabel({ thinking: true, workedSeconds: null, elapsedSeconds: 0 })
).toBe('Thinking')
expect(
formatNativeChatTurnStatusLabel({ thinking: false, workedSeconds: null, elapsedSeconds: 12 })
).toBe('Working for 12s')
expect(
formatNativeChatTurnStatusLabel({ thinking: false, workedSeconds: 184, elapsedSeconds: 0 })
).toBe('Worked for 3m 4s')
})
})
describe('nativeChatTurnHasResponse', () => {
const user = message('u1', 'user', [{ type: 'text', text: 'go' }])
it('is false while the turn has produced nothing', () => {
expect(nativeChatTurnHasResponse([user], 0)).toBe(false)
})
it('ignores a whitespace-only assistant block', () => {
const blank = message('a1', 'assistant', [{ type: 'text', text: ' \n ' }])
expect(nativeChatTurnHasResponse([user, blank], 0)).toBe(false)
})
it('is true on the first real text, tool call, or tool result', () => {
expect(
nativeChatTurnHasResponse(
[user, message('a1', 'assistant', [{ type: 'text', text: 'hi' }])],
0
)
).toBe(true)
expect(
nativeChatTurnHasResponse(
[user, message('t1', 'tool', [{ type: 'tool-call', name: 'Read', input: {} }])],
0
)
).toBe(true)
expect(
nativeChatTurnHasResponse(
[user, message('t1', 'tool', [{ type: 'tool-result', output: 'ok' }])],
0
)
).toBe(true)
})
it('does not count output that preceded the latest user turn', () => {
const earlier = message('a0', 'assistant', [{ type: 'text', text: 'old' }])
expect(nativeChatTurnHasResponse([earlier, user], 1)).toBe(false)
})
})
describe('reduceNativeChatTurnTiming', () => {
const validTurnKeys = new Set(['u1'])
it('stamps a start when a turn begins working', () => {
const next = reduceNativeChatTurnTiming(
{},
{ activeTurnKey: 'u1', validTurnKeys, isWorking: true, now: 1_000 }
)
expect(next).toEqual({ u1: { startedAt: 1_000, workedSeconds: null } })
})
it('keeps the original start across later working ticks', () => {
const first = reduceNativeChatTurnTiming(
{},
{ activeTurnKey: 'u1', validTurnKeys, isWorking: true, now: 1_000 }
)
const second = reduceNativeChatTurnTiming(first, {
activeTurnKey: 'u1',
validTurnKeys,
isWorking: true,
now: 9_000
})
expect(second).toBe(first)
})
it('prefers an authoritative host start over the local stamp', () => {
const next = reduceNativeChatTurnTiming(
{},
{
activeTurnKey: 'u1',
validTurnKeys,
isWorking: true,
workingStartedAt: 500,
now: 1_000
}
)
expect(next.u1?.startedAt).toBe(500)
})
it('settles the turn to whole elapsed seconds when work stops', () => {
const working = reduceNativeChatTurnTiming(
{},
{ activeTurnKey: 'u1', validTurnKeys, isWorking: true, now: 1_000 }
)
const settled = reduceNativeChatTurnTiming(working, {
activeTurnKey: 'u1',
validTurnKeys,
isWorking: false,
now: 13_400
})
expect(settled.u1).toEqual({ startedAt: 1_000, workedSeconds: 12 })
})
it('never re-settles an already settled turn', () => {
const settled: NativeChatTurnTimingByTurn = { u1: { startedAt: 1_000, workedSeconds: 12 } }
expect(
reduceNativeChatTurnTiming(settled, {
activeTurnKey: 'u1',
validTurnKeys,
isWorking: false,
now: 99_000
})
).toBe(settled)
})
it('does not invent a settled turn that never started', () => {
expect(
reduceNativeChatTurnTiming(
{},
{ activeTurnKey: 'u1', validTurnKeys, isWorking: false, now: 1_000 }
)
).toEqual({})
})
it('drops timings for turns that left the transcript, keeping the active one', () => {
const current: NativeChatTurnTimingByTurn = {
gone: { startedAt: 1, workedSeconds: 2 },
u1: { startedAt: 1_000, workedSeconds: 12 }
}
const next = reduceNativeChatTurnTiming(current, {
activeTurnKey: 'u1',
validTurnKeys,
isWorking: false,
now: 2_000
})
expect(Object.keys(next)).toEqual(['u1'])
})
})
describe('selectNativeChatTurnStatuses', () => {
it('reports the working turn as thinking until it produces output', () => {
const { active } = selectNativeChatTurnStatuses(
{ u1: { startedAt: 1_000, workedSeconds: null } },
{ activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: false }
)
expect(active).toEqual({ startedAt: 1_000, thinking: true, workedSeconds: null })
})
it('stops thinking once the turn has output', () => {
const { active } = selectNativeChatTurnStatuses(
{ u1: { startedAt: 1_000, workedSeconds: null } },
{ activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: true }
)
expect(active?.thinking).toBe(false)
})
it('exposes settled turns and resolves the active one from them when idle', () => {
const { active, completedByTurn } = selectNativeChatTurnStatuses(
{ u1: { startedAt: 1_000, workedSeconds: 12 } },
{ activeTurnKey: 'u1', isWorking: false, hasCurrentTurnResponse: true }
)
expect(completedByTurn.u1).toEqual({ startedAt: 1_000, thinking: false, workedSeconds: 12 })
expect(active).toEqual(completedByTurn.u1)
})
it('omits an in-flight turn from the completed map', () => {
const { completedByTurn } = selectNativeChatTurnStatuses(
{ u1: { startedAt: 1_000, workedSeconds: null } },
{ activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: true }
)
expect(completedByTurn).toEqual({})
})
})
describe('nativeChatElapsedSeconds', () => {
it('falls back to the mount epoch before the turn start lands', () => {
expect(nativeChatElapsedSeconds(null, 1_000, 5_400)).toBe(4)
expect(nativeChatElapsedSeconds(2_000, 1_000, 5_400)).toBe(3)
})
it('never counts backwards', () => {
expect(nativeChatElapsedSeconds(9_000, 1_000, 5_000)).toBe(0)
})
})
+194
View File
@@ -0,0 +1,194 @@
// Turn-status derivation and copy for the native-chat "Thinking / Working for N /
// Worked for N" row, shared by the desktop renderer (as its i18n fallback strings)
// and the mobile app (used directly — mobile ships English only) so the two
// surfaces never drift. Everything here is pure; each platform owns its own clock.
import type { NativeChatMessage } from './native-chat-types'
export const NATIVE_CHAT_TURN_STATUS_COPY = {
thinking: 'Thinking',
workingFor: 'Working for {{value0}}',
workedFor: 'Worked for {{value0}}',
toggleDetails: 'Toggle turn details',
responding: 'Agent is responding'
} as const
/** Format turn time without exposing an ever-growing raw seconds count. */
export function formatNativeChatDuration(seconds: number): string {
const totalSeconds = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0
if (totalSeconds < 60) {
return `${totalSeconds}s`
}
const minutes = Math.floor(totalSeconds / 60)
const remainingSeconds = totalSeconds % 60
if (minutes < 60) {
return `${minutes}m ${remainingSeconds}s`
}
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m ${remainingSeconds}s`
}
/** Which of the three copy keys a turn-status row renders, and its duration
* argument. Desktop maps this onto `translate`; mobile formats it directly. */
export function describeNativeChatTurnStatus({
thinking,
workedSeconds,
elapsedSeconds
}: {
thinking: boolean
workedSeconds?: number | null
elapsedSeconds: number
}): { key: 'thinking' | 'workingFor' | 'workedFor'; duration: string | null } {
if (workedSeconds != null) {
return { key: 'workedFor', duration: formatNativeChatDuration(workedSeconds) }
}
if (thinking) {
return { key: 'thinking', duration: null }
}
return { key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) }
}
/** Resolve the turn-status label in English. For platforms without i18n (mobile). */
export function formatNativeChatTurnStatusLabel(input: {
thinking: boolean
workedSeconds?: number | null
elapsedSeconds: number
}): string {
const { key, duration } = describeNativeChatTurnStatus(input)
const copy = NATIVE_CHAT_TURN_STATUS_COPY[key]
return duration == null ? copy : copy.replaceAll('{{value0}}', duration)
}
/** True once the current turn has produced anything renderable — the boundary
* between the "Thinking" label and the counting "Working for N" label. */
export function nativeChatTurnHasResponse(
messages: readonly NativeChatMessage[],
latestUserIndex: number
): boolean {
return messages
.slice(latestUserIndex + 1)
.some(
(message) =>
(message.role === 'assistant' || message.role === 'tool') &&
message.blocks.some(
(block) =>
block.type === 'tool-call' ||
block.type === 'tool-result' ||
(block.type === 'text' && block.text.trim().length > 0)
)
)
}
export type NativeChatTurnTiming = {
startedAt: number
workedSeconds: number | null
}
export type NativeChatTurnStatus = {
startedAt: number | null
thinking: boolean
workedSeconds: number | null
}
export type NativeChatTurnTimingByTurn = Readonly<Record<string, NativeChatTurnTiming>>
/** The turn-timing state machine, lifted out of the React hook so desktop and
* mobile stamp start/stop identically. Returns the same reference when nothing
* changed so callers can bail out of a state update. */
export function reduceNativeChatTurnTiming(
current: NativeChatTurnTimingByTurn,
{
activeTurnKey,
validTurnKeys,
isWorking,
workingStartedAt,
now
}: {
activeTurnKey: string
validTurnKeys: ReadonlySet<string>
isWorking: boolean
workingStartedAt?: number | null
now: number
}
): NativeChatTurnTimingByTurn {
let retained = current
for (const turnKey of Object.keys(current)) {
if (turnKey !== activeTurnKey && !validTurnKeys.has(turnKey)) {
if (retained === current) {
retained = { ...current }
}
delete (retained as Record<string, NativeChatTurnTiming>)[turnKey]
}
}
const timing = retained[activeTurnKey]
if (isWorking) {
// An in-flight turn keeps the start it already had; only a fresh turn (or an
// authoritative host timestamp) restamps it.
const startedAt =
workingStartedAt ?? (timing && timing.workedSeconds == null ? timing.startedAt : now)
if (timing?.startedAt === startedAt && timing.workedSeconds == null) {
return retained
}
return { ...retained, [activeTurnKey]: { startedAt, workedSeconds: null } }
}
if (timing?.workedSeconds != null) {
return retained
}
const startedAt = timing?.startedAt ?? workingStartedAt
if (startedAt == null) {
return retained
}
return {
...retained,
[activeTurnKey]: {
startedAt,
workedSeconds: Math.max(0, Math.floor((now - startedAt) / 1000))
}
}
}
/** Split the timing map into the active turn's status and the settled ones. */
export function selectNativeChatTurnStatuses(
timingByTurn: NativeChatTurnTimingByTurn,
{
activeTurnKey,
isWorking,
workingStartedAt,
hasCurrentTurnResponse
}: {
activeTurnKey: string
isWorking: boolean
workingStartedAt?: number | null
hasCurrentTurnResponse: boolean
}
): { active: NativeChatTurnStatus | null; completedByTurn: Record<string, NativeChatTurnStatus> } {
const completedByTurn = Object.fromEntries(
Object.entries(timingByTurn)
.filter(([, timing]) => timing.workedSeconds != null)
.map(([turnKey, timing]) => [
turnKey,
{ startedAt: timing.startedAt, thinking: false, workedSeconds: timing.workedSeconds }
])
) as Record<string, NativeChatTurnStatus>
return {
active: isWorking
? {
startedAt: workingStartedAt ?? timingByTurn[activeTurnKey]?.startedAt ?? null,
thinking: !hasCurrentTurnResponse,
workedSeconds: null
}
: (completedByTurn[activeTurnKey] ?? null),
completedByTurn
}
}
/** Elapsed whole seconds for a counting turn, tolerating a not-yet-stamped start. */
export function nativeChatElapsedSeconds(
startedAt: number | null,
fallbackStartedAt: number,
now: number
): number {
return Math.max(0, Math.floor((now - (startedAt ?? fallbackStartedAt)) / 1000))
}