mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }]
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user