mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
co-authored by
Merge Sim
parent
8ac1c6e2ac
commit
59facfb71e
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user