From 6abd8fa92a6e9f950c8a0853fbe7d3e92b68b703 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 16:38:03 -0700 Subject: [PATCH] feat(mobile): port the restructured native-chat turn status and live tool progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- mobile/src/components/MobileMarkdown.tsx | 15 +- .../session/MobileNativeChatMessage.test.ts | 114 ++++++- .../src/session/MobileNativeChatMessage.tsx | 297 +++++------------- .../src/session/MobileNativeChatOverlay.tsx | 1 + .../session/MobileNativeChatPromptCard.tsx | 74 +++++ .../src/session/MobileNativeChatToolRun.tsx | 250 +++++++++++++++ .../MobileNativeChatTurnStatus.test.ts | 112 +++++++ .../session/MobileNativeChatTurnStatus.tsx | 117 +++++++ mobile/src/session/MobileNativeChatView.tsx | 84 +++-- .../mobile-native-chat-controller-contract.ts | 2 + .../mobile-native-chat-message-styles.ts | 12 + .../use-mobile-native-chat-controller.ts | 39 +-- .../use-mobile-native-chat-session-lane.ts | 59 ++++ .../use-mobile-native-chat-turn-disclosure.ts | 100 ++++++ .../use-mobile-native-chat-turn-status.ts | 60 ++++ .../native-chat/NativeChatToolRun.tsx | 73 ++--- .../native-chat/NativeChatWorkingStatus.tsx | 65 ++-- .../use-native-chat-turn-status.ts | 115 ++----- src/shared/native-chat-tool-activity.test.ts | 109 +++++++ src/shared/native-chat-tool-activity.ts | 97 ++++++ src/shared/native-chat-turn-status.test.ts | 248 +++++++++++++++ src/shared/native-chat-turn-status.ts | 194 ++++++++++++ 22 files changed, 1770 insertions(+), 467 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatPromptCard.tsx create mode 100644 mobile/src/session/MobileNativeChatToolRun.tsx create mode 100644 mobile/src/session/MobileNativeChatTurnStatus.test.ts create mode 100644 mobile/src/session/MobileNativeChatTurnStatus.tsx create mode 100644 mobile/src/session/use-mobile-native-chat-session-lane.ts create mode 100644 mobile/src/session/use-mobile-native-chat-turn-disclosure.ts create mode 100644 mobile/src/session/use-mobile-native-chat-turn-status.ts create mode 100644 src/shared/native-chat-tool-activity.test.ts create mode 100644 src/shared/native-chat-tool-activity.ts create mode 100644 src/shared/native-chat-turn-status.test.ts create mode 100644 src/shared/native-chat-turn-status.ts diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index cc88c01e564..2f5b52cfe18 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -192,6 +192,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile return ( {renderInline(block.text, onOpenFile)} @@ -201,7 +202,9 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile if (block.type === 'quote') { return ( - {renderInline(block.text, onOpenFile)} + + {renderInline(block.text, onOpenFile)} + ) } @@ -223,7 +226,9 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile return ( {block.language ? {block.language} : null} - {block.text} + + {block.text} + ) } @@ -251,7 +256,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile {visibleHeaders.map((header, cellIndex) => ( - + {renderInline(header, onOpenFile)} ))} @@ -259,7 +264,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile {visibleRows.map((row, rowIndex) => ( {visibleHeaders.map((_, cellIndex) => ( - + {renderInline(row[cellIndex] ?? '', onOpenFile)} ))} @@ -290,7 +295,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile ? '[x]' : '[ ]'} - + {renderInline(item.text, onOpenFile)} diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 10b96cc3d20..529f3c31e96 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -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']) + }) + }) }) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 0a676061e5b..53031b399db 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -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 ( - - {lines.map((line, i) => ( - - {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} - {line.text} - - ))} - - ) -} - -/** A single inline tool line — `▸ ToolName preview` — that expands in place to - * show the call's diff/input or the result's body. Mirrors the reference design - * where tool calls read as flat lines in the conversation, not boxed blocks. */ -function ResultBody({ - output, - isError, - diff -}: { - output: string - isError?: boolean - diff: DiffLine[] | null -}): React.JSX.Element { - if (diff) { - return - } - return ( - - {truncateToolDetail(output)} - - ) -} - -/** One request: a tool call and its result rendered together as a single - * expandable line. `defaultExpanded` lets the group toggle open every line. */ -function ToolLine({ - pair, - defaultExpanded, - diffLineLimit, - onOpenFile -}: { - pair: ToolPair - defaultExpanded: boolean - diffLineLimit: number - onOpenFile?: (relativePath: string) => void -}): React.JSX.Element { - const [expanded, setExpanded] = useState(defaultExpanded) - const { call, result } = pair - const name = call ? call.name : 'Result' - const inputDisplay = call ? createToolInputDisplay(call.input) : null - const preview = inputDisplay?.label ?? result?.output.split('\n')[0]?.slice(0, 80) ?? '' - // Why: collapsed tool rows are the common path; defer bounded diff parsing - // and detail formatting until the user asks to reveal the detail. - const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null - const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null - const callDetail = expanded && inputDisplay && !callDiff ? inputDisplay.formatDetail() : undefined - const hasDetail = callDiff !== null || result !== undefined || inputDisplay?.hasDetail === true - // The group toggle opens every line at once, bypassing the tap guard, so the - // panel has to consult it too — else a detail-less row echoes its own label - // under itself and no tap can dismiss it. - const showDetail = hasDetail && expanded - // A tool that targets a file (Read/Edit/Write…) renders its preview as a - // tappable link that opens the file, independent of the line's expand tap. - const filePath = inputDisplay?.filePath ?? null - const openable = filePath !== null && onOpenFile !== undefined - return ( - - hasDetail && setExpanded((v) => !v)} - hitSlop={6} - > - {showDetail ? ( - - ) : ( - - )} - {name} - {preview ? ( - onOpenFile!(filePath!) : undefined} - suppressHighlighting={!openable} - > - {preview} - - ) : null} - - {showDetail ? ( - - {callDiff ? : null} - {callDetail ? {callDetail} : null} - {result ? ( - - ) : null} - - ) : null} - - ) -} - function Prose({ block, invert, @@ -150,7 +31,9 @@ function Prose({ // markdown renderer's light-on-dark palette. if (invert) { return ( - {block.text} + + {block.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 ( - - - setOpen((v) => !v)} hitSlop={6}> - {open ? ( - - ) : ( - - )} - {callCount}× - - {summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`} - - - {trailing} - - {open ? ( - - {pairs.map((pair, i) => ( - - ))} - {callCount > pairs.length ? ( - … {callCount - pairs.length} more tool calls - ) : null} - - ) : null} - - ) -} - /** 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 ( - - - {prose.map((block, index) => ( - - ))} - {tools.length > 0 ? ( - - ) : controls ? ( - {controls} - ) : null} + <> + + + {prose.map((block, index) => ( + + ))} + {showToolRun ? ( + + ) : controls ? ( + {controls} + ) : null} + - + {turnStatus ? ( + + ) : null} + ) } diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 23724300ddf..357a089466e 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -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} diff --git a/mobile/src/session/MobileNativeChatPromptCard.tsx b/mobile/src/session/MobileNativeChatPromptCard.tsx new file mode 100644 index 00000000000..470ba2ee8b6 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPromptCard.tsx @@ -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 + onCancelAsk?: () => Promise + permission?: MobileChatPermission | null + onRespondPermission?: (send: string) => Promise + question?: MobileChatQuestion | null + onAnswerQuestion?: (text: string) => Promise +}): React.JSX.Element | null { + if (ask) { + return ( + { + 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 ( + (await onRespondPermission?.(send)) ?? false} + /> + ) + } + if (question) { + return ( + (await onAnswerQuestion?.(text)) ?? false} + /> + ) + } + return null +} diff --git a/mobile/src/session/MobileNativeChatToolRun.tsx b/mobile/src/session/MobileNativeChatToolRun.tsx new file mode 100644 index 00000000000..db3ddbea063 --- /dev/null +++ b/mobile/src/session/MobileNativeChatToolRun.tsx @@ -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 ( + + {lines.map((line, i) => ( + + {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} + {line.text} + + ))} + + ) +} + +/** A single inline tool line — `▸ ToolName preview` — that expands in place to + * show the call's diff/input or the result's body. Mirrors the reference design + * where tool calls read as flat lines in the conversation, not boxed blocks. */ +function ResultBody({ + output, + isError, + diff +}: { + output: string + isError?: boolean + diff: DiffLine[] | null +}): React.JSX.Element { + if (diff) { + return + } + return ( + + {truncateToolDetail(output)} + + ) +} + +/** One request: a tool call and its result rendered together as a single + * expandable line. `defaultExpanded` lets the group toggle open every line. */ +function ToolLine({ + pair, + defaultExpanded, + diffLineLimit, + onOpenFile +}: { + pair: ToolPair + defaultExpanded: boolean + diffLineLimit: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [expanded, setExpanded] = useState(defaultExpanded) + const { call, result } = pair + const name = call ? call.name : 'Result' + const inputDisplay = call ? createToolInputDisplay(call.input) : null + const preview = inputDisplay?.label ?? result?.output.split('\n')[0]?.slice(0, 80) ?? '' + // Why: collapsed tool rows are the common path; defer bounded diff parsing + // and detail formatting until the user asks to reveal the detail. + const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null + const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null + const callDetail = expanded && inputDisplay && !callDiff ? inputDisplay.formatDetail() : undefined + const hasDetail = callDiff !== null || result !== undefined || inputDisplay?.hasDetail === true + // The group toggle opens every line at once, bypassing the tap guard, so the + // panel has to consult it too — else a detail-less row echoes its own label + // under itself and no tap can dismiss it. + const showDetail = hasDetail && expanded + // A tool that targets a file (Read/Edit/Write…) renders its preview as a + // tappable link that opens the file, independent of the line's expand tap. + const filePath = inputDisplay?.filePath ?? null + const openable = filePath !== null && onOpenFile !== undefined + return ( + + hasDetail && setExpanded((v) => !v)} + hitSlop={6} + > + {showDetail ? ( + + ) : ( + + )} + {name} + {preview ? ( + onOpenFile!(filePath!) : undefined} + suppressHighlighting={!openable} + > + {preview} + + ) : null} + + {showDetail ? ( + + {callDiff ? : null} + {callDetail ? {callDetail} : null} + {result ? ( + + ) : null} + + ) : null} + + ) +} + +/** Breathing label for a still-running tool, matching desktop's `animate-pulse`. */ +function PulsingText({ + style, + numberOfLines, + children +}: { + style?: React.ComponentProps['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 ( + + {children} + + ) +} + +/** A run of a message's tool calls/results, collapsed to a one-line summary that + * expands to the individual inline tool lines. `defaultExpanded` lets the global + * toolbar toggle drive every run at once while still allowing per-run override. */ +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 + 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 ( + + + {activeCall ? ( + setOpen((v) => !v)} + hitSlop={6} + accessibilityRole="button" + accessibilityState={{ expanded: open }} + accessibilityLiveRegion="polite" + > + + + {formatActiveToolLabel(describeActiveToolCall(activeCall))} + + {open ? : null} + + ) : ( + setOpen((v) => !v)} hitSlop={6}> + {open ? ( + + ) : ( + + )} + {callCount}× + + {summary || formatToolCallCount(callCount)} + + + )} + {trailing} + + {open ? ( + + {pairs.map((pair, i) => ( + + ))} + {callCount > pairs.length ? ( + … {callCount - pairs.length} more tool calls + ) : null} + + ) : null} + + ) +} diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts new file mode 100644 index 00000000000..78ac01e0d37 --- /dev/null +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -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') + }) +}) diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx new file mode 100644 index 00000000000..4ce73cdcd38 --- /dev/null +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -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 ( + [...rowStyle, pressed && styles.pressed]} + onPress={onToggleExpanded} + hitSlop={6} + accessibilityRole="button" + accessibilityState={{ expanded }} + accessibilityLabel={NATIVE_CHAT_TURN_STATUS_COPY.toggleDetails} + > + {label} + + + + + ) + } + + return ( + + {label} + + ) +} + +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' }] + } +}) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 4db4e437b43..88b47673f80 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -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 }) => ( ), - [toolsExpanded, fontScale, onScrollToMessage, onOpenFile] + [toolsExpanded, fontScale, onScrollToMessage, onOpenFile, structuredActivityUi, turns] ) const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) @@ -337,6 +351,15 @@ export function MobileNativeChatView({ ) : null } + ListFooterComponent={ + turns.activeTurnIsUnanchored && turns.active ? ( + + ) : null + } ListEmptyComponent={ emptyState ? ( @@ -360,47 +383,22 @@ export function MobileNativeChatView({ ) : null} )} - {/* 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 ? ( - { - 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 ? ( - (await onRespondPermission?.(send)) ?? false} - /> - ) : question ? ( - (await onAnswerQuestion?.(text)) ?? false} - /> - ) : null} + {/* Chrome row above the composer: the working indicator and the global tool-calls expand/collapse toggle on the left, Stop in the far corner. */} - {agentWorking ? : null} + {agentWorking && !structuredActivityUi ? : null} [styles.chromeToggle, pressed && styles.pressed]} onPress={() => setToolsExpanded((v) => !v)} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 890a3a1562e..53187e0d6db 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -25,6 +25,8 @@ export type MobileNativeChatController = { chatPending: MobileNativeChatPendingMessage[] chatImagePreviewsByMessageId: Record nativeChatSession: ReturnType + /** 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. */ diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index ad7cf4b4009..7ae1128445a 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -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, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index bf944398e87..a946956f8d6 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -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, diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts new file mode 100644 index 00000000000..fc465de902b --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -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[0]['sourceIdentity'] + enabled: boolean + connState: ConnectionState + onSendError: (message: string) => void +}): { + structuredSession: ReturnType + session: ReturnType +} { + 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 + } +} diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts new file mode 100644 index 00000000000..e2b7bebd72f --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -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>(() => 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 + } +} diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts new file mode 100644 index 00000000000..fcf98bdd713 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -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> + 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({}) + + 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 + } +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index f16d01331f8..14cb0163f50 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -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): 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 }) diff --git a/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx index 21145e94c94..e6c38de83f5 100644 --- a/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx @@ -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({