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 <sim@local>
This commit is contained in:
Brennan Benson
2026-08-31 16:31:30 -07:00
committed by GitHub
co-authored by Merge Sim
parent 8ac1c6e2ac
commit 59facfb71e
17 changed files with 1119 additions and 217 deletions
@@ -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(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'assistant-tool-1',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'sleep 5' },
state: 'running'
}
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
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(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'bridge-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'sleep 5' },
state: 'running'
}
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
showTurnStatus={false}
/>
)
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(
<NativeChatMessageList
session={{
...session,
status: 'working',
transcriptLifecycle: { state: 'completed', turnId: 'old-turn', timestamp: 1 },
messages: [
{
id: 'current-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'sleep 5' },
state: 'running'
}
],
timestamp: 2,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
expect(screen.getByText('Running sleep 5')).toBeInTheDocument()
})
it('shows a stable thinking status directly below the user message', () => {
const { container } = render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'user-thinking',
role: 'user',
blocks: [{ type: 'text', text: 'Start the task' }],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
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(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'user-1',
role: 'user',
blocks: [{ type: 'text', text: 'Run the checks' }],
timestamp: 1,
source: 'transcript'
},
{
id: 'assistant-1',
role: 'assistant',
blocks: [{ type: 'text', text: 'I am checking now.' }],
timestamp: 2,
source: 'transcript'
}
]
}}
isWorking
expandSignal={false}
fontScale={1}
/>
)
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(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'tool-1',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'sleep 5' },
state: 'running'
}
],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
workingStartedAt={Date.now() - 3000}
expandSignal={false}
fontScale={1}
/>
)
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(
<NativeChatMessageList
session={turnSession}
isWorking
workingStartedAt={startedAt}
expandSignal={false}
fontScale={1}
/>
)
rerender(
<NativeChatMessageList
session={{ ...turnSession, status: 'ready' }}
isWorking={false}
workingStartedAt={null}
expandSignal={false}
fontScale={1}
/>
)
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(
<NativeChatMessageList
session={{
...turnSession,
status: 'working',
messages: [
...turnSession.messages,
{
id: 'user-next',
role: 'user',
blocks: [{ type: 'text', text: 'Start another task' }],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
isWorking
workingStartedAt={Date.now()}
expandSignal={false}
fontScale={1}
/>
)
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(
<NativeChatMessageList
session={{
...session,
status: 'ready',
messages: [
{
id: 'user-details',
role: 'user',
blocks: [{ type: 'text', text: 'Inspect the repo' }],
timestamp: startedAt,
source: 'transcript'
},
{
id: 'assistant-details',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'pwd' },
state: 'completed'
},
{ type: 'tool-result', output: '/repo' }
],
timestamp: Date.now(),
source: 'transcript'
}
]
}}
isWorking={false}
workingStartedAt={startedAt}
expandSignal={false}
fontScale={1}
/>
)
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'
)
})
})
@@ -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 (
<div className="mb-2 flex flex-wrap gap-1.5">
{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 (
<div
key={`${label}-${index}`}
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground"
title={label}
>
<ImageIcon className="size-3.5 shrink-0" />
<span className="truncate">{name}</span>
</div>
)
})}
</div>
)
}
/** 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 (
<div className={cn('flex items-center gap-1', className)}>
<NativeChatCopyButton text={markdown} />
<button
type="button"
onClick={onScrollToTop}
aria-label={translate(
'components.native-chat.scrollMessageToTop',
'Scroll this message to top'
)}
title={translate('components.native-chat.scrollMessageToTop', 'Scroll this message to top')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowUp className="size-3.5" />
</button>
</div>
)
}
export { ProviderFrameRow } from './NativeChatTranscriptChrome'
function TypingIndicatorRow(): React.JSX.Element {
return (
@@ -109,7 +36,6 @@ function TypingIndicatorRow(): React.JSX.Element {
<span
key={i}
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/70"
// Stagger the three dots so they ripple rather than pulse in unison.
style={{ animationDelay: `${i * 160}ms` }}
/>
))}
@@ -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 (
<details className="group text-xs text-muted-foreground">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="transition-transform group-open:rotate-90"></span>
<span className="font-medium text-foreground">{frame.provider}</span>
<span className="truncate">{nativeChatProviderFrameSummary(block)}</span>
{frame.payload.truncated ? (
<span>
·{' '}
{translate('components.native-chat.providerFrame.byteLength', '{{value0}} bytes', {
value0: frame.payload.byteLength
})}
</span>
) : null}
</summary>
<pre className="scrollbar-sleek mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-muted p-2 font-mono text-xs text-foreground">
{frame.payload.head}
{frame.payload.truncated ? '\n…' : ''}
</pre>
</details>
)
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<HTMLDivElement | null>(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 (
<div ref={rowRef} className="flex flex-col items-end gap-0.5">
{/* User turns get a distinct muted fill (not the card/canvas color) so
@@ -208,7 +113,7 @@ function MessageRow({
<div className="max-w-[85%] rounded-lg rounded-tr-sm bg-muted px-3.5 py-2.5 text-sm text-foreground">
{markdown ? (
<>
<ImageAttachmentRefs blocks={prose} />
<NativeChatImageAttachments blocks={prose} />
<CommentMarkdown
content={markdown}
variant="document"
@@ -218,7 +123,7 @@ function MessageRow({
/>
</>
) : (
<ImageAttachmentRefs blocks={prose} />
<NativeChatImageAttachments blocks={prose} />
)}
</div>
{deliveryFailed ? (
@@ -247,7 +152,7 @@ function MessageRow({
isSystem && 'text-xs text-muted-foreground'
)}
>
<ImageAttachmentRefs blocks={prose} />
<NativeChatImageAttachments blocks={prose} />
{markdown ? (
<CommentMarkdown
content={markdown}
@@ -257,9 +162,17 @@ function MessageRow({
allowFileUriLinks={allowFileUriLinks}
/>
) : null}
{tools.length > 0 ? <NativeChatToolRun blocks={tools} expandSignal={expandSignal} /> : null}
{tools.length > 0 ? (
<NativeChatToolRun
blocks={tools}
expandSignal={expandSignal}
expandOverride={activityExpandOverride}
activeTurnIsWorking={activeTurnIsWorking}
structuredActivityUi={structuredActivityUi}
/>
) : null}
{showControls ? (
<AgentControls
<NativeChatAgentControls
markdown={markdown}
onScrollToTop={scrollToTop}
className="pointer-events-none mt-1 -mb-5 w-fit select-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"
@@ -276,7 +189,9 @@ export function NativeChatMessageList({
fontScale,
onLinkClick,
allowFileUriLinks = false,
failedDeliveryMessageIds
workingStartedAt,
failedDeliveryMessageIds,
showTurnStatus = true
}: {
session: NativeChatLiveSession
isWorking: boolean
@@ -284,18 +199,36 @@ export function NativeChatMessageList({
expandSignal: boolean
/** Chat-only text multiplier (1 = default), driven by the zoom shortcuts. */
fontScale: number
workingStartedAt?: number | null
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
failedDeliveryMessageIds?: ReadonlySet<string>
/** Turn timing/disclosure is available only on the structured Codex lane. */
showTurnStatus?: boolean
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const [stuckToBottom, setStuckToBottom] = useState(true)
const [showJump, setShowJump] = useState(false)
const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<string>>(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({
</button>
</div>
) : null}
{messages.map((message) => (
<MessageRow
key={message.id}
message={message}
expandSignal={expandSignal}
onScrollMessageToTop={scrollMessageToTop}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true}
{messages.map((message, index) => {
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 (
<Fragment key={message.id}>
<MessageRow
message={message}
expandSignal={expandSignal}
// A missing transcript lifecycle is not evidence that the turn
// ended. Structured sessions and legacy live hooks still expose
// the authoritative session-level working state.
activeTurnIsWorking={
showTurnStatus &&
isCurrentTurn &&
(isWorking || session.transcriptLifecycle?.state === 'working')
}
onScrollMessageToTop={scrollMessageToTop}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true}
structuredActivityUi={showTurnStatus}
activityExpandOverride={turnKey ? expandedTurnIds.has(turnKey) : undefined}
/>
{showTurnStatus &&
status &&
(index !== latestUserIndex || showTypingIndicator || !isWorking) ? (
<NativeChatWorkingStatus
startedAt={status.startedAt}
thinking={status.thinking}
workedSeconds={status.workedSeconds}
expanded={turnKey ? expandedTurnIds.has(turnKey) : false}
onToggleExpanded={
status.workedSeconds != null && turnKey
? () => toggleExpandedTurn(turnKey)
: undefined
}
/>
) : null}
</Fragment>
)
})}
{showTurnStatus &&
latestUserIndex === -1 &&
turnStatuses.active &&
showTypingIndicator ? (
<NativeChatWorkingStatus
startedAt={turnStatuses.active.startedAt}
thinking={turnStatuses.active.thinking}
workedSeconds={turnStatuses.active.workedSeconds}
/>
))}
{showTypingIndicator ? <TypingIndicatorRow /> : null}
) : null}
{!showTurnStatus && showTypingIndicator ? <TypingIndicatorRow /> : null}
</div>
</div>
{showJump ? (
@@ -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}
@@ -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}
/>
@@ -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(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
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(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'shell', input: { command: 'sleep 5' } }]}
expandSignal={false}
activeTurnIsWorking
/>
)
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(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
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(<NativeChatToolRun blocks={runningBlocks} expandSignal={false} />)
expect(screen.getByText('Running sleep 1')).toBeInTheDocument()
rerender(
<NativeChatToolRun
blocks={[
{ type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'completed' },
{ type: 'tool-result', output: 'done' }
]}
expandSignal={false}
/>
)
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(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
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(
<NativeChatToolRun
blocks={blocks}
expandSignal={false}
expandOverride={false}
activeTurnIsWorking={false}
/>
)
expect(screen.queryByText('git log -1')).toBeNull()
expect(screen.queryByText('exit 128')).toBeNull()
rerender(
<NativeChatToolRun
blocks={blocks}
expandSignal={false}
expandOverride
activeTurnIsWorking={false}
/>
)
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(
<NativeChatToolRun blocks={blocks} expandSignal={false} activeTurnIsWorking={false} />
)
expect(screen.queryByText('Running sleep 1')).toBeNull()
expect(container.querySelector('.lucide-check')).toBeInTheDocument()
expect(container.querySelector('.lucide-circle-alert')).toBeNull()
})
})
@@ -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<NativeChatBlock, { type: 'tool-call' }>): string {
const preview = createToolInputDisplay(call.input).label
if (COMMAND_TOOL_NAMES.has(normalizedToolName(call.name))) {
return preview
? translate('components.native-chat.tool.runningPreview', 'Running {{preview}}', {
preview
})
: translate('components.native-chat.tool.runningCommand', 'Running command')
}
return preview
? translate(
'components.native-chat.tool.runningNamedPreview',
'Running {{toolName}} {{preview}}',
{
toolName: call.name,
preview
}
)
: translate('components.native-chat.tool.runningNamed', 'Running {{toolName}}', {
toolName: call.name
})
}
/** 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}
>
<code className="shrink-0 font-mono text-xs font-semibold text-foreground/90 transition-colors group-hover:text-foreground">
{name}
@@ -71,8 +118,7 @@ function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | nu
</span>
) : 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.
<ChevronRight
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-all',
@@ -110,18 +156,43 @@ function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | nu
* toolbar toggle drive every run at once while still allowing per-run override. */
export function NativeChatToolRun({
blocks,
expandSignal
expandSignal,
activeTurnIsWorking,
expandOverride,
structuredActivityUi = true
}: {
blocks: NativeChatBlock[]
/** Toolbar-driven desired open state. Each change re-syncs this run's state. */
expandSignal: boolean
}): React.JSX.Element {
const [open, setOpen] = useState(expandSignal)
/** Per-turn disclosure state controlled by the completed turn status row. */
expandOverride?: boolean
/** Structured lifecycle state, when available, keeps orphaned running calls from spinning. */
activeTurnIsWorking?: boolean
structuredActivityUi?: boolean
}): React.JSX.Element | null {
const [open, setOpen] = useState(expandOverride ?? expandSignal)
// Re-sync when the global toolbar toggle flips.
useEffect(() => 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.
<div className="mt-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="group flex w-full items-center gap-1.5 py-0.5 text-left"
>
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground transition-colors group-hover:text-foreground/80">
{callCount}×
</span>
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{summary || fallbackLabel}
</span>
{/* Chevron on the right, revealed on hover when collapsed and pointing
down when open — matches Codex's tool-run disclosure. */}
<ChevronRight
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-all',
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
/>
</button>
{latestActiveCall ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="group flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-sm leading-relaxed text-muted-foreground hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
aria-expanded={open}
aria-live="polite"
>
<span className="flex size-6 shrink-0 items-center justify-center text-muted-foreground">
<ActiveToolIcon className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-foreground/85">
{activeToolLabel(latestActiveCall)}
</span>
{open ? <ChevronRight className="size-3.5 rotate-90 text-muted-foreground" /> : null}
</button>
) : (
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="group flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left"
aria-expanded={open}
>
{structuredActivityUi ? (
<span className="flex size-6 shrink-0 items-center justify-center text-muted-foreground">
<Check className="size-3.5" />
</span>
) : null}
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground transition-colors group-hover:text-foreground/80">
{callCount}×
</span>
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{summary || fallbackLabel}
</span>
{/* Chevron is revealed on hover when collapsed and points down when open. */}
<ChevronRight
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-all',
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
/>
</button>
)}
{open ? (
<div className="mt-1">
{blocks.map((block, i) => (
<ToolLine key={i} block={block} />
))}
{(() => {
const seen = new Map<string, number>()
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 (
<ToolLine
key={`${signature}:${occurrence}`}
block={block}
initiallyExpanded={expandToolLines}
/>
)
})
})()}
</div>
) : null}
</div>
@@ -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 (
<div className="mb-2 flex flex-wrap gap-1.5">
{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 (
<div
key={`${label}-${index}`}
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground"
title={label}
>
<ImageIcon className="size-3.5 shrink-0" />
<span className="truncate">{name}</span>
</div>
)
})}
</div>
)
}
export function NativeChatAgentControls({
markdown,
onScrollToTop,
className
}: {
markdown: string
onScrollToTop: () => void
className?: string
}): React.JSX.Element {
return (
<div className={cn('flex items-center gap-1', className)}>
<NativeChatCopyButton text={markdown} />
<button
type="button"
onClick={onScrollToTop}
aria-label={translate(
'components.native-chat.scrollMessageToTop',
'Scroll this message to top'
)}
title={translate('components.native-chat.scrollMessageToTop', 'Scroll this message to top')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowUp className="size-3.5" />
</button>
</div>
)
}
export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.JSX.Element | null {
if (block.type !== 'text' || !block.providerFrame) {
return null
}
const frame = block.providerFrame
return (
<details className="group text-xs text-muted-foreground">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="transition-transform group-open:rotate-90"></span>
<span className="font-medium text-foreground">{frame.provider}</span>
<span className="truncate">{nativeChatProviderFrameSummary(block)}</span>
{frame.payload.truncated ? (
<span>
·{' '}
{translate('components.native-chat.providerFrame.byteLength', '{{value0}} bytes', {
value0: frame.payload.byteLength
})}
</span>
) : null}
</summary>
<pre className="scrollbar-sleek mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-muted p-2 font-mono text-xs text-foreground">
{frame.payload.head}
{frame.payload.truncated ? '\n…' : ''}
</pre>
</details>
)
}
@@ -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 ? (
<ChevronRight
className={`size-3.5 transition-transform${expanded ? ' rotate-90' : ''}`}
aria-hidden="true"
/>
) : null
if (workedSeconds != null && onToggleExpanded) {
return (
<button
type="button"
className={`${className} w-full text-left hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70`}
aria-label={translate('components.native-chat.status.toggleDetails', 'Toggle turn details')}
aria-expanded={expanded}
onClick={onToggleExpanded}
>
<span>{label}</span>
{caret}
</button>
)
}
return (
<div
className={className}
aria-label={translate('components.native-chat.status.responding', 'Agent is responding')}
aria-live="polite"
>
<span className={thinking ? 'animate-pulse' : undefined}>{label}</span>
{caret}
</div>
)
}
@@ -0,0 +1,8 @@
import { isTextBlock, type NativeChatBlock } from '../../../../shared/native-chat-types'
export function nativeChatProseToMarkdown(blocks: NativeChatBlock[]): string {
return blocks
.map((block) => (isTextBlock(block) ? block.text : ''))
.filter((part) => part.length > 0)
.join('\n\n')
}
@@ -68,6 +68,24 @@ describe('shouldShowNativeChatTypingIndicator', () => {
).toBe(true)
})
it('does not let an unresolved tool from an earlier turn hide the next send indicator', () => {
const earlierRunningTool: NativeChatMessage = {
id: 'tool-old',
role: 'assistant',
blocks: [
{ type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' }
],
timestamp: null,
source: 'transcript'
}
expect(
shouldShowNativeChatTypingIndicator({
messages: [earlierRunningTool, message('a1', 'assistant'), message('u2', 'user')],
isWorking: true
})
).toBe(true)
})
it('shows after a slash-command marker even though an earlier turn replied', () => {
expect(
shouldShowNativeChatTypingIndicator({
@@ -118,15 +136,13 @@ describe('with rows projected from the structured journal', () => {
} as AgentJournalRenderItem
}
it('keeps showing while a running command is the newest row', () => {
// The screenshot case: prose landed, then codex started running shell commands
// and the chat body went still for the length of the command.
it('stays visible beside the structured live tool row while a command runs', () => {
const messages = projectStructuredItemsToNativeChat([assistantTextItem(1), toolCallItem(2)])
expect(messages.at(-1)?.role).toBe('assistant')
expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: true })).toBe(true)
})
it('still hides once prose is the newest row', () => {
it('hides once prose is the newest row', () => {
const messages = projectStructuredItemsToNativeChat([toolCallItem(1), assistantTextItem(2)])
expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: true })).toBe(false)
})
@@ -1,21 +1,7 @@
// When the trailing "…" row is allowed to render.
//
// The rule suppresses the dots once the turn's own assistant ANSWER is on screen,
// because a placeholder below streamed text reflows the list when it disappears.
// It must not suppress on a row that only reports tool work: a shell command can
// run for a minute with nothing else arriving, and that is precisely when the
// user needs to see that the turn is still alive.
//
// Both transports have to agree, and matching on `role` alone does not get there:
// the PTY path emits synthetic `command:` marker rows, while the structured path
// projects a journal tool-call item as `role: 'assistant'` with tool blocks. Same
// meaning, different shape — so the predicate is about the row's CONTENT.
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'
import { isCommandMarkerId } from './native-chat-command-marker'
/** A row carrying only tool activity — no prose. It is progress, not an answer. */
function isToolActivityOnlyRow(message: NativeChatMessage): boolean {
const blocks = message.blocks
if (!blocks || blocks.length === 0) {
@@ -32,20 +18,14 @@ export function shouldShowNativeChatTypingIndicator(args: {
return false
}
const { messages } = args
// Scan back only to the turn boundary: an assistant row from an EARLIER turn
// must not suppress the indicator for the send the user just made.
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]
if (!message || message.role === 'user' || isCommandMarkerId(message.id)) {
return true
}
// Tool work is the strongest reason to KEEP the dots, so it decides here
// rather than falling through to the assistant-role check below.
if (isToolActivityOnlyRow(message)) {
return true
}
// Status/system rows interleave mid-turn; they neither suppress nor unsuppress,
// otherwise the dots would flicker back on between assistant chunks.
if (message.role === 'assistant' || message.id === NATIVE_CHAT_STREAMING_ID) {
return false
}
@@ -3,7 +3,8 @@ import {
NATIVE_CHAT_SOURCE_PRIORITY,
type AgentType,
type NativeChatMessage,
type NativeChatSession
type NativeChatSession,
type NativeChatTurnLifecycle
} from '../../../../shared/native-chat-types'
import {
applyAppend,
@@ -39,6 +40,8 @@ export type UseNativeChatLiveSessionArgs = {
/** A live session plus the older-history pagination controls the view needs. */
export type NativeChatLiveSession = NativeChatSession & {
/** Latest provider turn boundary, used to settle orphaned running tool rows. */
transcriptLifecycle?: NativeChatTurnLifecycle
/** True when an older page may still exist (the last read filled the window). */
hasMore: boolean
/** Whether an older-history page is currently loading. */
@@ -0,0 +1,120 @@
import { useLayoutEffect, useState } from 'react'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
type NativeChatTurnTiming = {
startedAt: number
workedSeconds: number | null
}
export type NativeChatTurnStatus = {
startedAt: number | null
thinking: boolean
workedSeconds: number | null
}
export function useNativeChatTurnStatus({
messages,
latestUserIndex,
isWorking,
workingStartedAt
}: {
messages: readonly NativeChatMessage[]
latestUserIndex: number
isWorking: boolean
workingStartedAt?: number | null
}): {
active: NativeChatTurnStatus | null
completedByTurn: Readonly<Record<string, NativeChatTurnStatus>>
} {
const currentTurnMessages = messages.slice(latestUserIndex + 1)
const hasCurrentTurnResponse = currentTurnMessages.some(
(message) =>
(message.role === 'assistant' || message.role === 'tool') &&
message.blocks.some(
(block) =>
block.type === 'tool-call' ||
block.type === 'tool-result' ||
(block.type === 'text' && block.text.trim().length > 0)
)
)
const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null
const activeTurnKey = latestUserId ?? '__unanchored__'
const [timingByTurn, setTimingByTurn] = useState<Record<string, NativeChatTurnTiming>>({})
useLayoutEffect(() => {
const validTurnKeys = new Set(
messages.filter((message) => message.role === 'user').map((message) => message.id)
)
validTurnKeys.add(activeTurnKey)
if (isWorking) {
setTimingByTurn((current) => {
let retained = current
for (const turnKey of Object.keys(current)) {
if (!validTurnKeys.has(turnKey)) {
if (retained === current) {
retained = { ...current }
}
delete retained[turnKey]
}
}
const timing = retained[activeTurnKey]
const startedAt =
workingStartedAt ??
(timing?.workedSeconds == null && timing ? timing.startedAt : Date.now())
if (timing?.startedAt === startedAt && timing.workedSeconds == null) {
return retained
}
const next = { ...retained }
next[activeTurnKey] = { startedAt, workedSeconds: null }
return next
})
return
}
setTimingByTurn((current) => {
let retained = current
for (const turnKey of Object.keys(current)) {
if (!validTurnKeys.has(turnKey)) {
if (retained === current) {
retained = { ...current }
}
delete retained[turnKey]
}
}
const timing = retained[activeTurnKey]
if (timing?.workedSeconds != null) {
return retained
}
const startedAt = timing?.startedAt ?? workingStartedAt
if (startedAt == null) {
return retained
}
return {
...retained,
[activeTurnKey]: {
startedAt,
workedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
}
}
})
}, [activeTurnKey, isWorking, messages, workingStartedAt])
const currentTiming = timingByTurn[activeTurnKey]
const completedByTurn = Object.fromEntries(
Object.entries(timingByTurn)
.filter(([, timing]) => timing.workedSeconds != null)
.map(([turnKey, timing]) => [
turnKey,
{ startedAt: timing.startedAt, thinking: false, workedSeconds: timing.workedSeconds }
])
)
return {
active: isWorking
? {
startedAt: workingStartedAt ?? currentTiming?.startedAt ?? null,
thinking: !hasCurrentTurnResponse,
workedSeconds: null
}
: (completedByTurn[activeTurnKey] ?? null),
completedByTurn
}
}
+17 -2
View File
@@ -16621,13 +16621,28 @@
"running": "Running…",
"result": "Result",
"countOne": "1 tool call",
"countN": "{{value0}} tool calls"
"countN": "{{value0}} tool calls",
"runningPreview": "Running {{preview}}",
"runningCommand": "Running command",
"runningNamedPreview": "Running {{toolName}} {{preview}}",
"runningNamed": "Running {{toolName}}",
"ranCommandOneToolSummary": "Ran {{commandCount}} command and used {{toolCount}} tool",
"ranCommandManyToolsSummary": "Ran {{commandCount}} command and used {{toolCount}} tools",
"ranCommandsOneToolSummary": "Ran {{commandCount}} commands and used {{toolCount}} tool",
"ranCommandsManyToolsSummary": "Ran {{commandCount}} commands and used {{toolCount}} tools",
"usedOneSummary": "Used 1 tool",
"usedManySummary": "Used {{toolCount}} tools"
},
"providerFrame": {
"byteLength": "{{value0}} bytes"
},
"status": {
"responding": "Agent is responding"
"responding": "Agent is responding",
"working": "Working…",
"thinking": "Thinking",
"workingFor": "Working for {{value0}} seconds",
"workedFor": "Worked for {{value0}} seconds",
"toggleDetails": "Toggle turn details"
},
"jumpToLatest": "Jump to latest",
"toggle": {
+2
View File
@@ -51,6 +51,8 @@ export type NativeChatToolCallBlock = {
type: 'tool-call'
name: string
input: unknown
/** Provider lifecycle when the structured app-server path can supply it. */
state?: 'running' | 'completed' | 'failed'
}
/** The result returned to the agent for a prior tool call. */
@@ -81,4 +81,19 @@ describe('structured agent session status projection', () => {
})
])
})
it('preserves structured tool lifecycle state for the live renderer', () => {
const projected = projectStructuredItemToNativeChat(
item('running-tool', 1, {
kind: 'tool-call',
name: 'shell',
input: { command: 'cat package.json' },
state: 'running'
})
)
expect(projected?.blocks).toEqual([
{ type: 'tool-call', name: 'shell', input: { command: 'cat package.json' }, state: 'running' }
])
})
})
@@ -18,7 +18,7 @@ function itemBlocks(item: AgentJournalRenderItem): {
return {
role: 'assistant',
blocks: [
{ type: 'tool-call', name: body.name, input: body.input },
{ type: 'tool-call', name: body.name, input: body.input, state: body.state },
...(body.output
? [
{