diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx
index 50dd54d8dc1..9695317f3af 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx
@@ -2,7 +2,7 @@
import '@testing-library/jest-dom/vitest'
-import { cleanup, render, screen } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { NativeChatMessageList } from './NativeChatMessageList'
@@ -49,4 +49,336 @@ describe('NativeChatMessageList assistant messages', () => {
expect(controls).not.toHaveClass('absolute')
expect(prose.compareDocumentPosition(controls!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
})
+
+ it('keeps a running tool live when transcript lifecycle metadata is absent', () => {
+ render(
+
- {frame.payload.head}
- {frame.payload.truncated ? '\n…' : ''}
-
-
{name}
@@ -71,8 +118,7 @@ function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | nu
) : null}
{hasDetail ? (
- // Chevron sits on the right; hidden until hover when collapsed, always
- // shown (pointing down) when expanded — mirrors Codex's disclosure affordance.
+ // Chevron stays hidden until this row is expanded.
setOpen(expandSignal), [expandSignal])
+ useEffect(() => setOpen(expandOverride ?? expandSignal), [expandOverride, expandSignal])
const callCount = countToolCalls(blocks) || blocks.length
const summary = summarizeToolRun(blocks)
+ const calls = blocks.filter(isToolCallBlock)
+ const activeCalls = structuredActivityUi
+ ? calls.filter(
+ (call) =>
+ (call.state === 'running' || (call.state == null && activeTurnIsWorking === true)) &&
+ activeTurnIsWorking !== false
+ )
+ : []
+ const latestActiveCall = activeCalls.at(-1)
+ const isSettled = latestActiveCall == null
+ // The turn caret opens the activity group, while each child tool remains
+ // collapsed. The global expand toolbar still opens child details together.
+ const expandToolLines = expandOverride === undefined ? open : false
+ const ActiveToolIcon =
+ latestActiveCall && COMMAND_TOOL_NAMES.has(normalizedToolName(latestActiveCall.name))
+ ? SquareTerminal
+ : Wrench
const fallbackLabel =
callCount === 1
? translate('components.native-chat.tool.countOne', '1 tool call')
@@ -129,35 +200,87 @@ export function NativeChatToolRun({
value0: callCount
})
+ // Completed turn activity belongs behind the turn-status disclosure. Keeping
+ // the grouped row visible here made a failed child command look like the
+ // whole response was still running (or had failed) even while collapsed.
+ if (
+ structuredActivityUi &&
+ expandOverride === false &&
+ isSettled &&
+ activeTurnIsWorking === false
+ ) {
+ return null
+ }
+
return (
// Extra top margin sets the tool run apart from the assistant prose above it
// so the turn's activity doesn't crowd the message text.
-
+ {latestActiveCall ? (
+
+ ) : (
+
+ )}
{open ? (
- {blocks.map((block, i) => (
-
- ))}
+ {(() => {
+ const seen = new Map()
+ return blocks.map((block) => {
+ const signature =
+ block.type === 'tool-call'
+ ? `${block.type}:${block.name}:${JSON.stringify(block.input)}`
+ : block.type === 'tool-result'
+ ? `${block.type}:${block.output}`
+ : `${block.type}`
+ const occurrence = seen.get(signature) ?? 0
+ seen.set(signature, occurrence + 1)
+ return (
+
+ )
+ })
+ })()}
) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx
new file mode 100644
index 00000000000..206f0f8df5e
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx
@@ -0,0 +1,98 @@
+import { ArrowUp, Image as ImageIcon } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import { basename } from '@/lib/path'
+import type { NativeChatBlock } from '../../../../shared/native-chat-types'
+import { isNativeChatPastedImagePath } from './native-chat-image-paste'
+import { NativeChatCopyButton } from './NativeChatCopyButton'
+import { nativeChatProviderFrameSummary } from '../../../../shared/native-chat-provider-frame-summary'
+
+export function NativeChatImageAttachments({
+ blocks
+}: {
+ blocks: NativeChatBlock[]
+}): React.JSX.Element | null {
+ const images = blocks.filter((block) => block.type === 'image-ref')
+ if (images.length === 0) {
+ return null
+ }
+ return (
+
+ {images.map((image, index) => {
+ const label = image.alt ?? image.path ?? image.url ?? 'Image'
+ const name =
+ image.path && isNativeChatPastedImagePath(image.path)
+ ? translate('components.native-chat.composer.pastedImageLabel', 'Pasted image')
+ : image.path
+ ? basename(image.path)
+ : label
+ return (
+
+
+ {name}
+
+ )
+ })}
+
+ )
+}
+
+export function NativeChatAgentControls({
+ markdown,
+ onScrollToTop,
+ className
+}: {
+ markdown: string
+ onScrollToTop: () => void
+ className?: string
+}): React.JSX.Element {
+ return (
+
+
+
+
+ )
+}
+
+export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.JSX.Element | null {
+ if (block.type !== 'text' || !block.providerFrame) {
+ return null
+ }
+ const frame = block.providerFrame
+ return (
+
+
+ ›
+ {frame.provider}
+ {nativeChatProviderFrameSummary(block)}
+ {frame.payload.truncated ? (
+
+ ·{' '}
+ {translate('components.native-chat.providerFrame.byteLength', '{{value0}} bytes', {
+ value0: frame.payload.byteLength
+ })}
+
+ ) : null}
+
+
+ {frame.payload.head}
+ {frame.payload.truncated ? '\n…' : ''}
+
+
+ )
+}
diff --git a/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx
new file mode 100644
index 00000000000..5aef81ba51d
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useState } from 'react'
+import { ChevronRight } from 'lucide-react'
+import { translate } from '@/i18n/i18n'
+
+export function NativeChatWorkingStatus({
+ startedAt,
+ thinking,
+ workedSeconds,
+ expanded = false,
+ onToggleExpanded
+}: {
+ startedAt: number | null
+ thinking: boolean
+ workedSeconds?: number | null
+ expanded?: boolean
+ onToggleExpanded?: () => void
+}): React.JSX.Element {
+ const [elapsedSeconds, setElapsedSeconds] = useState(0)
+
+ useEffect(() => {
+ if (thinking || workedSeconds != null) {
+ return
+ }
+ const epoch = startedAt ?? Date.now()
+ setElapsedSeconds(Math.max(0, Math.floor((Date.now() - epoch) / 1000)))
+ const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - epoch) / 1000)))
+ const timer = window.setInterval(update, 1000)
+ return () => window.clearInterval(timer)
+ }, [startedAt, thinking, workedSeconds])
+
+ const label =
+ workedSeconds != null
+ ? translate('components.native-chat.status.workedFor', 'Worked for {{value0}} seconds', {
+ value0: workedSeconds
+ })
+ : thinking
+ ? translate('components.native-chat.status.thinking', 'Thinking')
+ : translate('components.native-chat.status.workingFor', 'Working for {{value0}} seconds', {
+ value0: elapsedSeconds
+ })
+
+ const className = `flex min-h-8 items-center gap-1 text-sm text-muted-foreground${thinking ? '' : ' border-b border-border'}`
+ const caret =
+ workedSeconds != null ? (
+
+ ) : null
+ if (workedSeconds != null && onToggleExpanded) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {label}
+ {caret}
+
+ )
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-prose.ts b/src/renderer/src/components/native-chat/native-chat-prose.ts
new file mode 100644
index 00000000000..68242e48e5b
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-prose.ts
@@ -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')
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts
index db572e57364..b53b217eb15 100644
--- a/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts
@@ -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)
})
diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts
index a3574e1842c..144560278a2 100644
--- a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts
+++ b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts
@@ -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
}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts
index d56fb1cbe50..59c4b80e389 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts
@@ -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. */
diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts
new file mode 100644
index 00000000000..3c4d563dc2b
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts
@@ -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>
+} {
+ 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>({})
+
+ 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
+ }
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 4fad82e2788..c193fa2be1d 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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": {
diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts
index 370037c4bb2..5daa16f760e 100644
--- a/src/shared/native-chat-types.ts
+++ b/src/shared/native-chat-types.ts
@@ -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. */
diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts
index bdd4b4c8f07..048051e70a5 100644
--- a/src/shared/structured-agent-session-projection.test.ts
+++ b/src/shared/structured-agent-session-projection.test.ts
@@ -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' }
+ ])
+ })
})
diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts
index 27a7cd2458f..71cffa43762 100644
--- a/src/shared/structured-agent-session-projection.ts
+++ b/src/shared/structured-agent-session-projection.ts
@@ -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
? [
{