diff --git a/apps/mobile/src/components/agents/child-session-message.test.ts b/apps/mobile/src/components/agents/child-session-message.test.ts new file mode 100644 index 0000000000..c04e4f0b02 --- /dev/null +++ b/apps/mobile/src/components/agents/child-session-message.test.ts @@ -0,0 +1,212 @@ +import { + type Part, + type StoredMessage, + type TextPart, + type ToolPart, +} from '@kilocode/cloud-agent-sdk'; +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ChildSessionMessage, ChildSessionSection } from './child-session-section'; +import { MessageErrorBoundary } from './message-error-boundary'; + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + View: 'View', +})); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'AnimatedView' }, + LinearTransition: { duration: () => ({}) }, +})); +vi.mock('lucide-react-native', () => ({ + Bot: 'Bot', + ChevronRight: 'ChevronRight', + Loader2: 'Loader2', +})); +vi.mock('@/components/ui/spinning-icon', () => ({ + SpinningIcon: 'SpinningIcon', +})); +vi.mock('@/components/ui/text', () => ({ + Text: 'Text', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({}), +})); +vi.mock('./message-error-boundary', () => ({ + MessageErrorBoundary: ({ children }: { children?: unknown }) => children, +})); + +const taskCompletedState: Extract = { + status: 'completed', + input: { description: 'child task', subagent_type: 'General' }, + output: '', + title: 'task', + metadata: { sessionId: 'child-1' }, + time: { start: 1, end: 2 }, +}; + +function makeToolPart(tool: string, state: ToolPart['state']): ToolPart { + return { + id: `${tool}-1`, + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: 'call-1', + tool, + state, + }; +} + +function makeTextPart(text: string): TextPart { + return { id: 't1', sessionID: 's1', messageID: 'm1', type: 'text', text }; +} + +function makeMessage(parts: Part[]): StoredMessage { + return { + info: { + id: 'm1', + sessionID: 's1', + role: 'assistant', + time: { created: 1 }, + parentID: 'm0', + modelID: 'model', + providerID: 'kilo', + mode: 'code', + agent: 'build', + path: { cwd: '/', root: '/' }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts, + }; +} + +function findAll( + node: unknown, + predicate: (el: React.ReactElement) => boolean +): React.ReactElement[] { + const matches: React.ReactElement[] = []; + function walk(value: unknown): void { + if (value == null || typeof value === 'string' || typeof value === 'number') { + return; + } + if (Array.isArray(value)) { + for (const child of value) { + walk(child); + } + return; + } + if (React.isValidElement(value)) { + if (predicate(value)) { + matches.push(value); + } + walk((value.props as Record).children); + } + } + walk(node); + return matches; +} + +function findByType(node: unknown, type: React.ElementType): React.ReactElement[] { + return findAll(node, el => el.type === type); +} + +const textChildren = (el: React.ReactElement): unknown => + (el.props as { children?: unknown }).children; + +describe('ChildSessionMessage routing seam', () => { + it('renders a task part directly through ChildSessionSection, never through renderPart', () => { + const childMessages = [makeMessage([makeTextPart('child text')])]; + const getChildMessages = vi.fn((id: string) => (id === 'child-1' ? childMessages : [])); + const renderPart = vi.fn(); + const onOpenChildSession = vi.fn<(sessionId: string, title: string) => void>(); + + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ChildSessionMessage({ + message: makeMessage([makeToolPart('task', taskCompletedState)]), + depth: 0, + getChildMessages, + renderPart, + onOpenChildSession, + }); + + expect(getChildMessages).toHaveBeenCalledWith('child-1'); + expect(renderPart).not.toHaveBeenCalled(); + const sections = findByType(root, ChildSessionSection); + expect(sections).toHaveLength(1); + const section = sections[0]; + if (!section) { + throw new Error('expected ChildSessionSection'); + } + expect(section.props).toMatchObject({ + part: expect.objectContaining({ id: 'task-1', tool: 'task' }), + childMessages, + onOpenChildSession, + }); + }); + + it('routes non-task parts through renderPart inside MessageErrorBoundary', () => { + const getChildMessages = vi.fn<() => StoredMessage[]>(() => []); + const renderPart = vi.fn(() => null); + const onOpenChildSession = vi.fn<(sessionId: string, title: string) => void>(); + const part = makeTextPart('hello'); + + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ChildSessionMessage({ + message: makeMessage([part]), + depth: 0, + getChildMessages, + renderPart, + onOpenChildSession, + }); + + expect(renderPart).toHaveBeenCalledTimes(1); + expect(renderPart).toHaveBeenCalledWith( + expect.objectContaining({ part, getChildMessages, onOpenChildSession }) + ); + expect(findByType(root, MessageErrorBoundary)).toHaveLength(1); + }); + + it('mixes direct child-session rendering with renderPart for sibling parts', () => { + const getChildMessages = vi.fn<() => StoredMessage[]>(() => []); + const renderPart = vi.fn(() => null); + const onOpenChildSession = vi.fn<(sessionId: string, title: string) => void>(); + + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ChildSessionMessage({ + message: makeMessage([makeToolPart('task', taskCompletedState), makeTextPart('after')]), + depth: 0, + getChildMessages, + renderPart, + onOpenChildSession, + }); + + expect(findByType(root, ChildSessionSection)).toHaveLength(1); + expect(renderPart).toHaveBeenCalledTimes(1); + expect(renderPart).toHaveBeenCalledWith( + expect.objectContaining({ part: expect.objectContaining({ id: 't1', type: 'text' }) }) + ); + }); + + it('renders the nesting-depth limit text at the depth cap', () => { + const getChildMessages = vi.fn<() => StoredMessage[]>(() => []); + const renderPart = vi.fn(); + const onOpenChildSession = vi.fn<(sessionId: string, title: string) => void>(); + + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ChildSessionMessage({ + message: makeMessage([makeTextPart('deep')]), + depth: 5, + getChildMessages, + renderPart, + onOpenChildSession, + }); + + const limitTexts = findAll( + root, + el => el.type === 'Text' && textChildren(el) === 'Maximum nesting depth reached.' + ); + expect(limitTexts).toHaveLength(1); + expect(renderPart).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/agents/child-session-sheet.tsx b/apps/mobile/src/components/agents/child-session-sheet.tsx index 352c68cd0d..c1e105679c 100644 --- a/apps/mobile/src/components/agents/child-session-sheet.tsx +++ b/apps/mobile/src/components/agents/child-session-sheet.tsx @@ -14,6 +14,7 @@ import { type RenderPartFn, } from './child-session-section'; import { MessageErrorBoundary } from './message-error-boundary'; +import { PartDetailSheetHost } from './part-detail-sheet-host'; import { getChildSessionSheetState } from './child-session-sheet-state'; import { SessionMessageList } from './session-message-list'; import { WorkingIndicator } from './working-indicator'; @@ -125,7 +126,7 @@ export function ChildSessionSheet({ > - {content} + {content} ); diff --git a/apps/mobile/src/components/agents/fixed-part-row.mounted.test.tsx b/apps/mobile/src/components/agents/fixed-part-row.mounted.test.tsx new file mode 100644 index 0000000000..6c6ecbf59e --- /dev/null +++ b/apps/mobile/src/components/agents/fixed-part-row.mounted.test.tsx @@ -0,0 +1,188 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */ +import { Eye } from 'lucide-react-native'; +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { FixedPartRow } from './fixed-part-row'; + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + View: 'View', +})); +vi.mock('lucide-react-native', () => ({ + ChevronRight: 'ChevronRight', + XCircle: 'XCircle', + Eye: 'Eye', +})); +vi.mock('@/components/ui/eyebrow', () => ({ + Eyebrow: 'Eyebrow', +})); +vi.mock('@/components/ui/text', () => ({ + Text: 'Text', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#999999', destructive: '#BE4E3F' }), +})); + +type RowProps = Parameters[0]; + +async function renderRow(props: RowProps): Promise { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create(createElement(FixedPartRow, props)); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function findHost( + root: TestRenderer.ReactTestInstance, + type: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => node.type === type); +} + +describe('FixedPartRow mounted', () => { + it('renders a pressable row with a details hint and chevron when onPress is set', async () => { + const onPress = vi.fn(() => undefined); + const renderer = await renderRow({ + icon: Eye, + label: 'app.ts', + status: 'completed', + onPress, + accessibilityLabel: 'app.ts tool, completed', + }); + + const pressable = findHost(renderer.root, 'Pressable')[0]; + expect(pressable).toBeDefined(); + if (!pressable) { + throw new Error('pressable not found'); + } + expect(pressable.props.accessibilityLabel).toBe('app.ts tool, completed'); + expect(pressable.props.accessibilityHint).toBe('Show details'); + expect(pressable.props.accessibilityState).toEqual({ disabled: false }); + expect(pressable.props.disabled).toBe(false); + expect(pressable.props.onPress).toBe(onPress); + expect(findHost(renderer.root, 'ChevronRight')).toHaveLength(1); + }); + + it('renders a disabled row with no hint and no chevron without onPress', async () => { + const renderer = await renderRow({ + icon: Eye, + label: 'app.ts', + status: 'completed', + accessibilityLabel: 'app.ts tool, completed', + }); + + const pressable = findHost(renderer.root, 'Pressable')[0]; + expect(pressable).toBeDefined(); + if (!pressable) { + throw new Error('pressable not found'); + } + expect(pressable.props.accessibilityHint).toBeUndefined(); + expect(pressable.props.accessibilityState).toEqual({ disabled: true }); + expect(pressable.props.disabled).toBe(true); + expect(pressable.props.onPress).toBeUndefined(); + expect(findHost(renderer.root, 'ChevronRight')).toHaveLength(0); + }); + + it('renders the destructive icon for the error status', async () => { + const renderer = await renderRow({ + icon: Eye, + label: 'bash', + status: 'error', + accessibilityLabel: 'bash tool, error', + }); + + expect(findHost(renderer.root, 'XCircle')).toHaveLength(1); + expect(findHost(renderer.root, 'ActivityIndicator')).toHaveLength(0); + expect(findHost(renderer.root, 'Eye')).toHaveLength(0); + }); + + it('renders an activity indicator for the running status', async () => { + const renderer = await renderRow({ + icon: Eye, + label: 'bash', + status: 'running', + accessibilityLabel: 'bash tool, running', + }); + + expect(findHost(renderer.root, 'ActivityIndicator')).toHaveLength(1); + expect(findHost(renderer.root, 'XCircle')).toHaveLength(0); + expect(findHost(renderer.root, 'Eye')).toHaveLength(0); + }); + + it('renders an activity indicator for the pending status', async () => { + const renderer = await renderRow({ + icon: Eye, + label: 'bash', + status: 'pending', + accessibilityLabel: 'bash tool, pending', + }); + + expect(findHost(renderer.root, 'ActivityIndicator')).toHaveLength(1); + }); + + it('renders the completed icon when status is completed and an icon is provided', async () => { + const renderer = await renderRow({ + icon: Eye, + label: 'app.ts', + status: 'completed', + accessibilityLabel: 'app.ts tool, completed', + }); + + expect(findHost(renderer.root, 'Eye')).toHaveLength(1); + expect(findHost(renderer.root, 'ActivityIndicator')).toHaveLength(0); + expect(findHost(renderer.root, 'XCircle')).toHaveLength(0); + }); + + it('renders no leading element when completed without an icon', async () => { + const renderer = await renderRow({ + label: 'app.ts', + status: 'completed', + accessibilityLabel: 'app.ts tool, completed', + }); + + expect(findHost(renderer.root, 'Eye')).toHaveLength(0); + expect(findHost(renderer.root, 'XCircle')).toHaveLength(0); + expect(findHost(renderer.root, 'ActivityIndicator')).toHaveLength(0); + const labels = findHost(renderer.root, 'Text'); + expect(labels.some(node => node.props.children === 'app.ts')).toBe(true); + }); + + it('renders no leading slot at all when status is absent (reasoning rows)', async () => { + const renderer = await renderRow({ + label: 'Thought', + accessibilityLabel: 'Thought', + }); + + expect(findHost(renderer.root, 'ActivityIndicator')).toHaveLength(0); + expect(findHost(renderer.root, 'XCircle')).toHaveLength(0); + expect(findHost(renderer.root, 'Eye')).toHaveLength(0); + }); + + it('keeps the eyebrow label on a single line', async () => { + const renderer = await renderRow({ + label: 'Thought', + labelKind: 'eyebrow', + accessibilityLabel: 'Thought', + }); + + const eyebrows = findHost(renderer.root, 'Eyebrow'); + expect(eyebrows).toHaveLength(1); + const eyebrow = eyebrows[0]; + if (!eyebrow) { + throw new Error('eyebrow not found'); + } + expect(eyebrow.props.numberOfLines).toBe(1); + expect(eyebrow.props.className).toContain('shrink'); + }); +}); diff --git a/apps/mobile/src/components/agents/fixed-part-row.tsx b/apps/mobile/src/components/agents/fixed-part-row.tsx new file mode 100644 index 0000000000..5d95fd1aa7 --- /dev/null +++ b/apps/mobile/src/components/agents/fixed-part-row.tsx @@ -0,0 +1,87 @@ +import { ChevronRight, type LucideIcon, XCircle } from 'lucide-react-native'; +import { ActivityIndicator, Pressable, View } from 'react-native'; + +import { Eyebrow } from '@/components/ui/eyebrow'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type FixedPartRowProps = { + /** Tool icon, shown in the completed slot. Never passed for reasoning rows. */ + icon?: LucideIcon; + /** Primary text: `display.subtitle ?? display.title`. */ + label: string; + /** 'text' for tools, 'eyebrow' for reasoning. */ + labelKind?: 'text' | 'eyebrow'; + badge?: string; + /** Absent for reasoning rows. */ + status?: 'pending' | 'running' | 'completed' | 'error'; + /** 'solid' tools, 'dashed' reasoning. */ + variant?: 'solid' | 'dashed'; + /** Presence makes the row pressable and adds the chevron and details hint. */ + onPress?: () => void; + accessibilityLabel: string; +}; + +/** + * Shared fixed-height row chrome for non-message transcript parts. Stateless + * and single-line: the row never expands inline and never changes height from + * streaming state transitions. A completed row without an `icon` renders no + * leading element (a valid no-op, never an undefined component). + */ +export function FixedPartRow({ + icon: Icon, + label, + labelKind = 'text', + badge, + status, + variant = 'solid', + onPress, + accessibilityLabel, +}: Readonly) { + const colors = useThemeColors(); + + return ( + + + {status === 'pending' || status === 'running' ? ( + + ) : null} + {status === 'error' ? : null} + {status === 'completed' && Icon ? : null} + + + {labelKind === 'eyebrow' ? ( + + {label} + + ) : ( + + {label} + + )} + {badge ? ( + + {badge} + + ) : null} + + + {onPress ? : null} + + + ); +} diff --git a/apps/mobile/src/components/agents/message-bubble.test.ts b/apps/mobile/src/components/agents/message-bubble.test.ts index 4c019b04a0..63174df3eb 100644 --- a/apps/mobile/src/components/agents/message-bubble.test.ts +++ b/apps/mobile/src/components/agents/message-bubble.test.ts @@ -1,5 +1,7 @@ +/* eslint-disable max-lines -- Queued-badge, delivery, a11y, and time-label seams share the direct-invocation MessageBubble harness. */ import { describe, expect, it, vi } from 'vitest'; +import { formatTranscriptTimeLabel } from './message-time-label'; import { assistantMessage, findElementByType, @@ -149,6 +151,78 @@ describe('MessageBubble failed delivery state', () => { }); }); +describe('MessageBubble time label', () => { + it('renders a same-day time label matching the formatter evaluated in the test', async () => { + const created = Date.now(); + const message = userMessage('m-time-same-day'); + message.info.time = { created }; + const tree = await renderBubble(message); + const expected = formatTranscriptTimeLabel(created, Date.now()); + expect(expected).not.toBeNull(); + expect(findText(tree, t => t === expected)).toBe(true); + }); + + it('renders the user time label in the meta row after the queued badge slot', async () => { + const tree = await renderBubble(userMessage('m-time-user'), { status: 'queued' }); + const metaRow = findElementByType( + tree, + 'View', + p => + typeof p.className === 'string' && + p.className.includes('flex-row items-center gap-2 self-end pr-1') + ); + expect(metaRow).not.toBeNull(); + if (!metaRow) { + throw new Error('expected meta row'); + } + const children = Array.isArray(metaRow.props.children) + ? metaRow.props.children + : [metaRow.props.children]; + expect(children.length).toBe(2); + const badge = children[0] as { props?: Record }; + const badgeClass = typeof badge.props?.className === 'string' ? badge.props.className : null; + expect(badgeClass).not.toBeNull(); + expect(badgeClass?.includes(BADGE_CLASS)).toBe(true); + const label = children[1] as { props?: Record }; + const labelClass = typeof label.props?.className === 'string' ? label.props.className : null; + expect(labelClass).not.toBeNull(); + expect(labelClass?.includes('tabular-nums')).toBe(true); + expect(typeof label.props?.children).toBe('string'); + }); + + it('renders the assistant time label after the parts view', async () => { + const tree = await renderBubble(assistantMessage('m-time-asst')); + const pressable = findElementByType(tree, 'Pressable'); + expect(pressable).not.toBeNull(); + if (!pressable) { + throw new Error('expected pressable'); + } + const children = Array.isArray(pressable.props.children) + ? pressable.props.children + : [pressable.props.children]; + const partsIndex = children.findIndex(child => + subtreeContains(child, p => typeof p.className === 'string' && p.className.includes('gap-2')) + ); + const labelIndex = children.findIndex(child => subtreeContainsTimeLabel(child)); + expect(partsIndex).toBeGreaterThanOrEqual(0); + expect(labelIndex).toBeGreaterThan(partsIndex); + }); + + it('does not render a time label when time.created is absent', async () => { + const message = userMessage('m-time-absent'); + (message.info.time as { created?: number }).created = undefined; + const tree = await renderBubble(message); + expect(findTimeLabel(tree)).toBeNull(); + }); + + it('does not render a time label when time.created is invalid', async () => { + const message = assistantMessage('m-time-invalid'); + message.info.time = { created: Number.NaN }; + const tree = await renderBubble(message); + expect(findTimeLabel(tree)).toBeNull(); + }); +}); + describe('MessageBubble regressions', () => { it('holds badge slot when queued and holdQueuedSlot is set after dequeue', async () => { const message = userMessage('m7'); @@ -277,3 +351,54 @@ function findProvider( } return null; } + +function subtreeContains( + node: unknown, + predicate: (props: Record) => boolean +): boolean { + if (node == null || typeof node !== 'object') { + return false; + } + const element = node as { type?: unknown; props?: Record }; + if (predicate(element.props ?? {})) { + return true; + } + const children = element.props?.children; + if (Array.isArray(children)) { + return children.some(child => subtreeContains(child, predicate)); + } + if (children && typeof children === 'object') { + return subtreeContains(children, predicate); + } + return false; +} + +function subtreeContainsTimeLabel(node: unknown): boolean { + return subtreeContains( + node, + p => typeof p.className === 'string' && p.className.includes('tabular-nums') + ); +} + +function findTimeLabel(node: unknown): { props: Record } | null { + if (node == null || typeof node !== 'object') { + return null; + } + const element = node as { type?: unknown; props?: Record }; + const props = element.props ?? {}; + if (typeof props.className === 'string' && props.className.includes('tabular-nums')) { + return { props }; + } + const children = element.props?.children; + if (Array.isArray(children)) { + for (const child of children) { + const hit = findTimeLabel(child); + if (hit) { + return hit; + } + } + } else if (children && typeof children === 'object') { + return findTimeLabel(children); + } + return null; +} diff --git a/apps/mobile/src/components/agents/message-bubble.tsx b/apps/mobile/src/components/agents/message-bubble.tsx index 7e2929c66f..c923f42d69 100644 --- a/apps/mobile/src/components/agents/message-bubble.tsx +++ b/apps/mobile/src/components/agents/message-bubble.tsx @@ -11,6 +11,7 @@ import { ChatMarkdownText } from './chat-markdown-text'; import { CompactionSeparator } from './compaction-separator'; import { FilePartRenderer } from './file-part-renderer'; import { buildAgentMessageBubbleAccessibilityProps } from './message-bubble-a11y'; +import { formatTranscriptTimeLabel } from './message-time-label'; import { PartRenderer } from './part-renderer'; import { isFilePart, isTextPart } from './part-types'; import { useMessageCopy } from './use-message-copy'; @@ -78,6 +79,12 @@ export function MessageBubble({ ); } + // Subtle time label, computed once per render. `Date.now()` at render time + // only: no timer and no day-boundary watcher, so a message mounted across + // midnight keeps its label until the next render (stream tick, list recycle, + // navigation). + const timeLabel = formatTranscriptTimeLabel(message.info.time.created, Date.now()); + if (isUser) { // Composer, queued-message synthesis, and slash commands emit exactly one // human-authored text part, so the separator separates it from synthesized @@ -104,22 +111,29 @@ export function MessageBubble({ ))} - {hasBadgeSlot ? ( - - - Queued + {hasBadgeSlot || timeLabel ? ( + + {hasBadgeSlot ? ( + + + Queued + + ) : null} + {timeLabel ? ( + {timeLabel} + ) : null} ) : null} @@ -159,6 +173,9 @@ export function MessageBubble({ ))} + {timeLabel ? ( + {timeLabel} + ) : null} {a11y.accessibilityActions.length > 0 ? ( { expect(formatMessageSentTime(Number.NaN)).toBeNull(); expect(formatMessageSentTime(-1)).toBeNull(); }); + + it('returns null for an out-of-range finite epoch without throwing', () => { + expect(formatMessageSentTime(Number.MAX_VALUE)).toBeNull(); + }); }); describe('getMessageDetailsContent — happy', () => { diff --git a/apps/mobile/src/components/agents/message-time-label.test.ts b/apps/mobile/src/components/agents/message-time-label.test.ts new file mode 100644 index 0000000000..270ee838c1 --- /dev/null +++ b/apps/mobile/src/components/agents/message-time-label.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { formatTranscriptTimeLabel } from './message-time-label'; + +const TIME_ONLY = new Intl.DateTimeFormat(undefined, { timeStyle: 'short' }); + +const DATE_TIME = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); + +/** + * All epochs are built with the local `new Date(y, m, d, h, min)` constructor + * so the assertions are timezone-stable and never depend on the runner clock. + */ +describe('formatTranscriptTimeLabel', () => { + it('returns null when the timestamp is absent', () => { + const now = new Date(2026, 7, 5, 9, 1).getTime(); + expect(formatTranscriptTimeLabel(undefined, now)).toBeNull(); + expect(formatTranscriptTimeLabel(null, now)).toBeNull(); + }); + + it('returns null when the timestamp is non-positive or non-finite', () => { + const now = new Date(2026, 7, 5, 9, 1).getTime(); + expect(formatTranscriptTimeLabel(0, now)).toBeNull(); + expect(formatTranscriptTimeLabel(-1, now)).toBeNull(); + expect(formatTranscriptTimeLabel(Number.NaN, now)).toBeNull(); + expect(formatTranscriptTimeLabel(Number.POSITIVE_INFINITY, now)).toBeNull(); + expect(formatTranscriptTimeLabel(Number.NEGATIVE_INFINITY, now)).toBeNull(); + }); + + it('returns null for an out-of-range finite epoch without throwing', () => { + const now = new Date(2026, 7, 5, 9, 1).getTime(); + expect(formatTranscriptTimeLabel(Number.MAX_VALUE, now)).toBeNull(); + }); + + it('formats time only when created is on the same local day as now', () => { + const created = new Date(2026, 7, 5, 14, 32).getTime(); + const now = new Date(2026, 7, 5, 9, 1).getTime(); + expect(formatTranscriptTimeLabel(created, now)).toBe(TIME_ONLY.format(new Date(created))); + }); + + it('formats date and time when created is on another local day', () => { + const created = new Date(2026, 7, 5, 14, 32).getTime(); + const now = new Date(2026, 7, 6, 9, 1).getTime(); + expect(formatTranscriptTimeLabel(created, now)).toBe(DATE_TIME.format(new Date(created))); + }); + + it('formats date and time when created is in a different year', () => { + const created = new Date(2025, 11, 31, 23, 59).getTime(); + const now = new Date(2026, 7, 5, 9, 1).getTime(); + expect(formatTranscriptTimeLabel(created, now)).toBe(DATE_TIME.format(new Date(created))); + }); +}); diff --git a/apps/mobile/src/components/agents/message-time-label.ts b/apps/mobile/src/components/agents/message-time-label.ts new file mode 100644 index 0000000000..4aff5cf30b --- /dev/null +++ b/apps/mobile/src/components/agents/message-time-label.ts @@ -0,0 +1,31 @@ +const TIME_ONLY = new Intl.DateTimeFormat(undefined, { timeStyle: 'short' }); + +const DATE_TIME = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); + +/** + * Format an epoch-ms created timestamp for the message time label. + * Same local day as `now` → time only; any other local day → date and time. + * Returns null for absent, invalid, non-positive, or out-of-range epochs + * without throwing (an Invalid Date makes `Intl.DateTimeFormat.format` throw). + */ +export function formatTranscriptTimeLabel( + created: number | undefined | null, + now: number +): string | null { + if (created === undefined || created === null || !Number.isFinite(created) || created <= 0) { + return null; + } + const createdDate = new Date(created); + if (Number.isNaN(createdDate.getTime())) { + return null; + } + const nowDate = new Date(now); + const sameDay = + createdDate.getFullYear() === nowDate.getFullYear() && + createdDate.getMonth() === nowDate.getMonth() && + createdDate.getDate() === nowDate.getDate(); + return sameDay ? TIME_ONLY.format(createdDate) : DATE_TIME.format(createdDate); +} diff --git a/apps/mobile/src/components/agents/open-part-detail-context.ts b/apps/mobile/src/components/agents/open-part-detail-context.ts new file mode 100644 index 0000000000..e4346caa35 --- /dev/null +++ b/apps/mobile/src/components/agents/open-part-detail-context.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +/** + * Single test seam for row-press behavior. Mounted once per transcript surface + * by `PartDetailSheetHost`; rows read it to open the detail sheet for a part. + * Lives in its own module with no component imports so the card split compiles + * before the sheet infrastructure exists. + */ +export const OpenPartDetailContext = createContext<((partId: string) => void) | null>(null); + +export function useOpenPartDetail(): ((partId: string) => void) | null { + return useContext(OpenPartDetailContext); +} diff --git a/apps/mobile/src/components/agents/part-detail-model.test.ts b/apps/mobile/src/components/agents/part-detail-model.test.ts new file mode 100644 index 0000000000..3da0d219ea --- /dev/null +++ b/apps/mobile/src/components/agents/part-detail-model.test.ts @@ -0,0 +1,122 @@ +import { + type Part, + type ReasoningPart, + type StoredMessage, + type ToolPart, +} from '@kilocode/cloud-agent-sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { findPartById, getPartDetailTitle } from './part-detail-model'; + +const { getToolDisplay } = vi.hoisted(() => ({ + getToolDisplay: vi.fn(), +})); +vi.mock('./tool-card-display', () => ({ getToolDisplay })); + +function makeToolPart( + overrides: { tool?: string; input?: Record } = {} +): ToolPart { + return { + id: 'tool-1', + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: 'call-1', + tool: overrides.tool ?? 'bash', + state: { + status: 'completed', + input: overrides.input ?? { command: 'echo hi' }, + output: '', + title: 'bash', + metadata: {}, + time: { start: 1, end: 2 }, + }, + }; +} + +function makeReasoningPart(text: string, ended = true): ReasoningPart { + return { + id: 'r1', + sessionID: 's1', + messageID: 'm1', + type: 'reasoning', + text, + time: { start: 1, end: ended ? 2 : undefined }, + }; +} + +function makeTextPart(): Part { + return { + id: 'text-1', + sessionID: 's1', + messageID: 'm1', + type: 'text', + text: 'hello', + }; +} + +function makeMessage(parts: Part[]): StoredMessage { + return { + info: { + id: `msg-${parts[0]?.id ?? 'x'}`, + sessionID: 's1', + role: 'user', + time: { created: 1 }, + agent: 'test', + model: { providerID: 'kilo', modelID: 'claude-sonnet-4' }, + }, + parts, + }; +} + +describe('findPartById', () => { + beforeEach(() => { + getToolDisplay.mockReset(); + }); + + it('finds a part across multiple messages', () => { + const messages = [ + makeMessage([makeToolPart(), makeReasoningPart('r')]), + makeMessage([makeTextPart()]), + ]; + expect(findPartById(messages, 'tool-1')?.id).toBe('tool-1'); + expect(findPartById(messages, 'text-1')?.type).toBe('text'); + }); + + it('returns null for an unknown id', () => { + const messages = [makeMessage([makeToolPart()])]; + expect(findPartById(messages, 'nope')).toBeNull(); + }); + + it('returns null for empty messages', () => { + expect(findPartById([], 'nope')).toBeNull(); + }); +}); + +describe('getPartDetailTitle', () => { + beforeEach(() => { + getToolDisplay.mockReset(); + }); + + it('combines the display title and subtitle for tools', () => { + getToolDisplay.mockReturnValue({ title: 'bash', subtitle: 'echo hi' }); + expect(getPartDetailTitle(makeToolPart())).toBe('bash: echo hi'); + }); + + it('uses the display title alone when the tool has no subtitle', () => { + getToolDisplay.mockReturnValue({ title: 'glob' }); + expect(getPartDetailTitle(makeToolPart({ tool: 'glob' }))).toBe('glob'); + }); + + it('labels streaming reasoning as Thinking', () => { + expect(getPartDetailTitle(makeReasoningPart('reasoning', false))).toBe('Thinking'); + }); + + it('labels completed reasoning as Thought', () => { + expect(getPartDetailTitle(makeReasoningPart('reasoning', true))).toBe('Thought'); + }); + + it('falls back to Details for other part types', () => { + expect(getPartDetailTitle(makeTextPart())).toBe('Details'); + }); +}); diff --git a/apps/mobile/src/components/agents/part-detail-model.ts b/apps/mobile/src/components/agents/part-detail-model.ts new file mode 100644 index 0000000000..296957229c --- /dev/null +++ b/apps/mobile/src/components/agents/part-detail-model.ts @@ -0,0 +1,35 @@ +import { type Part, type StoredMessage } from '@kilocode/cloud-agent-sdk'; + +import { isPartStreaming, isReasoningPart, isToolPart } from './part-types'; +import { getToolDisplay } from './tool-card-display'; + +/** + * Resolve a part by id from a surface's live messages. The sheet host calls + * this on every render so an open sheet tracks the part as it streams. + */ +export function findPartById(messages: readonly StoredMessage[], partId: string): Part | null { + for (const message of messages) { + for (const part of message.parts) { + if (part.id === partId) { + return part; + } + } + } + return null; +} + +/** + * Sheet header title for a part. Tools follow the same display projection the + * fixed row uses so the title updates live with the part. Reasoning shows the + * stream state; anything else is an unreachable fallback. + */ +export function getPartDetailTitle(part: Part): string { + if (isReasoningPart(part)) { + return isPartStreaming(part) ? 'Thinking' : 'Thought'; + } + if (isToolPart(part)) { + const display = getToolDisplay(part); + return display.subtitle ? `${display.title}: ${display.subtitle}` : display.title; + } + return 'Details'; +} diff --git a/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx b/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx new file mode 100644 index 0000000000..a9b4f5bca4 --- /dev/null +++ b/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx @@ -0,0 +1,197 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */ +import { + type Part, + type ReasoningPart, + type StoredMessage, + type ToolPart, +} from '@kilocode/cloud-agent-sdk'; +import { type ReactElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { useOpenPartDetail } from './open-part-detail-context'; +import { PartDetailSheetHost } from './part-detail-sheet-host'; + +vi.mock('react-native', () => ({ + Modal: 'Modal', + ScrollView: 'ScrollView', + View: 'View', +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +vi.mock('@/components/sheet-header', () => ({ + SheetHeader: 'SheetHeader', +})); +vi.mock('@/components/ui/text', () => ({ + Text: 'Text', +})); +vi.mock('./tool-part-detail-body', () => ({ + ToolPartDetailBody: 'ToolPartDetailBody', +})); + +let capturedOpener: ((partId: string) => void) | null = null; +function Opener() { + capturedOpener = useOpenPartDetail(); + return null; +} + +function makeBashPart(id: string, command: string, completed = false): ToolPart { + return { + id, + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: `call-${id}`, + tool: 'bash', + state: completed + ? { + status: 'completed', + input: { command }, + output: 'done', + title: 'bash', + metadata: {}, + time: { start: 1, end: 2 }, + } + : { status: 'running', input: { command }, time: { start: 1 } }, + }; +} + +function makeReasoningPart(id: string, text: string, ended = true): ReasoningPart { + return { + id, + sessionID: 's1', + messageID: 'm1', + type: 'reasoning', + text, + time: { start: 1, end: ended ? 2 : undefined }, + }; +} + +function makeMessage(id: string, parts: Part[]): StoredMessage { + return { + info: { + id, + sessionID: 's1', + role: 'user', + time: { created: 1 }, + agent: 'test', + model: { providerID: 'kilo', modelID: 'claude-sonnet-4' }, + }, + parts, + }; +} + +function hostElement(messages: StoredMessage[]): ReactElement { + return ( + + + + ); +} + +function findByType( + root: TestRenderer.ReactTestInstance, + type: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); +} + +function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown { + if (!instance) { + return undefined; + } + /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */ + return instance.props[key]; + /* eslint-enable typescript-eslint/no-unsafe-member-access */ +} + +function sheetTitle(renderer: TestRenderer.ReactTestRenderer): unknown { + return propOf(findByType(renderer.root, 'SheetHeader')[0], 'title'); +} + +async function mountHost(messages: StoredMessage[]): Promise { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create(hostElement(messages)); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +describe('PartDetailSheetHost mounted', () => { + it('opens a tool part and updates content live without close/reopen', async () => { + const renderer = await mountHost([makeMessage('m1', [makeBashPart('bash-1', 'echo one')])]); + + expect(capturedOpener).toBeTypeOf('function'); + + await act(async () => { + await Promise.resolve(); + capturedOpener?.('bash-1'); + }); + + expect(propOf(findByType(renderer.root, 'Modal')[0], 'visible')).toBe(true); + expect(propOf(findByType(renderer.root, 'Modal')[0], 'presentationStyle')).toBe('pageSheet'); + expect(sheetTitle(renderer)).toBe('bash: echo one'); + const openedPart = propOf(findByType(renderer.root, 'ToolPartDetailBody')[0], 'part'); + expect((openedPart as ToolPart).id).toBe('bash-1'); + + // A stream tick replaces the part object in the messages array. The host + // re-resolves the same id from the live prop on every render. + await act(async () => { + await Promise.resolve(); + renderer.update(hostElement([makeMessage('m1', [makeBashPart('bash-1', 'echo two', true)])])); + }); + + // Still open, no close/reopen: the sheet reflects the refreshed part. + expect(propOf(findByType(renderer.root, 'Modal')[0], 'visible')).toBe(true); + expect(sheetTitle(renderer)).toBe('bash: echo two'); + const refreshedPart = propOf(findByType(renderer.root, 'ToolPartDetailBody')[0], 'part'); + expect((refreshedPart as ToolPart).state.status).toBe('completed'); + }); + + it('shows Details unavailable when the open part id does not resolve', async () => { + const renderer = await mountHost([makeMessage('m1', [makeBashPart('bash-1', 'echo one')])]); + + await act(async () => { + await Promise.resolve(); + capturedOpener?.('ghost'); + }); + + expect(sheetTitle(renderer)).toBe('Details'); + const unavailable = renderer.root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'Text' && + propOf(node, 'children') === 'Details unavailable' + ); + expect(unavailable).toHaveLength(1); + }); + + it('renders full selectable reasoning text with the completed label', async () => { + const renderer = await mountHost([ + makeMessage('m1', [makeReasoningPart('r1', 'working through it', true)]), + ]); + + await act(async () => { + await Promise.resolve(); + capturedOpener?.('r1'); + }); + + expect(sheetTitle(renderer)).toBe('Thought'); + const reasoningTexts = renderer.root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'Text' && + propOf(node, 'children') === 'working through it' && + propOf(node, 'selectable') === true + ); + expect(reasoningTexts).toHaveLength(1); + }); +}); diff --git a/apps/mobile/src/components/agents/part-detail-sheet-host.tsx b/apps/mobile/src/components/agents/part-detail-sheet-host.tsx new file mode 100644 index 0000000000..a53588f386 --- /dev/null +++ b/apps/mobile/src/components/agents/part-detail-sheet-host.tsx @@ -0,0 +1,40 @@ +import { type StoredMessage } from '@kilocode/cloud-agent-sdk'; +import { type ReactNode, useCallback, useState } from 'react'; + +import { OpenPartDetailContext } from './open-part-detail-context'; +import { findPartById } from './part-detail-model'; +import { PartDetailSheet } from './part-detail-sheet'; + +type PartDetailSheetHostProps = { + messages: readonly StoredMessage[]; + children: ReactNode; +}; + +/** + * Per-transcript-surface host: provides the context opener to the rows it + * wraps and mounts the detail sheet. Stores only the open part id and + * re-resolves the part from the live `messages` prop on every render, so an + * open sheet tracks the part as it streams without a close/reopen. The + * fragment never wraps children in a layout view — the FlashList needs + * flex-1 passthrough. + */ +export function PartDetailSheetHost({ messages, children }: Readonly) { + const [openPartId, setOpenPartId] = useState(null); + + const part = openPartId ? findPartById(messages, openPartId) : null; + + const open = useCallback((partId: string) => { + setOpenPartId(partId); + }, []); + + const close = useCallback(() => { + setOpenPartId(null); + }, []); + + return ( + <> + {children} + + + ); +} diff --git a/apps/mobile/src/components/agents/part-detail-sheet.tsx b/apps/mobile/src/components/agents/part-detail-sheet.tsx new file mode 100644 index 0000000000..226fa1580b --- /dev/null +++ b/apps/mobile/src/components/agents/part-detail-sheet.tsx @@ -0,0 +1,69 @@ +import { type Part } from '@kilocode/cloud-agent-sdk'; +import { type ReactNode } from 'react'; +import { Modal, ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { SheetHeader } from '@/components/sheet-header'; +import { Text } from '@/components/ui/text'; + +import { getPartDetailTitle } from './part-detail-model'; +import { isReasoningPart, isToolPart } from './part-types'; +import { ToolPartDetailBody } from './tool-part-detail-body'; + +type PartDetailSheetProps = { + visible: boolean; + part: Part | null; + onClose: () => void; +}; + +function renderPartContent(part: Part | null): ReactNode { + if (part === null) { + return Details unavailable; + } + if (isToolPart(part)) { + return ; + } + if (isReasoningPart(part)) { + return ( + + {part.text} + + ); + } + return null; +} + +/** + * Detail sheet for a non-message transcript part. Follows the message-details + * sheet: a pageSheet modal with a SheetHeader, scroll content, and a safe-area + * footer. A vanished part shows a muted "Details unavailable" line; tool parts + * render the shared body dispatcher; reasoning parts render full selectable + * text. Reasoning and mono blocks are selectable here because the sheet is + * outside `InMessageBubbleContext`. + */ +export function PartDetailSheet({ visible, part, onClose }: Readonly) { + const insets = useSafeAreaInsets(); + + return ( + + + + + + {renderPartContent(part)} + + + + + + ); +} diff --git a/apps/mobile/src/components/agents/part-renderer.test.ts b/apps/mobile/src/components/agents/part-renderer.test.ts index 66830df4d9..2a184f6545 100644 --- a/apps/mobile/src/components/agents/part-renderer.test.ts +++ b/apps/mobile/src/components/agents/part-renderer.test.ts @@ -80,6 +80,7 @@ describe('PartRenderer', () => { ).props.children; expect(reasoningElement.type).toBe(ReasoningPartRenderer); expect(reasoningElement.props).toMatchObject({ + partId: 'r1', text: 'Meaningful reasoning text', isStreaming: false, }); diff --git a/apps/mobile/src/components/agents/part-renderer.tsx b/apps/mobile/src/components/agents/part-renderer.tsx index a0af56e555..d42ce0924e 100644 --- a/apps/mobile/src/components/agents/part-renderer.tsx +++ b/apps/mobile/src/components/agents/part-renderer.tsx @@ -64,6 +64,7 @@ export function PartRenderer({ return ( ({ Pressable: 'Pressable', View: 'View', })); -vi.mock('lucide-react-native', () => ({ - ChevronDown: 'ChevronDown', - ChevronRight: 'ChevronRight', -})); vi.mock('@/components/ui/eyebrow', () => ({ Eyebrow: 'Eyebrow', })); vi.mock('@/components/ui/text', () => ({ Text: 'Text', })); -vi.mock('@/lib/hooks/use-theme-colors', () => ({ - useThemeColors: () => ({ mutedSoft: '#999999' }), -})); vi.mock('./bubble-text-selection-context', () => ({ useTranscriptTextSelectable: () => false, })); +vi.mock('./fixed-part-row', () => ({ + FixedPartRow: 'FixedPartRow', +})); -/** Find the body element that contains the reasoning text (View right after the Pressable). */ +/** Find the body element that contains the reasoning text (a Text host with the raw text). */ function findTextElement( root: TestRenderer.ReactTestInstance ): TestRenderer.ReactTestInstance | undefined { - return root.find( + const matches = root.findAll( node => typeof node.type === 'string' && (node.type as string) === 'Text' && typeof node.props.children === 'string' ); + return matches[0]; +} + +/** Mount the renderer with the optional detail opener context value. */ +async function renderRenderer(props: { + partId: string; + text: string; + isStreaming?: boolean; + defaultExpanded?: boolean; + openPartDetail?: (partId: string) => void; +}): Promise { + const { openPartDetail, ...rendererProps } = props; + const element = createElement( + OpenPartDetailContext.Provider, + { value: openPartDetail ?? null }, + createElement(ReasoningPartRenderer, rendererProps) + ); + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create(element); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; } describe('ReasoningPartRenderer mounted', () => { - it('shows text without entering animation on first mount', async () => { - const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { - current: undefined, - }; - await act(async () => { - await Promise.resolve(); - rendererRef.current = TestRenderer.create( - createElement(ReasoningPartRenderer, { - text: 'First reasoning step', - isStreaming: false, - defaultExpanded: true, - }) - ); + it('expanded mode renders the full text with no Pressable and no entering animation', async () => { + const renderer = await renderRenderer({ + partId: 'p1', + text: 'First reasoning step', + isStreaming: false, + defaultExpanded: true, }); - const renderer = rendererRef.current; - if (!renderer) { - throw new Error('renderer was not created'); - } const textEl = findTextElement(renderer.root); expect(textEl).toBeDefined(); expect(textEl?.props.children).toBe('First reasoning step'); + // Expanded mode is static: no toggle control in the tree. + expect(renderer.root.findAll(node => (node.type as string) === 'Pressable')).toHaveLength(0); + // The body element must not have an entering prop (animation removed). - // After the fix, the body is a plain View, not an Animated.View. + // The body is a plain View, not an Animated.View. /* eslint-disable typescript-eslint/no-unsafe-member-access */ const bodyViews = renderer.root.findAll( node => @@ -76,24 +94,13 @@ describe('ReasoningPartRenderer mounted', () => { /* eslint-enable typescript-eslint/no-unsafe-member-access */ }); - it('shows text without entering animation on recycled mount', async () => { - const firstRef: { current: TestRenderer.ReactTestRenderer | undefined } = { - current: undefined, - }; - await act(async () => { - await Promise.resolve(); - firstRef.current = TestRenderer.create( - createElement(ReasoningPartRenderer, { - text: 'Recycled reasoning', - isStreaming: false, - defaultExpanded: true, - }) - ); + it('expanded mode stays static on a recycled mount', async () => { + const firstRenderer = await renderRenderer({ + partId: 'p1', + text: 'Recycled reasoning', + isStreaming: false, + defaultExpanded: true, }); - const firstRenderer = firstRef.current; - if (!firstRenderer) { - throw new Error('first renderer was not created'); - } // Unmount await act(async () => { await Promise.resolve(); @@ -101,28 +108,21 @@ describe('ReasoningPartRenderer mounted', () => { }); // Second mount (recycled) - const secondRef: { current: TestRenderer.ReactTestRenderer | undefined } = { - current: undefined, - }; - await act(async () => { - await Promise.resolve(); - secondRef.current = TestRenderer.create( - createElement(ReasoningPartRenderer, { - text: 'Recycled reasoning', - isStreaming: false, - defaultExpanded: true, - }) - ); + const secondRenderer = await renderRenderer({ + partId: 'p1', + text: 'Recycled reasoning', + isStreaming: false, + defaultExpanded: true, }); - const secondRenderer = secondRef.current; - if (!secondRenderer) { - throw new Error('second renderer was not created'); - } const textEl = findTextElement(secondRenderer.root); expect(textEl).toBeDefined(); expect(textEl?.props.children).toBe('Recycled reasoning'); + expect(secondRenderer.root.findAll(node => (node.type as string) === 'Pressable')).toHaveLength( + 0 + ); + /* eslint-disable typescript-eslint/no-unsafe-member-access */ const bodyViews = secondRenderer.root.findAll( node => @@ -135,46 +135,84 @@ describe('ReasoningPartRenderer mounted', () => { /* eslint-enable typescript-eslint/no-unsafe-member-access */ }); - it('returns null for blank (empty string) text', async () => { - const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { - current: undefined, - }; + it('collapsed mode renders one fixed row and pressing it opens the part id through context', async () => { + const openPartDetail = vi.fn((_partId: string) => undefined); + const renderer = await renderRenderer({ + partId: 'part-1', + text: 'Hidden reasoning text', + isStreaming: false, + openPartDetail, + }); + + // No body text in collapsed mode. + expect(findTextElement(renderer.root)).toBeUndefined(); + + const rows = renderer.root.findAll(node => (node.type as string) === 'FixedPartRow'); + expect(rows).toHaveLength(1); + const row = rows[0]; + if (!row) { + throw new Error('row not found'); + } + expect(row.props.label).toBe('Thought'); + expect(row.props.labelKind).toBe('eyebrow'); + expect(row.props.variant).toBe('dashed'); + expect(row.props.accessibilityLabel).toBe('Thought'); + + // Pressing the row calls the opener with the supplied part id. + /* eslint-disable typescript-eslint/no-unsafe-member-access */ + const onPress = row.props.onPress as () => void; + /* eslint-enable typescript-eslint/no-unsafe-member-access */ await act(async () => { await Promise.resolve(); - rendererRef.current = TestRenderer.create( - createElement(ReasoningPartRenderer, { - text: '', - isStreaming: false, - defaultExpanded: true, - }) - ); + onPress(); + }); + expect(openPartDetail).toHaveBeenCalledWith('part-1'); + }); + + it('collapsed mode labels a streaming part as Thinking', async () => { + const renderer = await renderRenderer({ + partId: 'part-1', + text: 'Streaming reasoning', + isStreaming: true, + }); + + const rows = renderer.root.findAll(node => (node.type as string) === 'FixedPartRow'); + expect(rows).toHaveLength(1); + expect(rows[0]?.props.label).toBe('Thinking'); + expect(rows[0]?.props.accessibilityLabel).toBe('Thinking'); + }); + + it('collapsed mode keeps the row visible but non-pressable without an opener', async () => { + const renderer = await renderRenderer({ + partId: 'part-1', + text: 'Hidden reasoning text', + isStreaming: false, + }); + + const rows = renderer.root.findAll(node => (node.type as string) === 'FixedPartRow'); + expect(rows).toHaveLength(1); + // No context opener: the row stays visible with no press wiring. + expect(rows[0]?.props.onPress).toBeUndefined(); + }); + + it('returns null for blank (empty string) text', async () => { + const renderer = await renderRenderer({ + partId: 'p1', + text: '', + isStreaming: false, + defaultExpanded: true, }); - const renderer = rendererRef.current; - if (!renderer) { - throw new Error('renderer was not created'); - } // The renderer returns null, so the tree should have no children. expect(renderer.root.children).toHaveLength(0); }); it('returns null for whitespace-only text', async () => { - const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { - current: undefined, - }; - await act(async () => { - await Promise.resolve(); - rendererRef.current = TestRenderer.create( - createElement(ReasoningPartRenderer, { - text: ' \n\t ', - isStreaming: false, - defaultExpanded: true, - }) - ); + const renderer = await renderRenderer({ + partId: 'p1', + text: ' \n\t ', + isStreaming: false, + defaultExpanded: true, }); - const renderer = rendererRef.current; - if (!renderer) { - throw new Error('renderer was not created'); - } expect(renderer.root.children).toHaveLength(0); }); }); diff --git a/apps/mobile/src/components/agents/reasoning-part-renderer.tsx b/apps/mobile/src/components/agents/reasoning-part-renderer.tsx index d1fa2a69b9..aa34b96d15 100644 --- a/apps/mobile/src/components/agents/reasoning-part-renderer.tsx +++ b/apps/mobile/src/components/agents/reasoning-part-renderer.tsx @@ -1,59 +1,62 @@ -import { ChevronDown, ChevronRight } from 'lucide-react-native'; -import { useState } from 'react'; -import { Pressable, View } from 'react-native'; +import { View } from 'react-native'; import { Eyebrow } from '@/components/ui/eyebrow'; import { Text } from '@/components/ui/text'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useTranscriptTextSelectable } from './bubble-text-selection-context'; +import { FixedPartRow } from './fixed-part-row'; +import { useOpenPartDetail } from './open-part-detail-context'; type ReasoningPartRendererProps = { + partId: string; text: string; isStreaming?: boolean; defaultExpanded?: boolean; }; export function ReasoningPartRenderer({ + partId, text, isStreaming, defaultExpanded = false, }: Readonly) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - const colors = useThemeColors(); + const openPartDetail = useOpenPartDetail(); const textSelectable = useTranscriptTextSelectable(); if (text.trim() === '') { return null; } - return ( - - { - setIsExpanded(prev => !prev); - }} - accessibilityRole="button" - accessibilityLabel={isStreaming ? 'Thinking' : 'Thought'} - accessibilityHint={isExpanded ? 'Collapse details' : 'Expand details'} - accessibilityState={{ expanded: isExpanded }} - > - {isStreaming ? 'Thinking' : 'Thought'} - {isExpanded ? ( - - ) : ( - - )} - - - {isExpanded ? ( + const label = isStreaming ? 'Thinking' : 'Thought'; + + if (defaultExpanded) { + // Static, not collapsible: dashed container, Eyebrow label, full text. + // No Pressable, no chevron, no collapse state. + return ( + + {label} {text} - ) : null} - + + ); + } + + return ( + { + openPartDetail(partId); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 74e71c21c5..45e3ec9f7b 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -72,6 +72,7 @@ import { openChildSessionSheet, releaseChildSessionSheet, } from '@/components/agents/child-session-sheet-state'; +import { PartDetailSheetHost } from '@/components/agents/part-detail-sheet-host'; import { PartRenderer } from '@/components/agents/part-renderer'; import { QueryError } from '@/components/query-error'; import { RenameModal } from '@/components/rename-modal'; @@ -781,96 +782,98 @@ export function SessionDetailContent({ isFocused && agentStatus.type !== 'disconnected' && (isStreaming || pendingMessages.size > 0); return ( - - - {keepScreenAwake ? : null} + + + + {keepScreenAwake ? : null} - {!isConnected && } + {!isConnected && } - {keyboardContainerKind === 'app-aware-padding' ? ( - - {renderKeyboardBody()} - - ) : ( - - {renderKeyboardBody()} - - )} + {keyboardContainerKind === 'app-aware-padding' ? ( + + {renderKeyboardBody()} + + ) : ( + + {renderKeyboardBody()} + + )} - {isComposerVisible ? ( - - - - ) : ( - - )} + {isComposerVisible ? ( + + + + ) : ( + + )} + + {sheetMountState.mounted ? ( + { + setOpenContextSheetIdentity(null); + }} + /> + ) : null} - {sheetMountState.mounted ? ( - { - setOpenContextSheetIdentity(null); + setDetailsMessage(null); }} /> - ) : null} - - { - setDetailsMessage(null); - }} - /> - {childSessionSheet.sheet ? ( - } - onOpenChildSession={handleOpenChildSession} - onRetry={() => { - const openSheet = childSessionSheet.sheet; - if (!openSheet) { - return; - } - void manager.hydrateChildSession(openSheet.sessionId); - }} - onClose={handleCloseChildSession} - onDismiss={handleChildSheetDismiss} - /> - ) : null} + {childSessionSheet.sheet ? ( + } + onOpenChildSession={handleOpenChildSession} + onRetry={() => { + const openSheet = childSessionSheet.sheet; + if (!openSheet) { + return; + } + void manager.hydrateChildSession(openSheet.sessionId); + }} + onClose={handleCloseChildSession} + onDismiss={handleChildSheetDismiss} + /> + ) : null} - {rename.isTitleInteractive && rename.isModalOpen ? ( - - ) : null} - + {rename.isTitleInteractive && rename.isModalOpen ? ( + + ) : null} + + ); function renderKeyboardBody() { diff --git a/apps/mobile/src/components/agents/suggest-tool-card.test.ts b/apps/mobile/src/components/agents/suggest-tool-card.test.ts new file mode 100644 index 0000000000..80333145b8 --- /dev/null +++ b/apps/mobile/src/components/agents/suggest-tool-card.test.ts @@ -0,0 +1,212 @@ +import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as React from 'react'; + +import { SuggestToolCard } from './suggest-tool-card'; + +const { resolveSuggestionPresentation, manager, activeSuggestion } = vi.hoisted(() => { + const suggestion = { + requestId: 'req-1', + callId: 'call-1', + text: 'Suggestion text', + actions: [{ label: 'Apply', description: 'Apply this change' }], + }; + return { + resolveSuggestionPresentation: vi.fn(), + manager: { + atoms: { activeSuggestion: {} }, + acceptSuggestion: vi.fn(), + dismissSuggestion: vi.fn(), + }, + activeSuggestion: suggestion, + }; +}); + +vi.mock('./suggestion-card-state', () => ({ resolveSuggestionPresentation })); +vi.mock('./suggestion-card', () => ({ SuggestionCard: 'SuggestionCard' })); +vi.mock('./fixed-part-row', () => ({ FixedPartRow: 'FixedPartRow' })); +vi.mock('lucide-react-native', () => ({ Sparkles: 'Sparkles' })); +vi.mock('jotai', () => ({ useAtomValue: () => activeSuggestion })); +vi.mock('@/components/agents/session-provider', () => ({ + useSessionManager: () => manager, +})); + +function makeSuggestState(status: ToolPart['state']['status']): ToolPart['state'] { + if (status === 'pending') { + return { status: 'pending', input: {}, raw: '' }; + } + if (status === 'running') { + return { status: 'running', input: {}, time: { start: 0 } }; + } + if (status === 'error') { + return { status: 'error', input: {}, error: 'dismissed', time: { start: 0, end: 1 } }; + } + return { + status: 'completed', + input: {}, + output: '', + title: '', + metadata: {}, + time: { start: 0, end: 1 }, + }; +} + +function makeSuggestPart(status: ToolPart['state']['status']): ToolPart { + return { + id: 'suggest-1', + sessionID: 'session-1', + messageID: 'message-1', + type: 'tool', + callID: 'call-1', + tool: 'suggest', + state: makeSuggestState(status), + }; +} + +function findAll( + node: unknown, + predicate: (el: React.ReactElement) => boolean +): React.ReactElement[] { + const matches: React.ReactElement[] = []; + function walk(value: unknown): void { + if (value == null || typeof value === 'string' || typeof value === 'number') { + return; + } + if (Array.isArray(value)) { + for (const child of value) { + walk(child); + } + return; + } + if (React.isValidElement(value)) { + if (predicate(value)) { + matches.push(value); + } + const props = value.props as Record; + if (typeof value.type === 'function') { + walk((value.type as React.FunctionComponent)(props)); + } + walk(props.children); + } + } + walk(node); + return matches; +} + +function findByType(root: React.ReactElement, type: string): React.ReactElement[] { + return findAll(root, el => el.type === type); +} + +describe('SuggestToolCard — interactive suggestion stays in the list', () => { + beforeEach(() => { + resolveSuggestionPresentation.mockReset(); + manager.acceptSuggestion.mockReset(); + manager.dismissSuggestion.mockReset(); + }); + + it('renders SuggestionCard with accept/dismiss wired to the manager', async () => { + resolveSuggestionPresentation.mockReturnValue('interactive'); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = SuggestToolCard({ + part: makeSuggestPart('running'), + }) as unknown as React.ReactElement; + + const cards = findByType(root, 'SuggestionCard'); + expect(cards).toHaveLength(1); + const card = cards[0]; + if (!card) { + throw new Error('card not found'); + } + const cardProps = card.props as { + text: string; + actions: unknown; + onAccept: (index: number) => Promise; + onDismiss: () => Promise; + }; + expect(cardProps).toMatchObject({ + text: 'Suggestion text', + actions: [{ label: 'Apply', description: 'Apply this change' }], + }); + + await cardProps.onAccept(1); + expect(manager.acceptSuggestion).toHaveBeenCalledWith('req-1', 1); + + await cardProps.onDismiss(); + expect(manager.dismissSuggestion).toHaveBeenCalledWith('req-1'); + }); + + it('never renders a fixed row in the interactive presentation', () => { + resolveSuggestionPresentation.mockReturnValue('interactive'); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = SuggestToolCard({ + part: makeSuggestPart('running'), + }) as unknown as React.ReactElement; + expect(findByType(root, 'FixedPartRow')).toHaveLength(0); + }); +}); + +describe('SuggestToolCard — compact fixed row', () => { + beforeEach(() => { + resolveSuggestionPresentation.mockReset(); + }); + + it('renders a disabled fixed row for a pending suggestion', () => { + resolveSuggestionPresentation.mockReturnValue('compact'); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = SuggestToolCard({ + part: makeSuggestPart('pending'), + }) as unknown as React.ReactElement; + + const rows = findByType(root, 'FixedPartRow'); + expect(rows).toHaveLength(1); + const row = rows[0]; + if (!row) { + throw new Error('row not found'); + } + const rowProps = row.props as { + icon: string; + label: string; + status: string; + accessibilityLabel: string; + onPress?: unknown; + }; + expect(rowProps).toMatchObject({ + icon: 'Sparkles', + label: 'Suggestion', + status: 'pending', + accessibilityLabel: 'Suggestion tool, pending', + }); + expect(rowProps.onPress).toBeUndefined(); + }); + + it('uses the plain label for a completed suggestion', () => { + resolveSuggestionPresentation.mockReturnValue('compact'); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = SuggestToolCard({ + part: makeSuggestPart('completed'), + }) as unknown as React.ReactElement; + const row = findByType(root, 'FixedPartRow')[0]; + if (!row) { + throw new Error('row not found'); + } + const rowProps = row.props as { label: string; accessibilityLabel: string }; + expect(rowProps.label).toBe('Suggestion'); + expect(rowProps.accessibilityLabel).toBe('Suggestion tool, completed'); + }); + + it('uses the dismissed label for an error suggestion', () => { + resolveSuggestionPresentation.mockReturnValue('compact'); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = SuggestToolCard({ + part: makeSuggestPart('error'), + }) as unknown as React.ReactElement; + const row = findByType(root, 'FixedPartRow')[0]; + if (!row) { + throw new Error('row not found'); + } + const rowProps = row.props as { label: string; status: string; accessibilityLabel: string }; + expect(rowProps.label).toBe('Suggestion dismissed'); + expect(rowProps.status).toBe('error'); + expect(rowProps.accessibilityLabel).toBe('Suggestion dismissed tool, error'); + }); +}); diff --git a/apps/mobile/src/components/agents/suggest-tool-card.tsx b/apps/mobile/src/components/agents/suggest-tool-card.tsx index b971502821..a154c274f6 100644 --- a/apps/mobile/src/components/agents/suggest-tool-card.tsx +++ b/apps/mobile/src/components/agents/suggest-tool-card.tsx @@ -4,9 +4,10 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { useSessionManager } from '@/components/agents/session-provider'; +import { FixedPartRow } from './fixed-part-row'; import { resolveSuggestionPresentation } from './suggestion-card-state'; import { SuggestionCard } from './suggestion-card'; -import { ToolCardShell } from './tool-card-shell'; +import { getToolDisplay } from './tool-card-display'; export function SuggestToolCard({ part }: Readonly<{ part: ToolPart }>) { const manager = useSessionManager(); @@ -33,12 +34,15 @@ export function SuggestToolCard({ part }: Readonly<{ part: ToolPart }>) { ); } + const display = getToolDisplay(part); + const label = display.subtitle ?? display.title; + return ( - ); } diff --git a/apps/mobile/src/components/agents/tool-card-display.test.ts b/apps/mobile/src/components/agents/tool-card-display.test.ts new file mode 100644 index 0000000000..6115c2b6a0 --- /dev/null +++ b/apps/mobile/src/components/agents/tool-card-display.test.ts @@ -0,0 +1,324 @@ +import { type FilePart, type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { describe, expect, it } from 'vitest'; + +import { getToolDisplay, type ToolDisplay, toolPartHasDetails } from './tool-card-display'; + +function makeToolPart(tool: string, state: ToolPart['state']): ToolPart { + return { + id: 'part-1', + sessionID: 'session-1', + messageID: 'message-1', + type: 'tool', + callID: 'call-1', + tool, + state, + }; +} + +function completed(input: Record = {}, output = ''): ToolPart['state'] { + return { + status: 'completed', + input, + output, + title: '', + metadata: {}, + time: { start: 0, end: 1 }, + }; +} + +function running(input: Record = {}): ToolPart['state'] { + return { status: 'running', input, time: { start: 0 } }; +} + +function errorState(input: Record = {}, error = 'failed'): ToolPart['state'] { + return { status: 'error', input, error, time: { start: 0, end: 1 } }; +} + +function makeAttachment(mime: string): FilePart { + return { + id: 'att-1', + sessionID: 'session-1', + messageID: 'message-1', + type: 'file', + mime, + url: '', + }; +} + +/** Typed read of the projection — pins the exported `ToolDisplay` shape. */ +function getDisplay(part: ToolPart): ToolDisplay { + return getToolDisplay(part); +} + +describe('getToolDisplay mapping', () => { + it('maps read with and without a file path', () => { + expect(getDisplay(makeToolPart('read', completed({ filePath: '/repo/src/app.ts' })))).toEqual({ + title: 'read', + subtitle: 'app.ts', + }); + expect(getDisplay(makeToolPart('read', completed()))).toEqual({ + title: 'read', + subtitle: 'read', + }); + }); + + it('maps edit with and without a file path', () => { + expect(getDisplay(makeToolPart('edit', completed({ filePath: 'src/app.tsx' })))).toEqual({ + title: 'edit', + subtitle: 'app.tsx', + }); + expect(getDisplay(makeToolPart('edit', completed()))).toEqual({ + title: 'edit', + subtitle: 'edit', + }); + }); + + it('maps write with and without a file path', () => { + expect(getDisplay(makeToolPart('write', completed({ filePath: 'new-file.ts' })))).toEqual({ + title: 'write', + subtitle: 'new-file.ts', + }); + expect(getDisplay(makeToolPart('write', completed()))).toEqual({ + title: 'write', + subtitle: 'write', + }); + }); + + it('maps bash description, command, and empty input', () => { + expect(getDisplay(makeToolPart('bash', completed({ description: 'List files' })))).toEqual({ + title: 'bash', + subtitle: 'List files', + }); + expect(getDisplay(makeToolPart('bash', completed({ command: 'ls -la' })))).toEqual({ + title: 'bash', + subtitle: 'ls -la', + }); + expect(getDisplay(makeToolPart('bash', completed()))).toEqual({ + title: 'bash', + subtitle: 'bash', + }); + }); + + it('truncates a long bash command to 60 characters', () => { + const command = 'x'.repeat(70); + expect(getDisplay(makeToolPart('bash', completed({ command }))).subtitle).toBe( + `${'x'.repeat(60)}\u2026` + ); + }); + + it('maps glob with a pattern', () => { + expect(getDisplay(makeToolPart('glob', completed({ pattern: '**/*.ts' })))).toEqual({ + title: 'glob', + subtitle: '**/*.ts', + }); + }); + + it('maps grep with pattern and include', () => { + expect( + getDisplay(makeToolPart('grep', completed({ pattern: 'foo', include: '*.ts' }))) + ).toEqual({ title: 'grep', subtitle: 'foo (*.ts)' }); + expect(getDisplay(makeToolPart('grep', completed({ pattern: 'foo' })))).toEqual({ + title: 'grep', + subtitle: 'foo', + }); + expect(getDisplay(makeToolPart('grep', completed()))).toEqual({ + title: 'grep', + subtitle: 'grep', + }); + }); + + it('maps list from path or filePath', () => { + expect(getDisplay(makeToolPart('list', completed({ path: '/repo/src' })))).toEqual({ + title: 'list', + subtitle: 'src', + }); + expect(getDisplay(makeToolPart('list', completed({ filePath: '/repo/src' })))).toEqual({ + title: 'list', + subtitle: 'src', + }); + expect(getDisplay(makeToolPart('list', completed()))).toEqual({ + title: 'list', + subtitle: 'list', + }); + }); + + it('maps websearch, codesearch, and webfetch from query or url', () => { + expect(getDisplay(makeToolPart('websearch', completed({ query: 'search terms' })))).toEqual({ + title: 'websearch', + subtitle: 'search terms', + }); + expect( + getDisplay(makeToolPart('websearch', completed({ url: 'https://example.com' }))) + ).toEqual({ title: 'websearch', subtitle: 'https://example.com' }); + expect( + getDisplay(makeToolPart('websearch', completed({ query: '', url: 'https://example.com' }))) + ).toEqual({ title: 'websearch', subtitle: 'https://example.com' }); + expect(getDisplay(makeToolPart('websearch', completed()))).toEqual({ + title: 'websearch', + subtitle: 'websearch', + }); + expect(getDisplay(makeToolPart('codesearch', completed({ query: 'foo' })))).toEqual({ + title: 'codesearch', + subtitle: 'foo', + }); + expect(getDisplay(makeToolPart('webfetch', completed({ url: 'https://example.com' })))).toEqual( + { title: 'webfetch', subtitle: 'https://example.com' } + ); + }); + + it('maps todo read and todo write', () => { + expect(getDisplay(makeToolPart('todoread', completed()))).toEqual({ + title: 'todoread', + subtitle: 'Read todos', + }); + expect(getDisplay(makeToolPart('todowrite', completed()))).toEqual({ + title: 'todowrite', + subtitle: 'Update todos', + }); + }); + + it('maps task from description or prompt', () => { + expect(getDisplay(makeToolPart('task', completed({ description: 'Do the thing' })))).toEqual({ + title: 'task', + subtitle: 'Do the thing', + }); + expect(getDisplay(makeToolPart('task', completed({ prompt: 'short' })))).toEqual({ + title: 'task', + subtitle: 'short', + }); + expect(getDisplay(makeToolPart('task', completed()))).toEqual({ + title: 'task', + subtitle: 'task', + }); + }); + + it('maps suggest completed and error labels', () => { + expect(getDisplay(makeToolPart('suggest', completed()))).toEqual({ + title: 'Suggestion', + subtitle: 'Suggestion', + }); + expect(getDisplay(makeToolPart('suggest', errorState()))).toEqual({ + title: 'Suggestion', + subtitle: 'Suggestion dismissed', + }); + }); + + it('maps an MCP tool to server/tool title', () => { + expect( + getDisplay( + makeToolPart('mcp', completed({ server_name: 'filesystem', tool_name: 'read_file' })) + ) + ).toEqual({ title: 'mcp', subtitle: 'filesystem/read_file' }); + }); + + it('falls back to the tool name for unknown tools', () => { + expect(getDisplay(makeToolPart('unknown-tool', completed()))).toEqual({ + title: 'unknown-tool', + subtitle: 'unknown-tool', + }); + }); + + it('uses the running/completed state title for the generic subtitle', () => { + expect( + getDisplay( + makeToolPart('mcp', { + status: 'running', + input: {}, + title: 'Custom title', + time: { start: 0 }, + }) + ) + ).toEqual({ title: 'mcp', subtitle: 'Custom title' }); + }); +}); + +describe('getToolDisplay badge rules', () => { + it('builds the read badge from offset and limit', () => { + expect( + getDisplay(makeToolPart('read', completed({ filePath: '/a/b.ts', offset: 10, limit: 25 }))) + .badge + ).toBe('L10, 25 lines'); + expect( + getDisplay(makeToolPart('read', completed({ filePath: '/a/b.ts', offset: 10 }))).badge + ).toBe('L10'); + expect( + getDisplay(makeToolPart('read', completed({ filePath: '/a/b.ts', limit: 25 }))).badge + ).toBe('25 lines'); + }); + + it('omits the read badge when neither offset nor limit is set', () => { + expect( + getDisplay(makeToolPart('read', completed({ filePath: '/a/b.ts' }))).badge + ).toBeUndefined(); + }); + + it('counts non-empty glob output lines as the file badge', () => { + expect( + getDisplay(makeToolPart('glob', completed({ pattern: '**/*.ts' }, 'a.ts\n\nb.ts\n'))).badge + ).toBe('2 files'); + }); + + it('omits the glob badge without completed output', () => { + expect( + getDisplay(makeToolPart('glob', completed({ pattern: '**/*.ts' }, ''))).badge + ).toBeUndefined(); + expect(getDisplay(makeToolPart('glob', running({ pattern: '**/*.ts' }))).badge).toBeUndefined(); + }); + + it('counts non-empty grep output lines as the matches badge', () => { + expect( + getDisplay(makeToolPart('grep', completed({ pattern: 'foo' }, 'a.ts:1\nb.ts:2\nc.ts:3\n'))) + .badge + ).toBe('3 matches'); + }); + + it('omits the grep badge without completed output', () => { + expect( + getDisplay(makeToolPart('grep', completed({ pattern: 'foo' }, ''))).badge + ).toBeUndefined(); + expect(getDisplay(makeToolPart('grep', running({ pattern: 'foo' }))).badge).toBeUndefined(); + }); +}); + +describe('toolPartHasDetails', () => { + it('returns false for suggest even with input', () => { + expect(toolPartHasDetails(makeToolPart('suggest', completed({ prompt: 'hi' })))).toBe(false); + }); + + it('returns false for a running part with empty input and no output', () => { + expect(toolPartHasDetails(makeToolPart('bash', running()))).toBe(false); + }); + + it('returns false for an empty completed part', () => { + expect(toolPartHasDetails(makeToolPart('bash', completed()))).toBe(false); + }); + + it('returns true when input exists', () => { + expect(toolPartHasDetails(makeToolPart('bash', running({ command: 'ls' })))).toBe(true); + }); + + it('returns true when completed output exists', () => { + expect(toolPartHasDetails(makeToolPart('bash', completed({ command: 'ls' }, 'done')))).toBe( + true + ); + }); + + it('returns true when error content exists', () => { + expect(toolPartHasDetails(makeToolPart('bash', errorState({ command: 'ls' }, 'boom')))).toBe( + true + ); + }); + + it('returns true when a completed part has an image attachment', () => { + const part = makeToolPart('read', { + status: 'completed', + input: {}, + output: '', + title: '', + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [makeAttachment('image/png')], + }); + expect(toolPartHasDetails(part)).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/agents/tool-card-display.ts b/apps/mobile/src/components/agents/tool-card-display.ts new file mode 100644 index 0000000000..9ea73f468e --- /dev/null +++ b/apps/mobile/src/components/agents/tool-card-display.ts @@ -0,0 +1,143 @@ +import { type ToolPart } from '@kilocode/cloud-agent-sdk'; + +import { getToolFileAttachments, getToolImageAttachments } from './tool-card-attachments'; +import { + getDirectoryName, + getFilename, + getGenericToolTitle, + truncateText, +} from './tool-card-utils'; + +export type ToolDisplay = { + title: string; + subtitle?: string; + badge?: string; +}; + +function countOutputLines(output: string): number { + if (output.length === 0) { + return 0; + } + return output.split('\n').filter(line => line.trim().length > 0).length; +} + +/** + * Pure row projection for a tool part. The strings and badge rules are copied + * verbatim from the tool-card bodies so the fixed row renders exactly what the + * cards render today. + */ +export function getToolDisplay(part: ToolPart): ToolDisplay { + const input = part.state.input; + const status = part.state.status; + + switch (part.tool) { + case 'read': { + const filePath = typeof input.filePath === 'string' ? input.filePath : ''; + const offset = typeof input.offset === 'number' ? input.offset : undefined; + const limit = typeof input.limit === 'number' ? input.limit : undefined; + + const badgeParts: string[] = []; + if (offset !== undefined) { + badgeParts.push(`L${offset}`); + } + if (limit !== undefined) { + badgeParts.push(`${limit} lines`); + } + const badge = badgeParts.length > 0 ? badgeParts.join(', ') : undefined; + + return { title: 'read', subtitle: filePath ? getFilename(filePath) : 'read', badge }; + } + case 'edit': { + const filePath = typeof input.filePath === 'string' ? input.filePath : ''; + return { title: 'edit', subtitle: filePath ? getFilename(filePath) : 'edit' }; + } + case 'write': { + const filePath = typeof input.filePath === 'string' ? input.filePath : ''; + return { title: 'write', subtitle: filePath ? getFilename(filePath) : 'write' }; + } + case 'bash': { + const command = typeof input.command === 'string' ? input.command : ''; + const description = typeof input.description === 'string' ? input.description : undefined; + const subtitle = description ?? (command ? truncateText(command, 60) : 'bash'); + return { title: 'bash', subtitle }; + } + case 'glob': { + const pattern = typeof input.pattern === 'string' ? input.pattern : ''; + const output = status === 'completed' ? part.state.output : undefined; + const matchCount = output ? countOutputLines(output) : undefined; + const badge = matchCount !== undefined ? `${matchCount} files` : undefined; + return { title: 'glob', subtitle: pattern || 'glob', badge }; + } + case 'grep': { + const pattern = typeof input.pattern === 'string' ? input.pattern : ''; + const include = typeof input.include === 'string' ? input.include : undefined; + let subtitle = pattern || 'grep'; + if (include) { + subtitle += ` (${include})`; + } + const output = status === 'completed' ? part.state.output : undefined; + const matchCount = output ? countOutputLines(output) : undefined; + const badge = matchCount !== undefined ? `${matchCount} matches` : undefined; + return { title: 'grep', subtitle, badge }; + } + case 'list': { + const filePath = typeof input.filePath === 'string' ? input.filePath : undefined; + const path = typeof input.path === 'string' ? input.path : undefined; + const resolvedPath = filePath ?? path ?? ''; + return { title: 'list', subtitle: resolvedPath ? getDirectoryName(resolvedPath) : 'list' }; + } + case 'websearch': + case 'codesearch': + case 'webfetch': { + const query = typeof input.query === 'string' ? input.query : undefined; + const url = typeof input.url === 'string' ? input.url : undefined; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty query must fall back to url; ?? would skip '' + const search = query || url; + return { title: part.tool, subtitle: search ? truncateText(search, 60) : part.tool }; + } + case 'todoread': { + return { title: part.tool, subtitle: 'Read todos' }; + } + case 'todowrite': { + return { title: part.tool, subtitle: 'Update todos' }; + } + case 'task': { + const description = typeof input.description === 'string' ? input.description : undefined; + const prompt = typeof input.prompt === 'string' ? input.prompt : undefined; + const subtitle = description ?? (prompt ? truncateText(prompt, 60) : 'task'); + return { title: 'task', subtitle }; + } + case 'suggest': { + return { + title: 'Suggestion', + subtitle: status === 'error' ? 'Suggestion dismissed' : 'Suggestion', + }; + } + default: { + const stateTitle = + status === 'running' || status === 'completed' ? part.state.title : undefined; + return { title: part.tool, subtitle: getGenericToolTitle(part.tool, stateTitle, input) }; + } + } +} + +/** + * Whether a tool part has content that a detail sheet could show. Suggest parts + * are never detailed. Everything else is detailed when input, completed output, + * error content, or any attachment exists. + */ +export function toolPartHasDetails(part: ToolPart): boolean { + if (part.tool === 'suggest') { + return false; + } + if (Object.keys(part.state.input).length > 0) { + return true; + } + if (part.state.status === 'completed' && part.state.output.length > 0) { + return true; + } + if (part.state.status === 'error' && part.state.error.length > 0) { + return true; + } + return getToolImageAttachments(part).length + getToolFileAttachments(part).length > 0; +} diff --git a/apps/mobile/src/components/agents/tool-card-shell.tsx b/apps/mobile/src/components/agents/tool-card-shell.tsx deleted file mode 100644 index cdb2d5714b..0000000000 --- a/apps/mobile/src/components/agents/tool-card-shell.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { type ToolPart } from '@kilocode/cloud-agent-sdk'; -import { useEffect, useState } from 'react'; -import { ActivityIndicator, Pressable, View } from 'react-native'; -import { ChevronDown, type LucideIcon, XCircle } from 'lucide-react-native'; -import Animated, { - FadeIn, - LinearTransition, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; - -import { Text } from '@/components/ui/text'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; - -import { getToolFileAttachments, getToolImageAttachments } from './tool-card-attachments'; -import { ToolCardFileAttachments } from './tool-card-file-attachments'; -import { ToolCardImageAttachments } from './tool-card-image-attachments'; - -type ToolCardShellProps = { - icon: LucideIcon; - title: string; - subtitle?: string; - badge?: string; - status: 'pending' | 'running' | 'completed' | 'error'; - defaultExpanded?: boolean; - /** When set, completed image attachments render above children in the expanded body. */ - part?: ToolPart; - children?: React.ReactNode; -}; - -export function ToolCardShell({ - icon: Icon, - title, - subtitle, - badge, - status, - defaultExpanded = false, - part, - children, -}: Readonly) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - const colors = useThemeColors(); - const imageAttachments = part ? getToolImageAttachments(part) : []; - const fileAttachments = part ? getToolFileAttachments(part) : []; - const hasContent = Boolean(children) || imageAttachments.length > 0 || fileAttachments.length > 0; - - const rotation = useSharedValue(defaultExpanded ? 180 : 0); - - useEffect(() => { - rotation.value = withTiming(isExpanded ? 180 : 0, { duration: 200 }); - }, [isExpanded, rotation]); - - const chevronStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${rotation.value}deg` }], - })); - - function handlePress() { - if (hasContent) { - setIsExpanded(prev => !prev); - } - } - - let accessibilityHint: string | undefined = undefined; - if (hasContent) { - accessibilityHint = isExpanded ? 'Collapse details' : 'Expand details'; - } - - return ( - - - {status === 'pending' || status === 'running' ? ( - - ) : null} - {status === 'error' ? : null} - {status === 'completed' ? : null} - - - - {subtitle ?? title} - - {badge ? {badge} : null} - - - {hasContent ? ( - - - - ) : null} - - - {isExpanded && hasContent ? ( - - {imageAttachments.length > 0 && part ? : null} - {fileAttachments.length > 0 && part ? : null} - {children} - - ) : null} - - ); -} diff --git a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx index 3dc15bf57c..2a53eb29d9 100644 --- a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx @@ -5,51 +5,63 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; -import { ToolCardShell } from '../tool-card-shell'; -import { truncateText } from '../tool-card-utils'; +import { useOpenPartDetail } from '../open-part-detail-context'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; -export function BashToolCard({ part }: Readonly<{ part: ToolPart }>) { +/** + * Sheet body for a bash tool part: the `$ command` block, the output block, + * and the error. Renders only inside the detail sheet — attachments and the + * pending/running status line live in `ToolPartDetailBody`. + */ +export function BashToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const command = typeof input.command === 'string' ? input.command : ''; - const description = typeof input.description === 'string' ? input.description : undefined; - - const subtitle = description ?? (command ? truncateText(command, 60) : 'bash'); const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; - const hasExpandedContent = command.length > 60 || Boolean(output) || Boolean(error); - return ( - - {hasExpandedContent ? ( - - {command.length > 0 ? ( - - - $ {command} - - - ) : null} - {output ? ( - - ) : null} - {error ? ( - - {error} - - ) : null} + + {command.length > 0 ? ( + + + $ {command} + ) : null} - + {output ? ( + + ) : null} + {error ? ( + + {error} + + ) : null} + + ); +} + +export function BashToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts index 2273e8477d..3cd46bcfc8 100644 --- a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts +++ b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.test.ts @@ -2,7 +2,7 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type ToolDiffModel } from '../tool-diff-model'; -import { EditToolCard } from './edit-tool-card'; +import { EditToolCard, EditToolCardBody } from './edit-tool-card'; import * as React from 'react'; vi.mock('react-native', () => ({ View: 'View' })); @@ -12,10 +12,7 @@ vi.mock('../bubble-text-selection-context', () => ({ useTranscriptTextSelectable: () => true, })); vi.mock('../mono-scroll-block', () => ({ MonoScrollBlock: 'MonoScrollBlock' })); -vi.mock('../tool-card-shell', () => ({ ToolCardShell: 'ToolCardShell' })); -vi.mock('../tool-card-utils', () => ({ - getFilename: (p: string) => p.split('/').pop() ?? p, -})); +vi.mock('../fixed-part-row', () => ({ FixedPartRow: 'FixedPartRow' })); vi.mock('../tool-diff-preview', () => ({ ToolDiffPreview: 'ToolDiffPreview' })); vi.mock('react', async importOriginal => { const actual = await importOriginal(); @@ -31,6 +28,14 @@ const { buildToolDiffModel } = vi.hoisted(() => ({ })); vi.mock('../tool-diff-model', () => ({ buildToolDiffModel })); +const { getToolDisplay, toolPartHasDetails, openSpy } = vi.hoisted(() => ({ + getToolDisplay: vi.fn(), + toolPartHasDetails: vi.fn(), + openSpy: vi.fn(), +})); +vi.mock('../tool-card-display', () => ({ getToolDisplay, toolPartHasDetails })); +vi.mock('../open-part-detail-context', () => ({ useOpenPartDetail: () => openSpy })); + function makeCompletedState(overrides: { filePath: string; oldString: string; @@ -149,7 +154,69 @@ function findByType(root: React.ReactElement, type: string): React.ReactElement[ return findAll(root, el => el.type === type); } -describe('EditToolCard — diff preview routing', () => { +describe('EditToolCard — fixed row', () => { + beforeEach(() => { + buildToolDiffModel.mockReset(); + getToolDisplay.mockReset(); + toolPartHasDetails.mockReset(); + openSpy.mockReset(); + }); + + it('renders a FixedPartRow with the display projection and status', () => { + getToolDisplay.mockReturnValue({ title: 'edit', subtitle: 'app.tsx' }); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = EditToolCard({ part: makeEditPart({}) }) as unknown as React.ReactElement; + const rows = findByType(root, 'FixedPartRow'); + expect(rows).toHaveLength(1); + const row = rows[0]; + if (!row) { + throw new Error('row not found'); + } + const rowProps = row.props as { + icon: string; + label: string; + status: string; + accessibilityLabel: string; + badge?: unknown; + }; + expect(rowProps).toMatchObject({ + icon: 'Pencil', + label: 'app.tsx', + status: 'completed', + accessibilityLabel: 'app.tsx tool, completed', + }); + expect(rowProps.badge).toBeUndefined(); + }); + + it('wires onPress to openPartDetail with the part id when details exist', () => { + getToolDisplay.mockReturnValue({ title: 'edit', subtitle: 'app.tsx' }); + toolPartHasDetails.mockReturnValue(true); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = EditToolCard({ part: makeEditPart({}) }) as unknown as React.ReactElement; + const row = findByType(root, 'FixedPartRow')[0]; + if (!row) { + throw new Error('row not found'); + } + const onPress = (row.props as { onPress?: unknown }).onPress as () => void; + expect(onPress).toBeTypeOf('function'); + onPress(); + expect(openSpy).toHaveBeenCalledWith('edit-1'); + }); + + it('leaves onPress undefined when details do not exist', () => { + getToolDisplay.mockReturnValue({ title: 'edit', subtitle: 'app.tsx' }); + toolPartHasDetails.mockReturnValue(false); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = EditToolCard({ part: makeEditPart({}) }) as unknown as React.ReactElement; + const row = findByType(root, 'FixedPartRow')[0]; + if (!row) { + throw new Error('row not found'); + } + expect((row.props as { onPress?: unknown }).onPress).toBeUndefined(); + }); +}); + +describe('EditToolCardBody — diff preview routing', () => { beforeEach(() => { buildToolDiffModel.mockReset(); }); @@ -158,7 +225,7 @@ describe('EditToolCard — diff preview routing', () => { const model = makeModel(); buildToolDiffModel.mockReturnValue(model); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = EditToolCard({ part: makeEditPart({}) }) as unknown as React.ReactElement; + const root = EditToolCardBody({ part: makeEditPart({}) }) as unknown as React.ReactElement; const previews = findByType(root, 'ToolDiffPreview'); expect(previews).toHaveLength(1); }); @@ -167,7 +234,7 @@ describe('EditToolCard — diff preview routing', () => { const model = makeModel(); buildToolDiffModel.mockReturnValue(model); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = EditToolCard({ part: makeEditPart({}) }) as unknown as React.ReactElement; + const root = EditToolCardBody({ part: makeEditPart({}) }) as unknown as React.ReactElement; const preview = findByType(root, 'ToolDiffPreview')[0]; expect(preview).toBeDefined(); if (!preview) { @@ -180,7 +247,7 @@ describe('EditToolCard — diff preview routing', () => { it('renders MonoScrollBlock fallback when the model does not exist', () => { buildToolDiffModel.mockReturnValue(null); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = EditToolCard({ + const root = EditToolCardBody({ part: makeEditPart({ oldString: 'old', newString: 'new' }), }) as unknown as React.ReactElement; expect(findByType(root, 'ToolDiffPreview')).toHaveLength(0); @@ -190,7 +257,7 @@ describe('EditToolCard — diff preview routing', () => { it('renders no body when the model does not exist and strings are empty', () => { buildToolDiffModel.mockReturnValue(null); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = EditToolCard({ + const root = EditToolCardBody({ part: makeEditPart({ oldString: '', newString: '' }), }) as unknown as React.ReactElement; expect(findByType(root, 'ToolDiffPreview')).toHaveLength(0); @@ -201,7 +268,7 @@ describe('EditToolCard — diff preview routing', () => { const model = makeModel(); buildToolDiffModel.mockReturnValue(model); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = EditToolCard({ + const root = EditToolCardBody({ part: makeEditPart({ status: 'error', error: 'something went wrong' }), }) as unknown as React.ReactElement; const texts = findAll( @@ -216,7 +283,7 @@ describe('EditToolCard — diff preview routing', () => { it('preserves the error block when the model does not exist', () => { buildToolDiffModel.mockReturnValue(null); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = EditToolCard({ + const root = EditToolCardBody({ part: makeEditPart({ oldString: 'old', newString: 'new', diff --git a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx index ee1b60ee4b..b557298c57 100644 --- a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx @@ -6,9 +6,10 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; -import { ToolCardShell } from '../tool-card-shell'; -import { getFilename } from '../tool-card-utils'; +import { useOpenPartDetail } from '../open-part-detail-context'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; import { buildToolDiffModel } from '../tool-diff-model'; import { ToolDiffPreview } from '../tool-diff-preview'; @@ -38,35 +39,59 @@ function EditFallbackBody({ ); } -export function EditToolCard({ part }: Readonly<{ part: ToolPart }>) { +/** + * Sheet body for an edit tool part: the diff preview when the model exists, + * else the old/new fallback blocks for whichever string is non-empty, plus the + * error. Renders only inside the detail sheet — attachments and the + * pending/running status line live in `ToolPartDetailBody`. + */ +export function EditToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; - const filePath = typeof input.filePath === 'string' ? input.filePath : ''; const oldString = typeof input.oldString === 'string' ? input.oldString : ''; const newString = typeof input.newString === 'string' ? input.newString : ''; - const subtitle = filePath ? getFilename(filePath) : 'edit'; const error = part.state.status === 'error' ? part.state.error : undefined; - const hasChanges = oldString.length > 0 || newString.length > 0; - const diffModel = useMemo(() => buildToolDiffModel(part), [part]); let body: React.ReactNode = null; if (diffModel) { body = ; - } else if (hasChanges) { + } else if (oldString.length > 0 || newString.length > 0) { body = ; } return ( - + {body} {error ? ( {error} ) : null} - + + ); +} + +export function EditToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx index 92a394ee87..20f742d1fa 100644 --- a/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/generic-tool-card.tsx @@ -5,9 +5,10 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; -import { ToolCardShell } from '../tool-card-shell'; -import { getGenericToolTitle } from '../tool-card-utils'; +import { useOpenPartDetail } from '../open-part-detail-context'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; function formatInput(input: Record): string { try { @@ -17,48 +18,60 @@ function formatInput(input: Record): string { } } -export function GenericToolCard({ part }: Readonly<{ part: ToolPart }>) { +/** + * Sheet body for a generic tool part (including unknown tools): the input JSON + * block when input is non-empty, the output block, and the error. Renders only + * inside the detail sheet — attachments and the pending/running status line + * live in `ToolPartDetailBody`. + */ +export function GenericToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; - const stateTitle = - part.state.status === 'running' || part.state.status === 'completed' - ? part.state.title - : undefined; - const subtitle = getGenericToolTitle(part.tool, stateTitle, input); const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; const inputStr = Object.keys(input).length > 0 ? formatInput(input) : undefined; - const hasExpandedContent = Boolean(inputStr) || Boolean(output) || Boolean(error); return ( - + {inputStr ? ( + + ) : null} + {output ? ( + + ) : null} + {error ? ( + + {error} + + ) : null} + + ); +} + +export function GenericToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + - {hasExpandedContent ? ( - - {inputStr ? ( - - ) : null} - {output ? ( - - ) : null} - {error ? ( - - {error} - - ) : null} - - ) : null} - + accessibilityLabel={`${display.subtitle ?? display.title} tool, ${part.state.status}`} + onPress={ + hasDetails && openPartDetail + ? () => { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx index 00047e387f..c45dafcf02 100644 --- a/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/glob-tool-card.tsx @@ -1,40 +1,28 @@ +import { View } from 'react-native'; import { Search } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; -import { ToolCardShell } from '../tool-card-shell'; - -function countOutputLines(output: string): number { - if (output.length === 0) { - return 0; - } - return output.split('\n').filter(line => line.trim().length > 0).length; -} - -export function GlobToolCard({ part }: Readonly<{ part: ToolPart }>) { +import { useOpenPartDetail } from '../open-part-detail-context'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; + +/** + * Sheet body for a glob tool part: the output block and the error. The pattern + * lives in the sheet title. Renders only inside the detail sheet — attachments + * and the pending/running status line live in `ToolPartDetailBody`. + */ +export function GlobToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); - const input = part.state.input; - const pattern = typeof input.pattern === 'string' ? input.pattern : ''; - - const subtitle = pattern || 'glob'; const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; - const matchCount = output ? countOutputLines(output) : undefined; - const badge = matchCount !== undefined ? `${matchCount} files` : undefined; - return ( - + {output ? ( ) : null} @@ -43,6 +31,29 @@ export function GlobToolCard({ part }: Readonly<{ part: ToolPart }>) { {error} ) : null} - + + ); +} + +export function GlobToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx index c8aad15f59..a4cae02f92 100644 --- a/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/grep-tool-card.tsx @@ -1,44 +1,28 @@ +import { View } from 'react-native'; import { FileSearch } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; -import { ToolCardShell } from '../tool-card-shell'; - -function countOutputLines(output: string): number { - if (output.length === 0) { - return 0; - } - return output.split('\n').filter(line => line.trim().length > 0).length; -} - -export function GrepToolCard({ part }: Readonly<{ part: ToolPart }>) { +import { useOpenPartDetail } from '../open-part-detail-context'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; + +/** + * Sheet body for a grep tool part: the output block and the error. The pattern + * lives in the sheet title. Renders only inside the detail sheet — attachments + * and the pending/running status line live in `ToolPartDetailBody`. + */ +export function GrepToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); - const input = part.state.input; - const pattern = typeof input.pattern === 'string' ? input.pattern : ''; - const include = typeof input.include === 'string' ? input.include : undefined; - - let subtitle = pattern || 'grep'; - if (include) { - subtitle += ` (${include})`; - } const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; - const matchCount = output ? countOutputLines(output) : undefined; - const badge = matchCount !== undefined ? `${matchCount} matches` : undefined; - return ( - + {output ? ( ) : null} @@ -47,6 +31,29 @@ export function GrepToolCard({ part }: Readonly<{ part: ToolPart }>) { {error} ) : null} - + + ); +} + +export function GrepToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-cards/index.ts b/apps/mobile/src/components/agents/tool-cards/index.ts index 81fd5f3772..8164206db9 100644 --- a/apps/mobile/src/components/agents/tool-cards/index.ts +++ b/apps/mobile/src/components/agents/tool-cards/index.ts @@ -1,11 +1,11 @@ -export { BashToolCard } from './bash-tool-card'; -export { EditToolCard } from './edit-tool-card'; -export { GenericToolCard } from './generic-tool-card'; -export { GlobToolCard } from './glob-tool-card'; -export { GrepToolCard } from './grep-tool-card'; -export { ListToolCard } from './list-tool-card'; -export { ReadToolCard } from './read-tool-card'; -export { TaskToolCard } from './task-tool-card'; -export { TodoToolCard } from './todo-tool-card'; -export { WebSearchToolCard } from './web-search-tool-card'; -export { WriteToolCard } from './write-tool-card'; +export { BashToolCard, BashToolCardBody } from './bash-tool-card'; +export { EditToolCard, EditToolCardBody } from './edit-tool-card'; +export { GenericToolCard, GenericToolCardBody } from './generic-tool-card'; +export { GlobToolCard, GlobToolCardBody } from './glob-tool-card'; +export { GrepToolCard, GrepToolCardBody } from './grep-tool-card'; +export { ListToolCard, ListToolCardBody } from './list-tool-card'; +export { ReadToolCard, ReadToolCardBody } from './read-tool-card'; +export { TaskToolCard, TaskToolCardBody } from './task-tool-card'; +export { TodoToolCard, TodoToolCardBody } from './todo-tool-card'; +export { WebSearchToolCard, WebSearchToolCardBody } from './web-search-tool-card'; +export { WriteToolCard, WriteToolCardBody } from './write-tool-card'; diff --git a/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx index 381ef247ae..714e52e399 100644 --- a/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/list-tool-card.tsx @@ -1,27 +1,28 @@ +import { View } from 'react-native'; import { FolderOpen } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; -import { ToolCardShell } from '../tool-card-shell'; -import { getDirectoryName } from '../tool-card-utils'; +import { useOpenPartDetail } from '../open-part-detail-context'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; -export function ListToolCard({ part }: Readonly<{ part: ToolPart }>) { +/** + * Sheet body for a list tool part: the output block and the error. The path + * lives in the sheet title. Renders only inside the detail sheet — attachments + * and the pending/running status line live in `ToolPartDetailBody`. + */ +export function ListToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); - const input = part.state.input; - const filePath = typeof input.filePath === 'string' ? input.filePath : undefined; - const path = typeof input.path === 'string' ? input.path : undefined; - const resolvedPath = filePath ?? path ?? ''; - - const subtitle = resolvedPath ? getDirectoryName(resolvedPath) : 'list'; const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; return ( - + {output ? ( ) : null} @@ -30,6 +31,28 @@ export function ListToolCard({ part }: Readonly<{ part: ToolPart }>) { {error} ) : null} - + + ); +} + +export function ListToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx index b1b535da9b..f134fb67ae 100644 --- a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx @@ -1,38 +1,28 @@ +import { View } from 'react-native'; import { Eye } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; +import { useOpenPartDetail } from '../open-part-detail-context'; import { ReadMarkdownPreview } from '../read-markdown-preview'; import { isMarkdownPath, resolveMarkdownPreview } from '../read-tool-markdown'; import { getToolImageAttachments } from '../tool-card-attachments'; -import { ToolCardShell } from '../tool-card-shell'; -import { getFilename } from '../tool-card-utils'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; -export function ReadToolCard({ part }: Readonly<{ part: ToolPart }>) { +/** + * Sheet body for a read tool part: the markdown preview for markdown paths, + * else the output block (skipped for image reads), plus the error. Image + * attachments render above via `ToolPartDetailBody`. Renders only inside the + * detail sheet — the pending/running status line lives in `ToolPartDetailBody`. + */ +export function ReadToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; const filePath = typeof input.filePath === 'string' ? input.filePath : ''; - const offset = typeof input.offset === 'number' ? input.offset : undefined; - const limit = typeof input.limit === 'number' ? input.limit : undefined; - - const subtitle = filePath ? getFilename(filePath) : 'read'; - - const badge = (() => { - if (offset === undefined && limit === undefined) { - return undefined; - } - const parts: string[] = []; - if (offset !== undefined) { - parts.push(`L${offset}`); - } - if (limit !== undefined) { - parts.push(`${limit} lines`); - } - return parts.join(', '); - })(); const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; @@ -40,14 +30,7 @@ export function ReadToolCard({ part }: Readonly<{ part: ToolPart }>) { const hasImages = getToolImageAttachments(part).length > 0; return ( - + {markdownPreview ? : null} {/* An image read's output is only "Image read successfully" — the image itself is the content, so the mono block would be noise (plan D10). */} @@ -59,6 +42,29 @@ export function ReadToolCard({ part }: Readonly<{ part: ToolPart }>) { {error} ) : null} - + + ); +} + +export function ReadToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx index e3f82d842d..7238fa1866 100644 --- a/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/task-tool-card.tsx @@ -1,26 +1,28 @@ +import { View } from 'react-native'; import { Cpu } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; -import { ToolCardShell } from '../tool-card-shell'; -import { truncateText } from '../tool-card-utils'; +import { useOpenPartDetail } from '../open-part-detail-context'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; -export function TaskToolCard({ part }: Readonly<{ part: ToolPart }>) { +/** + * Sheet body for a task tool part: the output block and the error. Renders only + * inside the detail sheet — attachments and the pending/running status line + * live in `ToolPartDetailBody`. + */ +export function TaskToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); - const input = part.state.input; - const description = typeof input.description === 'string' ? input.description : undefined; - const prompt = typeof input.prompt === 'string' ? input.prompt : undefined; - - const subtitle = description ?? (prompt ? truncateText(prompt, 60) : 'task'); const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; return ( - + {output ? ( ) : null} @@ -29,6 +31,28 @@ export function TaskToolCard({ part }: Readonly<{ part: ToolPart }>) { {error} ) : null} - + + ); +} + +export function TaskToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx index 3c0796398d..8310a6243c 100644 --- a/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/todo-tool-card.tsx @@ -1,22 +1,28 @@ +import { View } from 'react-native'; import { ListTodo } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; -import { ToolCardShell } from '../tool-card-shell'; +import { useOpenPartDetail } from '../open-part-detail-context'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; -export function TodoToolCard({ part }: Readonly<{ part: ToolPart }>) { +/** + * Sheet body for a todoread/todowrite tool part: the output block and the + * error. Renders only inside the detail sheet — attachments and the + * pending/running status line live in `ToolPartDetailBody`. + */ +export function TodoToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); - const isWrite = part.tool === 'todowrite'; - const subtitle = isWrite ? 'Update todos' : 'Read todos'; const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; return ( - + {output ? ( ) : null} @@ -25,6 +31,28 @@ export function TodoToolCard({ part }: Readonly<{ part: ToolPart }>) { {error} ) : null} - + + ); +} + +export function TodoToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx index 221ce9dc54..ca119c9533 100644 --- a/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/web-search-tool-card.tsx @@ -1,31 +1,29 @@ +import { View } from 'react-native'; import { Globe } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; -import { ToolCardShell } from '../tool-card-shell'; -import { truncateText } from '../tool-card-utils'; +import { useOpenPartDetail } from '../open-part-detail-context'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; -export function WebSearchToolCard({ part }: Readonly<{ part: ToolPart }>) { +/** + * Sheet body for a websearch/codesearch/webfetch tool part: the output block + * and the error. The query/url lives in the sheet title. Renders only inside + * the detail sheet — attachments and the pending/running status line live in + * `ToolPartDetailBody`. + */ +export function WebSearchToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); - const input = part.state.input; - const query = typeof input.query === 'string' ? input.query : undefined; - const url = typeof input.url === 'string' ? input.url : undefined; - - let subtitle = part.tool; - if (query) { - subtitle = truncateText(query, 60); - } else if (url) { - subtitle = truncateText(url, 60); - } const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; return ( - + {output ? ( ) : null} @@ -34,6 +32,28 @@ export function WebSearchToolCard({ part }: Readonly<{ part: ToolPart }>) { {error} ) : null} - + + ); +} + +export function WebSearchToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts b/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts index a9a9b6ebd4..56d87d2d93 100644 --- a/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts +++ b/apps/mobile/src/components/agents/tool-cards/write-tool-card.test.ts @@ -2,7 +2,7 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type ToolDiffModel } from '../tool-diff-model'; -import { WriteToolCard } from './write-tool-card'; +import { WriteToolCard, WriteToolCardBody } from './write-tool-card'; import * as React from 'react'; vi.mock('react-native', () => ({ View: 'View' })); @@ -12,10 +12,7 @@ vi.mock('../bubble-text-selection-context', () => ({ useTranscriptTextSelectable: () => true, })); vi.mock('../mono-scroll-block', () => ({ MonoScrollBlock: 'MonoScrollBlock' })); -vi.mock('../tool-card-shell', () => ({ ToolCardShell: 'ToolCardShell' })); -vi.mock('../tool-card-utils', () => ({ - getFilename: (p: string) => p.split('/').pop() ?? p, -})); +vi.mock('../fixed-part-row', () => ({ FixedPartRow: 'FixedPartRow' })); vi.mock('../tool-diff-preview', () => ({ ToolDiffPreview: 'ToolDiffPreview' })); vi.mock('react', async importOriginal => { const actual = await importOriginal(); @@ -31,6 +28,14 @@ const { buildToolDiffModel } = vi.hoisted(() => ({ })); vi.mock('../tool-diff-model', () => ({ buildToolDiffModel })); +const { getToolDisplay, toolPartHasDetails, openSpy } = vi.hoisted(() => ({ + getToolDisplay: vi.fn(), + toolPartHasDetails: vi.fn(), + openSpy: vi.fn(), +})); +vi.mock('../tool-card-display', () => ({ getToolDisplay, toolPartHasDetails })); +vi.mock('../open-part-detail-context', () => ({ useOpenPartDetail: () => openSpy })); + function makeCompletedState(overrides: { filePath: string; content: string; @@ -123,6 +128,9 @@ function findAll( matches.push(value); } const props = value.props as { children?: unknown }; + if (typeof value.type === 'function') { + walk((value.type as React.FunctionComponent)(props)); + } walk(props.children); } } @@ -134,7 +142,69 @@ function findByType(root: React.ReactElement, type: string): React.ReactElement[ return findAll(root, el => el.type === type); } -describe('WriteToolCard — diff preview routing', () => { +describe('WriteToolCard — fixed row', () => { + beforeEach(() => { + buildToolDiffModel.mockReset(); + getToolDisplay.mockReset(); + toolPartHasDetails.mockReset(); + openSpy.mockReset(); + }); + + it('renders a FixedPartRow with the display projection and status', () => { + getToolDisplay.mockReturnValue({ title: 'write', subtitle: 'new.ts' }); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = WriteToolCard({ part: makeWritePart({}) }) as unknown as React.ReactElement; + const rows = findByType(root, 'FixedPartRow'); + expect(rows).toHaveLength(1); + const row = rows[0]; + if (!row) { + throw new Error('row not found'); + } + const rowProps = row.props as { + icon: string; + label: string; + status: string; + accessibilityLabel: string; + badge?: unknown; + }; + expect(rowProps).toMatchObject({ + icon: 'FilePlus', + label: 'new.ts', + status: 'completed', + accessibilityLabel: 'new.ts tool, completed', + }); + expect(rowProps.badge).toBeUndefined(); + }); + + it('wires onPress to openPartDetail with the part id when details exist', () => { + getToolDisplay.mockReturnValue({ title: 'write', subtitle: 'new.ts' }); + toolPartHasDetails.mockReturnValue(true); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = WriteToolCard({ part: makeWritePart({}) }) as unknown as React.ReactElement; + const row = findByType(root, 'FixedPartRow')[0]; + if (!row) { + throw new Error('row not found'); + } + const onPress = (row.props as { onPress?: unknown }).onPress as () => void; + expect(onPress).toBeTypeOf('function'); + onPress(); + expect(openSpy).toHaveBeenCalledWith('write-1'); + }); + + it('leaves onPress undefined when details do not exist', () => { + getToolDisplay.mockReturnValue({ title: 'write', subtitle: 'new.ts' }); + toolPartHasDetails.mockReturnValue(false); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = WriteToolCard({ part: makeWritePart({}) }) as unknown as React.ReactElement; + const row = findByType(root, 'FixedPartRow')[0]; + if (!row) { + throw new Error('row not found'); + } + expect((row.props as { onPress?: unknown }).onPress).toBeUndefined(); + }); +}); + +describe('WriteToolCardBody — diff preview routing', () => { beforeEach(() => { buildToolDiffModel.mockReset(); }); @@ -143,7 +213,7 @@ describe('WriteToolCard — diff preview routing', () => { const model = makeModel(); buildToolDiffModel.mockReturnValue(model); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = WriteToolCard({ part: makeWritePart({}) }) as unknown as React.ReactElement; + const root = WriteToolCardBody({ part: makeWritePart({}) }) as unknown as React.ReactElement; const previews = findByType(root, 'ToolDiffPreview'); expect(previews).toHaveLength(1); }); @@ -152,7 +222,7 @@ describe('WriteToolCard — diff preview routing', () => { const model = makeModel(); buildToolDiffModel.mockReturnValue(model); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = WriteToolCard({ part: makeWritePart({}) }) as unknown as React.ReactElement; + const root = WriteToolCardBody({ part: makeWritePart({}) }) as unknown as React.ReactElement; const preview = findByType(root, 'ToolDiffPreview')[0]; expect(preview).toBeDefined(); if (!preview) { @@ -165,7 +235,7 @@ describe('WriteToolCard — diff preview routing', () => { it('renders MonoScrollBlock fallback when the model does not exist', () => { buildToolDiffModel.mockReturnValue(null); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = WriteToolCard({ + const root = WriteToolCardBody({ part: makeWritePart({ content: 'hello' }), }) as unknown as React.ReactElement; expect(findByType(root, 'ToolDiffPreview')).toHaveLength(0); @@ -175,7 +245,7 @@ describe('WriteToolCard — diff preview routing', () => { it('renders no body when the model does not exist and content is empty', () => { buildToolDiffModel.mockReturnValue(null); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = WriteToolCard({ + const root = WriteToolCardBody({ part: makeWritePart({ content: '' }), }) as unknown as React.ReactElement; expect(findByType(root, 'ToolDiffPreview')).toHaveLength(0); @@ -186,7 +256,7 @@ describe('WriteToolCard — diff preview routing', () => { const model = makeModel(); buildToolDiffModel.mockReturnValue(model); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = WriteToolCard({ + const root = WriteToolCardBody({ part: makeWritePart({ status: 'error', error: 'write failed' }), }) as unknown as React.ReactElement; const texts = findAll( @@ -199,7 +269,7 @@ describe('WriteToolCard — diff preview routing', () => { it('preserves the error block when the model does not exist', () => { buildToolDiffModel.mockReturnValue(null); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = WriteToolCard({ + const root = WriteToolCardBody({ part: makeWritePart({ content: 'hello', status: 'error', error: 'write error' }), }) as unknown as React.ReactElement; const texts = findAll( diff --git a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx index 0b22d6ad15..1942e03ede 100644 --- a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx @@ -1,23 +1,29 @@ import { useMemo } from 'react'; +import { View } from 'react-native'; import { FilePlus } from 'lucide-react-native'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { Text } from '@/components/ui/text'; import { useTranscriptTextSelectable } from '../bubble-text-selection-context'; +import { FixedPartRow } from '../fixed-part-row'; import { MonoScrollBlock } from '../mono-scroll-block'; -import { ToolCardShell } from '../tool-card-shell'; -import { getFilename } from '../tool-card-utils'; +import { useOpenPartDetail } from '../open-part-detail-context'; +import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; import { buildToolDiffModel } from '../tool-diff-model'; import { ToolDiffPreview } from '../tool-diff-preview'; -export function WriteToolCard({ part }: Readonly<{ part: ToolPart }>) { +/** + * Sheet body for a write tool part: the diff preview when the model exists, + * else the content block, plus the error. Renders only inside the detail sheet + * — attachments and the pending/running status line live in + * `ToolPartDetailBody`. + */ +export function WriteToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const textSelectable = useTranscriptTextSelectable(); const input = part.state.input; - const filePath = typeof input.filePath === 'string' ? input.filePath : ''; const content = typeof input.content === 'string' ? input.content : ''; - const subtitle = filePath ? getFilename(filePath) : 'write'; const error = part.state.status === 'error' ? part.state.error : undefined; const diffModel = useMemo(() => buildToolDiffModel(part), [part]); @@ -30,13 +36,35 @@ export function WriteToolCard({ part }: Readonly<{ part: ToolPart }>) { } return ( - + {body} {error ? ( {error} ) : null} - + + ); +} + +export function WriteToolCard({ part }: Readonly<{ part: ToolPart }>) { + const openPartDetail = useOpenPartDetail(); + const display = getToolDisplay(part); + const hasDetails = toolPartHasDetails(part); + + return ( + { + openPartDetail(part.id); + } + : undefined + } + /> ); } diff --git a/apps/mobile/src/components/agents/tool-part-detail-body.test.ts b/apps/mobile/src/components/agents/tool-part-detail-body.test.ts new file mode 100644 index 0000000000..00edb61968 --- /dev/null +++ b/apps/mobile/src/components/agents/tool-part-detail-body.test.ts @@ -0,0 +1,298 @@ +import { type FilePart, type ToolPart } from '@kilocode/cloud-agent-sdk'; +import * as React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ToolPartDetailBody } from './tool-part-detail-body'; +import { + BashToolCardBody, + EditToolCardBody, + GenericToolCardBody, + GlobToolCardBody, + GrepToolCardBody, + ListToolCardBody, + ReadToolCardBody, + TaskToolCardBody, + TodoToolCardBody, + WebSearchToolCardBody, + WriteToolCardBody, +} from './tool-cards'; +import { BashToolCardBody as RealBashToolCardBody } from './tool-cards/bash-tool-card'; + +vi.mock('react-native', () => ({ View: 'View' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('lucide-react-native', () => ({ Terminal: 'Terminal' })); +vi.mock('./bubble-text-selection-context', () => ({ + useTranscriptTextSelectable: () => true, +})); +vi.mock('./mono-scroll-block', () => ({ MonoScrollBlock: 'MonoScrollBlock' })); +vi.mock('./fixed-part-row', () => ({ FixedPartRow: 'FixedPartRow' })); +vi.mock('./tool-card-display', () => ({ + getToolDisplay: vi.fn(), + toolPartHasDetails: vi.fn(), +})); +vi.mock('./tool-card-image-attachments', () => ({ + ToolCardImageAttachments: 'ToolCardImageAttachments', +})); +vi.mock('./tool-card-file-attachments', () => ({ + ToolCardFileAttachments: 'ToolCardFileAttachments', +})); + +const { getToolImageAttachments, getToolFileAttachments } = vi.hoisted(() => ({ + getToolImageAttachments: vi.fn<() => FilePart[]>(() => []), + getToolFileAttachments: vi.fn<() => FilePart[]>(() => []), +})); +vi.mock('./tool-card-attachments', () => ({ getToolImageAttachments, getToolFileAttachments })); + +vi.mock('./tool-cards', () => ({ + BashToolCardBody: 'BashToolCardBody', + EditToolCardBody: 'EditToolCardBody', + GenericToolCardBody: 'GenericToolCardBody', + GlobToolCardBody: 'GlobToolCardBody', + GrepToolCardBody: 'GrepToolCardBody', + ListToolCardBody: 'ListToolCardBody', + ReadToolCardBody: 'ReadToolCardBody', + TaskToolCardBody: 'TaskToolCardBody', + TodoToolCardBody: 'TodoToolCardBody', + WebSearchToolCardBody: 'WebSearchToolCardBody', + WriteToolCardBody: 'WriteToolCardBody', +})); + +const completedState: Extract = { + status: 'completed', + input: { command: 'echo hi' }, + output: 'hi', + title: 'bash', + metadata: {}, + time: { start: 1, end: 2 }, +}; + +const runningState: Extract = { + status: 'running', + input: { command: 'echo hi' }, + time: { start: 1 }, +}; + +const pendingState: Extract = { + status: 'pending', + input: { command: 'echo hi' }, + raw: '', +}; + +const errorState: Extract = { + status: 'error', + input: {}, + error: 'boom', + time: { start: 1, end: 2 }, +}; + +function makeToolPart(tool: string, state: ToolPart['state']): ToolPart { + return { + id: `${tool}-1`, + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: 'call-1', + tool, + state, + }; +} + +function makeFilePart(id: string, mime: string): FilePart { + return { + id, + sessionID: 's1', + messageID: 'm1', + type: 'file', + mime, + url: '', + }; +} + +function findAll( + node: unknown, + predicate: (el: React.ReactElement) => boolean +): React.ReactElement[] { + const matches: React.ReactElement[] = []; + function walk(value: unknown): void { + if (value == null || typeof value === 'string' || typeof value === 'number') { + return; + } + if (Array.isArray(value)) { + for (const child of value) { + walk(child); + } + return; + } + if (React.isValidElement(value)) { + if (predicate(value)) { + matches.push(value); + } + walk((value.props as Record).children); + } + } + walk(node); + return matches; +} + +function findByType(node: unknown, type: string | ToolBody): React.ReactElement[] { + return findAll(node, el => el.type === type); +} + +function orderedTypes(node: unknown): (string | React.ComponentType)[] { + const types: (string | React.ComponentType)[] = []; + function walk(value: unknown): void { + if (value == null || typeof value === 'string' || typeof value === 'number') { + return; + } + if (Array.isArray(value)) { + for (const child of value) { + walk(child); + } + return; + } + if (React.isValidElement(value)) { + types.push(value.type as string | React.ComponentType); + walk((value.props as Record).children); + } + } + walk(node); + return types; +} + +const textChildren = (el: React.ReactElement): unknown => + (el.props as { children?: unknown }).children; + +/** Join JSX children so `$ {command}` renders as one comparable string. */ +function renderedText(el: React.ReactElement): string { + const children = textChildren(el); + if (Array.isArray(children)) { + return children.filter(child => typeof child === 'string').join(''); + } + return typeof children === 'string' ? children : ''; +} + +type ToolBody = React.ComponentType<{ part: ToolPart }>; + +const routingTable: [string, ToolBody][] = [ + ['read', ReadToolCardBody], + ['edit', EditToolCardBody], + ['write', WriteToolCardBody], + ['bash', BashToolCardBody], + ['glob', GlobToolCardBody], + ['grep', GrepToolCardBody], + ['websearch', WebSearchToolCardBody], + ['codesearch', WebSearchToolCardBody], + ['webfetch', WebSearchToolCardBody], + ['list', ListToolCardBody], + ['todoread', TodoToolCardBody], + ['todowrite', TodoToolCardBody], + ['task', TaskToolCardBody], +]; + +describe('ToolPartDetailBody routing', () => { + beforeEach(() => { + getToolImageAttachments.mockReturnValue([]); + getToolFileAttachments.mockReturnValue([]); + }); + + it.each(routingTable)('routes tool %s to its body component', (tool, body) => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartDetailBody({ part: makeToolPart(tool, completedState) }); + expect(findByType(root, body)).toHaveLength(1); + }); + + it('routes unknown tools to the generic body', () => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartDetailBody({ part: makeToolPart('some-new-tool', completedState) }); + expect(findByType(root, GenericToolCardBody)).toHaveLength(1); + }); + + it('renders no body for suggest parts', () => { + const allBodies = [ + BashToolCardBody, + EditToolCardBody, + GenericToolCardBody, + GlobToolCardBody, + GrepToolCardBody, + ListToolCardBody, + ReadToolCardBody, + TaskToolCardBody, + TodoToolCardBody, + WebSearchToolCardBody, + WriteToolCardBody, + ]; + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartDetailBody({ part: makeToolPart('suggest', completedState) }); + expect(allBodies.flatMap(body => findByType(root, body))).toHaveLength(0); + }); + + it('renders attachments above the body when present', () => { + getToolImageAttachments.mockReturnValue([makeFilePart('img-1', 'image/png')]); + getToolFileAttachments.mockReturnValue([makeFilePart('file-1', 'application/pdf')]); + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartDetailBody({ part: makeToolPart('bash', completedState) }); + expect(orderedTypes(root)).toEqual([ + 'View', + 'ToolCardImageAttachments', + 'ToolCardFileAttachments', + 'BashToolCardBody', + ]); + }); +}); + +describe('ToolPartDetailBody status line', () => { + beforeEach(() => { + getToolImageAttachments.mockReturnValue([]); + getToolFileAttachments.mockReturnValue([]); + }); + + it('renders the Running… status line for a running part', () => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartDetailBody({ part: makeToolPart('bash', runningState) }); + expect(findAll(root, el => el.type === 'Text' && textChildren(el) === 'Running…')).toHaveLength( + 1 + ); + expect(findAll(root, el => el.type === 'Text' && textChildren(el) === 'Pending…')).toHaveLength( + 0 + ); + }); + + it('renders the Pending… status line for a pending part', () => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartDetailBody({ part: makeToolPart('bash', pendingState) }); + expect(findAll(root, el => el.type === 'Text' && textChildren(el) === 'Pending…')).toHaveLength( + 1 + ); + }); + + it('renders no status line for a completed part', () => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartDetailBody({ part: makeToolPart('bash', completedState) }); + const statusLines = findAll( + root, + el => + el.type === 'Text' && (textChildren(el) === 'Pending…' || textChildren(el) === 'Running…') + ); + expect(statusLines).toHaveLength(0); + }); + + it('renders no status line for an error part', () => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartDetailBody({ part: makeToolPart('bash', errorState) }); + const statusLines = findAll( + root, + el => + el.type === 'Text' && (textChildren(el) === 'Pending…' || textChildren(el) === 'Running…') + ); + expect(statusLines).toHaveLength(0); + }); +}); + +describe('BashToolCardBody streaming contract', () => { + it('renders the $ command block while running with a short command', () => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = RealBashToolCardBody({ part: makeToolPart('bash', runningState) }); + const commands = findAll(root, el => el.type === 'Text' && renderedText(el) === '$ echo hi'); + expect(commands).toHaveLength(1); + }); +}); diff --git a/apps/mobile/src/components/agents/tool-part-detail-body.tsx b/apps/mobile/src/components/agents/tool-part-detail-body.tsx new file mode 100644 index 0000000000..bd541fdcf3 --- /dev/null +++ b/apps/mobile/src/components/agents/tool-part-detail-body.tsx @@ -0,0 +1,88 @@ +import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { View } from 'react-native'; + +import { Text } from '@/components/ui/text'; + +import { getToolFileAttachments, getToolImageAttachments } from './tool-card-attachments'; +import { ToolCardFileAttachments } from './tool-card-file-attachments'; +import { ToolCardImageAttachments } from './tool-card-image-attachments'; +import { + BashToolCardBody, + EditToolCardBody, + GenericToolCardBody, + GlobToolCardBody, + GrepToolCardBody, + ListToolCardBody, + ReadToolCardBody, + TaskToolCardBody, + TodoToolCardBody, + WebSearchToolCardBody, + WriteToolCardBody, +} from './tool-cards'; + +function renderToolBody(part: ToolPart): React.ReactNode { + switch (part.tool) { + case 'read': { + return ; + } + case 'edit': { + return ; + } + case 'write': { + return ; + } + case 'bash': { + return ; + } + case 'glob': { + return ; + } + case 'grep': { + return ; + } + case 'websearch': + case 'codesearch': + case 'webfetch': { + return ; + } + case 'list': { + return ; + } + case 'todoread': + case 'todowrite': { + return ; + } + case 'task': { + return ; + } + case 'suggest': { + return null; + } + default: { + return ; + } + } +} + +/** + * Sheet body dispatcher for a tool part. Renders a uniform pending/running + * status line, the attachments above the per-tool body, then the type-specific + * body. Suggest parts have no body; unknown tools use the generic body. + */ +export function ToolPartDetailBody({ part }: Readonly<{ part: ToolPart }>) { + const status = part.state.status; + + return ( + + {status === 'pending' ? ( + Pending… + ) : null} + {status === 'running' ? ( + Running… + ) : null} + {getToolImageAttachments(part).length > 0 ? : null} + {getToolFileAttachments(part).length > 0 ? : null} + {renderToolBody(part)} + + ); +} diff --git a/apps/mobile/src/components/agents/tool-part-renderer.test.ts b/apps/mobile/src/components/agents/tool-part-renderer.test.ts new file mode 100644 index 0000000000..39f06b1c85 --- /dev/null +++ b/apps/mobile/src/components/agents/tool-part-renderer.test.ts @@ -0,0 +1,232 @@ +import { type StoredMessage, type ToolPart } from '@kilocode/cloud-agent-sdk'; +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + BashToolCard, + EditToolCard, + GenericToolCard, + GlobToolCard, + GrepToolCard, + ListToolCard, + ReadToolCard, + TaskToolCard, + TodoToolCard, + WebSearchToolCard, + WriteToolCard, +} from './tool-cards'; +import { SuggestToolCard } from './suggest-tool-card'; +import { ChildSessionSection } from './child-session-section'; +import { ToolPartRenderer } from './tool-part-renderer'; + +// The seam test must not pull in React Native, so the child-session section is +// mocked entirely. getTaskToolSessionId mirrors child-session-card-state so the +// task-with-handlers route resolves a session id; the real extraction logic has +// its own coverage in child-session-card-state.test.ts. +vi.mock('./child-session-section', () => ({ + ChildSessionSection: 'ChildSessionSection', + getTaskToolSessionId: (part: ToolPart) => { + if (part.tool !== 'task') { + return undefined; + } + const { state } = part; + if (state.status === 'running' || state.status === 'completed' || state.status === 'error') { + return state.metadata?.sessionId as string | undefined; + } + return undefined; + }, +})); +vi.mock('./suggest-tool-card', () => ({ + SuggestToolCard: 'SuggestToolCard', +})); +vi.mock('./tool-cards', () => ({ + BashToolCard: 'BashToolCard', + EditToolCard: 'EditToolCard', + GenericToolCard: 'GenericToolCard', + GlobToolCard: 'GlobToolCard', + GrepToolCard: 'GrepToolCard', + ListToolCard: 'ListToolCard', + ReadToolCard: 'ReadToolCard', + TaskToolCard: 'TaskToolCard', + TodoToolCard: 'TodoToolCard', + WebSearchToolCard: 'WebSearchToolCard', + WriteToolCard: 'WriteToolCard', +})); + +const completedState: Extract = { + status: 'completed', + input: { command: 'echo hi' }, + output: 'hi', + title: 'bash', + metadata: {}, + time: { start: 1, end: 2 }, +}; + +const taskCompletedState: Extract = { + status: 'completed', + input: { description: 'child task', subagent_type: 'General' }, + output: '', + title: 'task', + metadata: { sessionId: 'child-1' }, + time: { start: 1, end: 2 }, +}; + +function makeToolPart(tool: string, state: ToolPart['state']): ToolPart { + return { + id: `${tool}-1`, + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: 'call-1', + tool, + state, + }; +} + +function makeStoredMessage(): StoredMessage { + return { + info: { + id: 'm1', + sessionID: 's1', + role: 'assistant', + time: { created: 1 }, + parentID: 'm0', + modelID: 'model', + providerID: 'kilo', + mode: 'code', + agent: 'build', + path: { cwd: '/', root: '/' }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [], + }; +} + +function findAll( + node: unknown, + predicate: (el: React.ReactElement) => boolean +): React.ReactElement[] { + const matches: React.ReactElement[] = []; + function walk(value: unknown): void { + if (value == null || typeof value === 'string' || typeof value === 'number') { + return; + } + if (Array.isArray(value)) { + for (const child of value) { + walk(child); + } + return; + } + if (React.isValidElement(value)) { + if (predicate(value)) { + matches.push(value); + } + walk((value.props as Record).children); + } + } + walk(node); + return matches; +} + +function findByType(node: unknown, type: React.ElementType): React.ReactElement[] { + return findAll(node, el => el.type === type); +} + +const routingTable: [string, React.ElementType][] = [ + ['read', ReadToolCard], + ['edit', EditToolCard], + ['write', WriteToolCard], + ['bash', BashToolCard], + ['glob', GlobToolCard], + ['grep', GrepToolCard], + ['websearch', WebSearchToolCard], + ['codesearch', WebSearchToolCard], + ['webfetch', WebSearchToolCard], + ['list', ListToolCard], + ['todoread', TodoToolCard], + ['todowrite', TodoToolCard], + ['task', TaskToolCard], + ['suggest', SuggestToolCard], +]; + +describe('ToolPartRenderer routing', () => { + it.each(routingTable)('routes tool %s to its card', (tool, card) => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartRenderer({ part: makeToolPart(tool, completedState) }); + expect(findByType(root, card)).toHaveLength(1); + }); + + it('routes unknown tools to the generic card', () => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartRenderer({ part: makeToolPart('some-new-tool', completedState) }); + expect(findByType(root, GenericToolCard)).toHaveLength(1); + }); + + it.each(['plan_exit', 'plan_enter'])('returns null for %s parts', tool => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const result = ToolPartRenderer({ part: makeToolPart(tool, completedState) }); + expect(result).toBeNull(); + }); +}); + +describe('ToolPartRenderer child navigation seam', () => { + it('routes a task part with all child handlers to ChildSessionSection with resolved messages', () => { + const childMessage = makeStoredMessage(); + const getChildMessages = vi.fn((id: string) => (id === 'child-1' ? [childMessage] : [])); + const renderPart = vi.fn(); + const onOpenChildSession = vi.fn<(sessionId: string, title: string) => void>(); + const part = makeToolPart('task', taskCompletedState); + + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartRenderer({ part, getChildMessages, renderPart, onOpenChildSession }); + + expect(getChildMessages).toHaveBeenCalledWith('child-1'); + const sections = findByType(root, ChildSessionSection); + expect(sections).toHaveLength(1); + const section = sections[0]; + if (!section) { + throw new Error('expected ChildSessionSection'); + } + expect(section.props).toMatchObject({ + part, + childMessages: [childMessage], + onOpenChildSession, + }); + expect(renderPart).not.toHaveBeenCalled(); + }); + + it('routes a task part without a session id to ChildSessionSection with empty messages', () => { + const getChildMessages = vi.fn<() => StoredMessage[]>(() => []); + const renderPart = vi.fn(); + const onOpenChildSession = vi.fn<(sessionId: string, title: string) => void>(); + + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartRenderer({ + part: makeToolPart('task', completedState), + getChildMessages, + renderPart, + onOpenChildSession, + }); + + expect(getChildMessages).not.toHaveBeenCalled(); + const sections = findByType(root, ChildSessionSection); + expect(sections).toHaveLength(1); + const section = sections[0]; + if (!section) { + throw new Error('expected ChildSessionSection'); + } + expect(section.props).toMatchObject({ + part: expect.objectContaining({ tool: 'task' }), + childMessages: [], + onOpenChildSession, + }); + expect(renderPart).not.toHaveBeenCalled(); + }); + + it('routes a task part without handlers to TaskToolCard', () => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartRenderer({ part: makeToolPart('task', taskCompletedState) }); + expect(findByType(root, TaskToolCard)).toHaveLength(1); + }); +}); diff --git a/apps/mobile/src/components/security-agent/collapsible-section.tsx b/apps/mobile/src/components/security-agent/collapsible-section.tsx index b8921581e8..a30edbfde6 100644 --- a/apps/mobile/src/components/security-agent/collapsible-section.tsx +++ b/apps/mobile/src/components/security-agent/collapsible-section.tsx @@ -21,8 +21,9 @@ type CollapsibleSectionProps = { }; // Shared collapsible section for finding-details/-analysis/-remediation -// panels (source record, technical report, attempt history) — mirrors -// tool-card-shell.tsx's chevron-rotation pattern but adds the +// panels (source record, technical report, attempt history) — the +// transcript tool cards dropped this chevron-rotation pattern when they +// moved to fixed rows; this section keeps it and adds the // accessibilityState the security-agent brief calls for. export function CollapsibleSection({ title,