From 59facfb71e96c944c96aff0c3682835fff3dbfda Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:31:30 -0700 Subject: [PATCH] Show live tool progress in native chat (#17597) * Show live tool progress in native chat * fix(native-chat): scope live tool indicator to current turn * fix(native-chat): settle orphaned live tool rows * fix(native-chat): keep live tools running without lifecycle metadata * fix(native-chat): keep working status stable during streaming * fix(native-chat): anchor turn status below prompts * fix(native-chat): preserve turn status and legacy tool activity * fix(native-chat): limit turn status UI to structured Codex --------- Co-authored-by: Merge Sim --- .../NativeChatMessageList.test.tsx | 334 +++++++++++++++++- .../native-chat/NativeChatMessageList.tsx | 297 ++++++++-------- .../native-chat/NativeChatResolvedView.tsx | 2 + .../NativeChatStructuredSession.tsx | 2 + .../native-chat/NativeChatToolRun.test.tsx | 122 +++++++ .../native-chat/NativeChatToolRun.tsx | 191 ++++++++-- .../NativeChatTranscriptChrome.tsx | 98 +++++ .../native-chat/NativeChatWorkingStatus.tsx | 75 ++++ .../native-chat/native-chat-prose.ts | 8 + .../native-chat-typing-indicator.test.ts | 24 +- .../native-chat-typing-indicator.ts | 20 -- .../use-native-chat-live-session.ts | 5 +- .../use-native-chat-turn-status.ts | 120 +++++++ src/renderer/src/i18n/locales/en.json | 19 +- src/shared/native-chat-types.ts | 2 + ...tructured-agent-session-projection.test.ts | 15 + .../structured-agent-session-projection.ts | 2 +- 17 files changed, 1119 insertions(+), 217 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-prose.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-turn-status.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx index 50dd54d8dc1..9695317f3af 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { NativeChatMessageList } from './NativeChatMessageList' @@ -49,4 +49,336 @@ describe('NativeChatMessageList assistant messages', () => { expect(controls).not.toHaveClass('absolute') expect(prose.compareDocumentPosition(controls!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) }) + + it('keeps a running tool live when transcript lifecycle metadata is absent', () => { + render( + + ) + + expect(screen.getByText('Running sleep 5')).toBeInTheDocument() + expect(screen.queryByText('1×')).toBeNull() + expect(document.querySelector('.text-destructive')).toBeNull() + }) + + it('keeps bridge chats on the legacy activity chrome', () => { + render( + + ) + + expect(screen.queryByText('Thinking')).toBeNull() + expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() + expect(screen.queryByText('Running sleep 5')).toBeNull() + expect(document.querySelectorAll('.animate-bounce')).toHaveLength(3) + }) + + it('keeps the current tool live when a stale completed lifecycle meets active hook state', () => { + render( + + ) + + expect(screen.getByText('Running sleep 5')).toBeInTheDocument() + }) + + it('shows a stable thinking status directly below the user message', () => { + const { container } = render( + + ) + + const user = screen.getByText('Start the task') + const thinking = screen.getByText('Thinking') + expect(user.compareDocumentPosition(thinking)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(thinking.parentElement).not.toHaveClass('border-b') + expect(thinking.parentElement).toHaveClass('text-sm') + expect(container.querySelector('.animate-bounce')).toBeNull() + expect(thinking).toHaveClass('animate-pulse') + expect(container.querySelectorAll('.size-1.5.animate-pulse')).toHaveLength(0) + }) + + it('places the thinking status directly after the latest user message', () => { + render( + + ) + + const user = screen.getByText('Run the checks') + const status = screen.getByText('Working for 0 seconds') + const assistant = screen.getByText('I am checking now.') + expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(status.parentElement).toHaveClass('border-b') + }) + + it('shows elapsed working time once tool activity starts', () => { + render( + + ) + + expect(screen.getByText('Working for 3 seconds')).toBeInTheDocument() + }) + + it('keeps the completed duration below the user message', () => { + const startedAt = Date.now() - 3000 + const turnSession: NativeChatLiveSession = { + ...session, + status: 'working', + messages: [ + { + id: 'user-complete', + role: 'user', + blocks: [{ type: 'text', text: 'Complete this task' }], + timestamp: startedAt, + source: 'transcript' + }, + { + id: 'assistant-complete', + role: 'assistant', + blocks: [{ type: 'text', text: 'Task complete.' }], + timestamp: Date.now(), + source: 'transcript' + } + ] + } + const { rerender } = render( + + ) + + rerender( + + ) + + const user = screen.getByText('Complete this task') + const status = screen.getByText('Worked for 3 seconds') + const assistant = screen.getByText('Task complete.') + expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + + rerender( + + ) + + expect(screen.getByText('Worked for 3 seconds')).toBeInTheDocument() + expect(screen.getByText('Thinking')).toBeInTheDocument() + }) + + it("uses the completed caret to expand that turn's tool details", () => { + const startedAt = Date.now() - 3000 + render( + + ) + + const status = screen.getByRole('button', { name: 'Toggle turn details' }) + expect(status).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('button', { name: /1× shell/ })).toBeNull() + fireEvent.click(status) + expect(status).toHaveAttribute('aria-expanded', 'true') + const tool = screen.getByRole('button', { name: /1× shell/ }) + expect(tool).toHaveAttribute('aria-expanded', 'true') + expect(screen.getAllByRole('button', { name: /shell pwd/ })[1]).toHaveAttribute( + 'aria-expanded', + 'false' + ) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index f2030362a07..988db65f730 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -1,101 +1,28 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { ArrowDown, ArrowUp, Image as ImageIcon } from 'lucide-react' +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { ArrowDown } from 'lucide-react' import CommentMarkdown, { type CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { basename } from '@/lib/path' -import { - isTextBlock, - type NativeChatBlock, - type NativeChatMessage -} from '../../../../shared/native-chat-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { orderNativeChatMessages } from './native-chat-message-grouping' import { stripNoiseMessages } from './native-chat-noise' import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold' import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll' -import { isNativeChatPastedImagePath } from './native-chat-image-paste' import { NativeChatToolRun } from './NativeChatToolRun' -import { NativeChatCopyButton } from './NativeChatCopyButton' import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' -import { nativeChatProviderFrameSummary } from '../../../../shared/native-chat-provider-frame-summary' +import { NativeChatWorkingStatus } from './NativeChatWorkingStatus' +import { useNativeChatTurnStatus } from './use-native-chat-turn-status' +import { nativeChatProseToMarkdown } from './native-chat-prose' +import { + NativeChatAgentControls, + NativeChatImageAttachments, + ProviderFrameRow +} from './NativeChatTranscriptChrome' -function geometryOf(el: HTMLElement): ScrollGeometry { - return { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight } -} - -function proseToMarkdown(blocks: NativeChatBlock[]): string { - return blocks - .map((block) => { - if (isTextBlock(block)) { - return block.text - } - return '' - }) - .filter((part) => part.length > 0) - .join('\n\n') -} - -function ImageAttachmentRefs({ blocks }: { blocks: NativeChatBlock[] }): React.JSX.Element | null { - const images = blocks.filter((block) => block.type === 'image-ref') - if (images.length === 0) { - return null - } - return ( -
- {images.map((image, index) => { - const label = image.alt ?? image.path ?? image.url ?? 'Image' - const name = - image.path && isNativeChatPastedImagePath(image.path) - ? translate('components.native-chat.composer.pastedImageLabel', 'Pasted image') - : image.path - ? basename(image.path) - : label - return ( -
- - {name} -
- ) - })} -
- ) -} - -/** Footer controls for an agent message: copy its prose or align it to the viewport top. */ -function AgentControls({ - markdown, - onScrollToTop, - className -}: { - markdown: string - onScrollToTop: () => void - className?: string -}): React.JSX.Element { - return ( -
- - -
- ) -} +export { ProviderFrameRow } from './NativeChatTranscriptChrome' function TypingIndicatorRow(): React.JSX.Element { return ( @@ -109,7 +36,6 @@ function TypingIndicatorRow(): React.JSX.Element { ))} @@ -118,56 +44,40 @@ function TypingIndicatorRow(): React.JSX.Element { ) } -export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.JSX.Element | null { - if (block.type !== 'text' || !block.providerFrame) { - return null - } - const frame = block.providerFrame - return ( -
- - - {frame.provider} - {nativeChatProviderFrameSummary(block)} - {frame.payload.truncated ? ( - - ·{' '} - {translate('components.native-chat.providerFrame.byteLength', '{{value0}} bytes', { - value0: frame.payload.byteLength - })} - - ) : null} - -
-        {frame.payload.head}
-        {frame.payload.truncated ? '\n…' : ''}
-      
-
- ) +function geometryOf(el: HTMLElement): ScrollGeometry { + return { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight } } +const MAX_EXPANDED_TURNS = 128 + /** One message: its prose first, then a collapsible run folding all of the * turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a * lifted card, assistant prose as body copy, reasoning de-emphasized. */ function MessageRow({ message, expandSignal, + activeTurnIsWorking, onScrollMessageToTop, onLinkClick, allowFileUriLinks = false, - deliveryFailed = false + deliveryFailed = false, + activityExpandOverride, + structuredActivityUi = true }: { message: NativeChatMessage expandSignal: boolean + activeTurnIsWorking?: boolean /** Align this message's top to the top of the scroll viewport. */ onScrollMessageToTop: (el: HTMLElement) => void onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryFailed?: boolean + activityExpandOverride?: boolean + structuredActivityUi?: boolean }): React.JSX.Element | null { const rowRef = useRef(null) const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks]) - const markdown = proseToMarkdown(prose) + const markdown = nativeChatProseToMarkdown(prose) const hasImages = prose.some((block) => block.type === 'image-ref') const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' @@ -196,11 +106,6 @@ function MessageRow({ } if (isUser) { - // Why: an optimistic echo is rendered identically to a real user turn (no - // muting, no "Queued" label) so that when the real transcript turn lands and - // replaces it, there is no visible state change — the send just appears and - // stays. (A distinct "queued" treatment flickered normal→queued→normal as the - // transcript caught up.) return (
{/* User turns get a distinct muted fill (not the card/canvas color) so @@ -208,7 +113,7 @@ function MessageRow({
{markdown ? ( <> - + ) : ( - + )}
{deliveryFailed ? ( @@ -247,7 +152,7 @@ function MessageRow({ isSystem && 'text-xs text-muted-foreground' )} > - + {markdown ? ( ) : null} - {tools.length > 0 ? : null} + {tools.length > 0 ? ( + + ) : null} {showControls ? ( - + /** Turn timing/disclosure is available only on the structured Codex lane. */ + showTurnStatus?: boolean }): React.JSX.Element { const scrollRef = useRef(null) const contentRef = useRef(null) const [stuckToBottom, setStuckToBottom] = useState(true) const [showJump, setShowJump] = useState(false) + const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()) + const toggleExpandedTurn = useCallback((turnKey: string) => { + setExpandedTurnIds((current) => { + const next = new Set(current) + if (next.has(turnKey)) { + next.delete(turnKey) + } else { + if (next.size >= MAX_EXPANDED_TURNS) { + const oldest = next.values().next().value + if (oldest) { + next.delete(oldest) + } + } + next.add(turnKey) + } + return next + }) + }, []) - // Why: mirror stuck state into a ref so the auto-scroll layout effect can read - // it without depending on it — depending on stuckToBottom (which scrollToBottom - // sets) would re-fire the effect in a self-loop. const stuckToBottomRef = useRef(stuckToBottom) stuckToBottomRef.current = stuckToBottom @@ -306,11 +239,30 @@ export function NativeChatMessageList({ () => stripNoiseMessages(foldToolMessages(orderNativeChatMessages(session.messages))), [session.messages] ) - const showTypingIndicator = shouldShowNativeChatTypingIndicator({ messages, isWorking }) + const showTypingIndicator = showTurnStatus + ? isWorking + : shouldShowNativeChatTypingIndicator({ messages, isWorking }) + const latestUserIndex = messages.findLastIndex((message) => message.role === 'user') + const currentTurnKey = + latestUserIndex === -1 ? undefined : (messages[latestUserIndex]?.id ?? undefined) + // Resolve each row's turn boundary once. Prefix slice/findLast in the render + // loop becomes quadratic for long transcripts. + const turnKeys = useMemo(() => { + let currentTurnKey: string | undefined + return messages.map((message) => { + if (message.role === 'user') { + currentTurnKey = message.id + } + return currentTurnKey + }) + }, [messages]) + const turnStatuses = useNativeChatTurnStatus({ + messages, + latestUserIndex, + isWorking: showTurnStatus && isWorking, + workingStartedAt: showTurnStatus ? workingStartedAt : null + }) - // When an older page prepends, the scroll content grows above the viewport. - // Capture the pre-render scroll height so the layout effect can restore the - // user's position (no jump) instead of letting the browser keep scrollTop. const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const handleScroll = useCallback(() => { @@ -346,18 +298,12 @@ export function NativeChatMessageList({ if (!container) { return } - // Detach synchronously (not just via the pending onScroll) so an in-place - // streaming growth can't re-pin to the bottom mid-flight and fight this - // deliberate scroll. The ref is what the resize observer reads. stuckToBottomRef.current = false setStuckToBottom(false) const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top container.scrollTo({ top: container.scrollTop + delta, behavior: 'smooth' }) }, []) - // Re-pin to the bottom when new content arrives, but only if the user hasn't - // scrolled up. Layout effect so the jump happens before paint (no flicker). - // When an older page just prepended, restore the prior position instead. useLayoutEffect(() => { const el = scrollRef.current if (el && prependAnchorRef.current) { @@ -373,11 +319,6 @@ export function NativeChatMessageList({ } }, [messages.length, isWorking, showTypingIndicator, scrollToBottom]) - // Content growing without a message-count change (a streaming assistant turn - // extends its own message in place) never re-fires the layout effect above. - // Observe the container so those in-place growths still re-pin: stay glued to - // the bottom while stuck, otherwise just refresh the jump affordance. This is - // what removes most "Jump to latest" clicks during a live response. useEffect(() => { const el = scrollRef.current if (!el || typeof ResizeObserver === 'undefined') { @@ -430,18 +371,66 @@ export function NativeChatMessageList({
) : null} - {messages.map((message) => ( - { + const turnKey = turnKeys[index] + const isCurrentTurn = currentTurnKey + ? turnKey === currentTurnKey + : turnKey === undefined + const status = + index === latestUserIndex + ? turnStatuses.active + : message.role === 'user' && turnKey + ? turnStatuses.completedByTurn[turnKey] + : undefined + return ( + + + {showTurnStatus && + status && + (index !== latestUserIndex || showTypingIndicator || !isWorking) ? ( + toggleExpandedTurn(turnKey) + : undefined + } + /> + ) : null} + + ) + })} + {showTurnStatus && + latestUserIndex === -1 && + turnStatuses.active && + showTypingIndicator ? ( + - ))} - {showTypingIndicator ? : null} + ) : null} + {!showTurnStatus && showTypingIndicator ? : null} {showJump ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 6806387411b..397b524a4c4 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -359,6 +359,8 @@ export function NativeChatResolvedView({ isWorking={isWorking} expandSignal={false} fontScale={fontScale.scale} + workingStartedAt={hookWorkingEpoch} + showTurnStatus={false} onLinkClick={nativeChatFileLinkClick} allowFileUriLinks={fileLinkContext !== null} failedDeliveryMessageIds={failedLaunchPromptMessageIds} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 67d5db8ab4c..7a02829a05a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -141,6 +141,8 @@ export function NativeChatStructuredSession(props: { isWorking={controller.isWorking} expandSignal={false} fontScale={fontScale.scale} + workingStartedAt={null} + showTurnStatus={props.agent === 'codex'} onLinkClick={fileLinkClick} allowFileUriLinks={fileLinkClick !== undefined} /> diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index a092b8f00bb..41a70a8457d 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -89,4 +89,126 @@ describe('NativeChatToolRun', () => { expect(container).not.toHaveTextContent('"changes"') expect(container.querySelector('pre')).toBeNull() }) + + it('keeps a grouped active run to one stable row showing only the latest tool', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'shell', input: { command: 'date' }, state: 'completed' }, + { type: 'tool-call', name: 'shell', input: { command: 'pwd' }, state: 'completed' }, + { type: 'tool-call', name: 'shell', input: { command: 'cat package.json' }, state: 'running' } + ] + + const { container } = render() + + expect(screen.getByText('Running cat package.json')).toBeInTheDocument() + expect(screen.queryByText('Running date')).toBeNull() + expect(screen.queryByText('Running pwd')).toBeNull() + expect(screen.queryByText('Ran 3 commands and used 1 tool')).toBeNull() + expect(container.querySelector('.animate-spin')).toBeNull() + }) + + it('treats legacy tool calls without lifecycle state as active while the turn works', () => { + render( + + ) + + expect(screen.getByText('Running sleep 5')).toBeInTheDocument() + }) + + it('keeps a completed tool payload collapsed until the run is expanded', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'shell', + input: { command: 'printf hello' }, + state: 'completed' + }, + { type: 'tool-result', output: 'hello' } + ] + + render() + expect(screen.queryByText('hello')).toBeNull() + }) + + it('replaces the live row with a compact result when the active call settles', () => { + const runningBlocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' } + ] + const { rerender } = render() + + expect(screen.getByText('Running sleep 1')).toBeInTheDocument() + + rerender( + + ) + + expect(screen.queryByText('Running sleep 1')).toBeNull() + expect(screen.getByText('shell sleep 1')).toBeInTheDocument() + }) + + it('keeps failed tool runs visually neutral while collapsed', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'shell', input: { command: 'false' }, state: 'failed' }, + { type: 'tool-result', output: 'exit 1', isError: true } + ] + + const { container } = render() + + expect(container.querySelector('.lucide-check')).toBeInTheDocument() + expect(container.querySelector('.lucide-circle-alert')).toBeNull() + expect(screen.queryByText('exit 1')).toBeNull() + }) + + it('keeps settled tool activity behind the completed turn disclosure', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'shell', input: { command: 'git log -1' }, state: 'failed' }, + { type: 'tool-result', output: 'exit 128', isError: true } + ] + + const { rerender } = render( + + ) + + expect(screen.queryByText('git log -1')).toBeNull() + expect(screen.queryByText('exit 128')).toBeNull() + + rerender( + + ) + + expect(screen.getByText('shell git log -1')).toBeInTheDocument() + }) + + it('settles an orphaned running call when its turn lifecycle has ended', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' } + ] + + const { container } = render( + + ) + + expect(screen.queryByText('Running sleep 1')).toBeNull() + expect(container.querySelector('.lucide-check')).toBeInTheDocument() + expect(container.querySelector('.lucide-circle-alert')).toBeNull() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index e0c5cd10284..716d293838e 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { ChevronRight } from 'lucide-react' +import { Check, ChevronRight, SquareTerminal, Wrench } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { @@ -16,13 +16,59 @@ import { } from './native-chat-tool-summary' import { NativeChatDiffView } from './NativeChatDiffView' +const COMMAND_TOOL_NAMES = new Set([ + 'bash', + 'shell', + 'powershell', + 'terminal', + 'execute', + 'run_command', + 'run_shell_command', + 'shell_command', + 'exec_command', + 'run_terminal_cmd', + 'run_terminal_command' +]) + +function normalizedToolName(name: string): string { + return name.trim().toLowerCase() +} + +function activeToolLabel(call: Extract): string { + const preview = createToolInputDisplay(call.input).label + if (COMMAND_TOOL_NAMES.has(normalizedToolName(call.name))) { + return preview + ? translate('components.native-chat.tool.runningPreview', 'Running {{preview}}', { + preview + }) + : translate('components.native-chat.tool.runningCommand', 'Running command') + } + return preview + ? translate( + 'components.native-chat.tool.runningNamedPreview', + 'Running {{toolName}} {{preview}}', + { + toolName: call.name, + preview + } + ) + : translate('components.native-chat.tool.runningNamed', 'Running {{toolName}}', { + toolName: call.name + }) +} + /** A single inline tool line — `▸ ToolName preview` — that expands in place to * show the call's diff/input or the result's body. Tool calls read as flat * lines in the conversation rather than boxed blocks (mobile parity). Lines only - * mount while the parent run is open, so each starts expanded (opening the run - * reveals every line at once) and is then individually collapsible. */ -function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | null { - const [expanded, setExpanded] = useState(true) + * mount while the parent run is open and are individually collapsible. */ +function ToolLine({ + block, + initiallyExpanded = true +}: { + block: NativeChatBlock + initiallyExpanded?: boolean +}): React.JSX.Element | null { + const [expanded, setExpanded] = useState(initiallyExpanded) let name: string let preview: string @@ -58,6 +104,7 @@ function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | nu 'group flex w-full items-center gap-1.5 py-0.5 text-left', hasDetail ? 'cursor-pointer' : 'cursor-default' )} + aria-expanded={hasDetail ? expanded : undefined} > {name} @@ -71,8 +118,7 @@ function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | nu
) : null} {hasDetail ? ( - // Chevron sits on the right; hidden until hover when collapsed, always - // shown (pointing down) when expanded — mirrors Codex's disclosure affordance. + // Chevron stays hidden until this row is expanded. setOpen(expandSignal), [expandSignal]) + useEffect(() => setOpen(expandOverride ?? expandSignal), [expandOverride, expandSignal]) const callCount = countToolCalls(blocks) || blocks.length const summary = summarizeToolRun(blocks) + const calls = blocks.filter(isToolCallBlock) + const activeCalls = structuredActivityUi + ? calls.filter( + (call) => + (call.state === 'running' || (call.state == null && activeTurnIsWorking === true)) && + activeTurnIsWorking !== false + ) + : [] + const latestActiveCall = activeCalls.at(-1) + const isSettled = latestActiveCall == null + // The turn caret opens the activity group, while each child tool remains + // collapsed. The global expand toolbar still opens child details together. + const expandToolLines = expandOverride === undefined ? open : false + const ActiveToolIcon = + latestActiveCall && COMMAND_TOOL_NAMES.has(normalizedToolName(latestActiveCall.name)) + ? SquareTerminal + : Wrench const fallbackLabel = callCount === 1 ? translate('components.native-chat.tool.countOne', '1 tool call') @@ -129,35 +200,87 @@ export function NativeChatToolRun({ value0: callCount }) + // Completed turn activity belongs behind the turn-status disclosure. Keeping + // the grouped row visible here made a failed child command look like the + // whole response was still running (or had failed) even while collapsed. + if ( + structuredActivityUi && + expandOverride === false && + isSettled && + activeTurnIsWorking === false + ) { + return null + } + return ( // Extra top margin sets the tool run apart from the assistant prose above it // so the turn's activity doesn't crowd the message text.
- + {latestActiveCall ? ( + + ) : ( + + )} {open ? (
- {blocks.map((block, i) => ( - - ))} + {(() => { + const seen = new Map() + return blocks.map((block) => { + const signature = + block.type === 'tool-call' + ? `${block.type}:${block.name}:${JSON.stringify(block.input)}` + : block.type === 'tool-result' + ? `${block.type}:${block.output}` + : `${block.type}` + const occurrence = seen.get(signature) ?? 0 + seen.set(signature, occurrence + 1) + return ( + + ) + }) + })()}
) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx new file mode 100644 index 00000000000..206f0f8df5e --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -0,0 +1,98 @@ +import { ArrowUp, Image as ImageIcon } from 'lucide-react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { basename } from '@/lib/path' +import type { NativeChatBlock } from '../../../../shared/native-chat-types' +import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { NativeChatCopyButton } from './NativeChatCopyButton' +import { nativeChatProviderFrameSummary } from '../../../../shared/native-chat-provider-frame-summary' + +export function NativeChatImageAttachments({ + blocks +}: { + blocks: NativeChatBlock[] +}): React.JSX.Element | null { + const images = blocks.filter((block) => block.type === 'image-ref') + if (images.length === 0) { + return null + } + return ( +
+ {images.map((image, index) => { + const label = image.alt ?? image.path ?? image.url ?? 'Image' + const name = + image.path && isNativeChatPastedImagePath(image.path) + ? translate('components.native-chat.composer.pastedImageLabel', 'Pasted image') + : image.path + ? basename(image.path) + : label + return ( +
+ + {name} +
+ ) + })} +
+ ) +} + +export function NativeChatAgentControls({ + markdown, + onScrollToTop, + className +}: { + markdown: string + onScrollToTop: () => void + className?: string +}): React.JSX.Element { + return ( +
+ + +
+ ) +} + +export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.JSX.Element | null { + if (block.type !== 'text' || !block.providerFrame) { + return null + } + const frame = block.providerFrame + return ( +
+ + + {frame.provider} + {nativeChatProviderFrameSummary(block)} + {frame.payload.truncated ? ( + + ·{' '} + {translate('components.native-chat.providerFrame.byteLength', '{{value0}} bytes', { + value0: frame.payload.byteLength + })} + + ) : null} + +
+        {frame.payload.head}
+        {frame.payload.truncated ? '\n…' : ''}
+      
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx new file mode 100644 index 00000000000..5aef81ba51d --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react' +import { ChevronRight } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export function NativeChatWorkingStatus({ + startedAt, + thinking, + workedSeconds, + expanded = false, + onToggleExpanded +}: { + startedAt: number | null + thinking: boolean + workedSeconds?: number | null + expanded?: boolean + onToggleExpanded?: () => void +}): React.JSX.Element { + const [elapsedSeconds, setElapsedSeconds] = useState(0) + + useEffect(() => { + if (thinking || workedSeconds != null) { + return + } + const epoch = startedAt ?? Date.now() + setElapsedSeconds(Math.max(0, Math.floor((Date.now() - epoch) / 1000))) + const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - epoch) / 1000))) + const timer = window.setInterval(update, 1000) + return () => window.clearInterval(timer) + }, [startedAt, thinking, workedSeconds]) + + const label = + workedSeconds != null + ? translate('components.native-chat.status.workedFor', 'Worked for {{value0}} seconds', { + value0: workedSeconds + }) + : thinking + ? translate('components.native-chat.status.thinking', 'Thinking') + : translate('components.native-chat.status.workingFor', 'Working for {{value0}} seconds', { + value0: elapsedSeconds + }) + + const className = `flex min-h-8 items-center gap-1 text-sm text-muted-foreground${thinking ? '' : ' border-b border-border'}` + const caret = + workedSeconds != null ? ( +