fix(native-chat): render the terminal-backed chat through the structured chat's turn status (#22090)

* fix(native-chat): render the terminal-backed chat through the structured chat's turn status

A terminal-backed chat (grok and omp always; Claude and Codex whenever the
structured lane is refused) could not say what its agent was doing. A
working agent drew three bouncing dots with no elapsed time, and an agent
stopped on a prompt that only its terminal showed drew nothing, so it looked
idle. A pending AskUserQuestion row read "Asked:" while the agent was still
waiting, because the pane treated the wait as the end of the turn.

The pane already held the facts: the host-stamped working epoch and the
hook's waiting/blocked state. It now feeds them to the same message list
props and components the structured lane uses, instead of opting out with
showTurnStatus={false}:

- The turn runs on while the agent waits on the reader, as a structured turn
  does behind its prompt card, so the "Working for" bar keeps counting and
  the live line yields to the wait.
- A wait the pane can draw as a card (approval or question) is shown by the
  card; a wait only the terminal shows is reported by the tail row the
  structured lane already uses for a pending question
  (NativeChatAwaitingInputRow).
- The hook wait is reconciled against the transcript's terminal marker, like
  hook 'working' is, because an interrupt at the prompt fires no hook.

showTurnStatus meant nothing once both lanes passed it, so it is gone, along
with showLiveTurnActivity (replaced by the awaitingInput fact), the
structuredActivityUi flag it fed to tool rows, and the transcript-guessing
typing indicator (native-chat-typing-indicator.ts,
NativeChatTypingIndicatorRow.tsx), which had no other consumer.

The interactive card's derivation moved into
useNativeChatInteractivePromptCard so the view can tell a wait the card
answers from one it cannot.

* test(native-chat): seed the hook wait through the store instead of a cast

* fix(native-chat): say "Awaiting user input" as a whole phrase when no question is named

A wait the pane cannot draw as a card, and a question whose text could not be
parsed, both drew "Awaiting user input:" with nothing after the colon. The row
now reads "Awaiting user input" when it has no question to name, in both chat
lanes. The pending-question view test now counts awaiting rows by attribute so
a second, unnamed row cannot slip past it.

* perf(native-chat): read the prompt's tool name only while a prompt is pending

Deriving the prompt card moved from the card into the pane's view, and with it
a store read of the status row's tool name, which changes on every tool call.
Every tool call therefore re-rendered the whole terminal-backed pane and its
transcript list, where before only the card re-rendered. The tool name only
matters beside a prompt, so read it only then.

* fix(native-chat): keep the terminal lane's turn clock and folds across a remount

Answering a prompt only the terminal shows means switching the pane to its
terminal, which unmounts the chat. On return the "Working for" clock restarted
from the agent's current state (seconds after the answer), and every turn the
pane had folded behind "Worked for N" came back unfolded with no bar.

- The running turn now counts from the start of the hook's unbroken run of
  mid-turn states under the current prompt (read from the status row's state
  history), not the current state's own start.
- Finished turns take their duration from the transcript's own timestamps
  (prompt to the agent's last row or its interruption), so history turns fold
  like a structured chat's. The latest turn still relies on what the pane saw.

* fix(native-chat): date the terminal lane's turns by the main agent and skip harness notices

Two ways the terminal-backed chat showed a wrong turn status:

- A background task or subagent keeps the status row 'working' after the
  main agent finishes, so the row's own start time carries into the next
  turn. A prompt sent while a background dev server ran showed "Working for"
  counted from the earlier turn. The clock now reads the main agent's own
  state and start time where the row carries them.
- Harness notices (task notifications, reminders) are user-role rows in the
  transcript that the chat does not draw. The finished-turn derivation
  treated them as new prompts, so a running turn was reported settled and
  folded its steps while it still ran, and a finished turn's "Worked for"
  stopped at the notice. They no longer start or end a turn.

* feat(agent-status): stamp each turn's start on the host

The hook server now records turnStartedAt on a status row when the main
agent's own turn-opening event arrives (the per-provider new-turn
classifier the observation boundary already uses; not a replay, a child
event or an identity-only row). Other events carry it; a session
boundary clears it, and a settled main agent running again with no
opening event drops it rather than count from the finished turn.

It rides the snapshot and the live push as an optional field, persists
with the row, lands on the renderer entry (kept within one state for
writers with no turn clock), and is part of the paired-client
projection key and equality. Old hosts send nothing; readers fall back
to stateStartedAt.

* fix(native-chat): time the terminal lane's turns from the host's turn stamp

The running clock reads the host's turnStartedAt, so a remount, a reload
(which starts with no state history) and child work holding the row open
no longer move it, and the same prompt sent again is a new turn. The
stateHistory walk and its prompt-equality rule are gone; an old host
falls back to the current state's start.

The latest turn is settled from host facts: once the main agent is done
(outside a session boundary) its duration is the done stamp minus the
turn start, so it folds after a remount. When the host went quiet
mid-turn (the store's staleness dropped the row) the pane passes null,
so a still-waiting turn no longer reads "Worked for 30m" and folds.
Older turns keep their transcript durations.

* test(agent-status): pin the host's turn start on the live status push

* fix(native-chat): keep omp's turn working while its latest row is a tool call

The terminal-backed chat settles a hook 'working' turn when the transcript's
last row is an assistant row written after the turn began, a recovery for a
Stop hook the host can miss. omp writes a timestamp on every row and has no
transcript turn markers, so every tool call mid-turn tripped it: the pane
dropped its "Working for" bar and showed Send instead of Stop while the
command ran.

omp's own runtime delivers its turn end (agent_end, retried until the host
takes it and held back while the run continues), so its hook 'working' is
never a dropped Stop. The prose recovery now skips it.

* test(native-chat): read the clock once in the host-ended remount test

The test stamped the row's done time and the turn start from two separate
Date.now() reads, so whenever a millisecond passed between them the expected
"Worked for 1m 30s" came out as 1m 29s. It failed that way once under load.

* test(sync-runtime-graph): carry the turn stamp in the hot-path projection reference

The hot-path suite compares the agent-status projection against its own copy of
the serializer; that copy lacked the new turnStartedAt key.
This commit is contained in:
Brennan Benson
2026-09-29 01:30:54 -07:00
committed by GitHub
parent 6e1b7e7fa3
commit 4da485cac1
65 changed files with 1593 additions and 398 deletions
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHookServer, _internals } from './server'
import { buildBody, postHookEvent, PANE } from './server.test-fixtures'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
trackMock: vi.fn()
}))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
beforeEach(() => {
_internals.resetCachesForTests()
getCohortAtEmitMock.mockReturnValue({})
})
afterEach(() => {
vi.restoreAllMocks()
})
// Distinct host stamps: each event lands on a later millisecond than the one before it.
async function nextMillisecond(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 5))
}
// The execution host stamps when the main agent's turn began, so every reader (and a reload, which
// starts the renderer with no state history) gets the turn start rather than rebuilding it.
describe('the host-stamped turn start', () => {
const servers: AgentHookServer[] = []
let userDataPath: string
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-turn-started-at-'))
})
afterEach(() => {
for (const server of servers) {
server.stop()
}
servers.length = 0
rmSync(userDataPath, { recursive: true, force: true })
})
async function startServer(): Promise<AgentHookServer> {
const server = new AgentHookServer()
servers.push(server)
await server.start({ env: 'production', userDataPath })
return server
}
async function post(server: AgentHookServer, payload: Record<string, unknown>): Promise<void> {
const response = await postHookEvent(server, buildBody(payload))
expect(response.status).toBe(204)
await nextMillisecond()
}
function turnStartedAt(server: AgentHookServer): number | undefined {
return server.getStatusSnapshot()[0]?.turnStartedAt
}
it('stamps a new main-agent turn and carries it across tools, a wait and its answer', async () => {
const server = await startServer()
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' })
const started = turnStartedAt(server)
expect(started).toBe(server.getStatusSnapshot()[0]?.stateStartedAt)
const bash = { tool_name: 'Bash', tool_input: { command: 'pnpm build' }, tool_use_id: 'tu-1' }
await post(server, { hook_event_name: 'PreToolUse', ...bash })
await post(server, { hook_event_name: 'PermissionRequest', ...bash })
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting' })
await post(server, { hook_event_name: 'PostToolUse', ...bash })
// The answer restarted the state clock, not the turn's.
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
expect(server.getStatusSnapshot()[0]?.stateStartedAt).toBeGreaterThan(started!)
expect(turnStartedAt(server)).toBe(started)
await post(server, { hook_event_name: 'Stop' })
// Kept on the finished turn, so a reader can time it from the host's own facts.
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', turnStartedAt: started })
})
it('is not stamped by a child agent, and the same prompt sent again is a new turn', async () => {
const server = await startServer()
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' })
const started = turnStartedAt(server)
await post(server, {
hook_event_name: 'UserPromptSubmit',
prompt: 'child task',
agent_id: 'child-1'
})
expect(turnStartedAt(server)).toBe(started)
// Esc at a permission prompt fires no hook, so the resend follows the stale wait directly.
await post(server, { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: {} })
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' })
expect(turnStartedAt(server)).toBeGreaterThan(started!)
})
it('is not stamped by a relayed replay', async () => {
const server = await startServer()
const envelope = {
paneKey: PANE,
tabId: 'tab-1',
source: 'claude' as const,
hookEventName: 'UserPromptSubmit',
payload: { state: 'working' as const, prompt: 'relayed', agentType: 'claude' as const }
}
server.ingestRemote(envelope, 'ssh-turn')
const started = turnStartedAt(server)
expect(started).toEqual(expect.any(Number))
await nextMillisecond()
server.ingestRemote({ ...envelope, isReplay: true }, 'ssh-turn')
expect(turnStartedAt(server)).toBe(started)
})
// A session start lands an idle boundary row: no turn is open until the next prompt.
it('clears on a session boundary', async () => {
const server = await startServer()
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' })
expect(turnStartedAt(server)).toEqual(expect.any(Number))
await post(server, { hook_event_name: 'SessionStart', source: 'clear' })
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true })
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('turnStartedAt')
})
// An OSC repaint can land before the prompt hook; it must not count from the finished turn.
it('drops the stamp when a finished main agent runs again with no turn-opening event', async () => {
const server = await startServer()
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' })
await post(server, { hook_event_name: 'Stop' })
expect(turnStartedAt(server)).toEqual(expect.any(Number))
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'Rename the module', agentType: 'claude' }
})
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('turnStartedAt')
})
it('cannot be declared by a producer', async () => {
const server = await startServer()
await post(server, {
hook_event_name: 'UserPromptSubmit',
prompt: 'Rename the module',
turnStartedAt: 1
})
await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Bash', turnStartedAt: 1 })
expect(turnStartedAt(server)).toBeGreaterThan(1)
})
it('reaches the live push and survives a restart through the status file', async () => {
const server = await startServer()
const pushed: (number | undefined)[] = []
server.setListener((status) => pushed.push(status.turnStartedAt))
await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' })
await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {} })
const started = turnStartedAt(server)
expect(pushed.at(-1)).toBe(started)
server.flushStatusPersistSync()
server.stop()
const file = JSON.parse(
readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8')
)
expect(file.entries[PANE].turnStartedAt).toBe(started)
const restarted = await startServer()
expect(restarted.getStatusSnapshot()[0]).toMatchObject({
restoredUnconfirmed: true,
turnStartedAt: started
})
// The turn goes on after the restart: the next event carries the restored stamp.
await post(restarted, { hook_event_name: 'PostToolUse', tool_name: 'Bash', tool_input: {} })
expect(turnStartedAt(restarted)).toBe(started)
})
})
@@ -149,6 +149,7 @@ export function sanitizeHydratedEntry(
source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto')
? record.compactTrigger
: undefined
const turnStartedAt = record.turnStartedAt
return {
paneKey,
source,
@@ -170,7 +171,10 @@ export function sanitizeHydratedEntry(
retainedForLiveness: retainedForLiveness ? true : undefined,
payload,
receivedAt,
stateStartedAt
stateStartedAt,
...(typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0
? { turnStartedAt }
: {})
}
}
@@ -10,6 +10,7 @@ import type {
AgentStatusObservationOrigin
} from '../../../shared/agent-status-observation'
import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter'
import type { AgentStatusState, ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
import { admitLegacyAgentStatus } from '../../../shared/agent-hook-listener/listener-state'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { agentTypeToPromptSentAgentKind } from './server-status-identity'
@@ -18,6 +19,41 @@ import { AgentHookServerStatusDisposition } from './server-status-disposition'
/** Bounds the retained observation clock; eviction only degrades a replay to `now`. */
const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024
function mainAgentState(payload: ParsedAgentStatusPayload): AgentStatusState {
return payload.mainAgent?.state ?? payload.state
}
/**
* When the main agent's current turn began. Its own turn-opening event stamps it (the same
* classifier the observation boundary uses), except a session boundary, which lands idle with no
* turn open. Replays, child events and identity-only rows restate a turn rather than open one.
*/
function resolveTurnStartedAt(
previous: EnrichedAgentHookEventPayload | undefined,
payload: AgentHookEventPayload,
at: number
): number | undefined {
if (
payload.source !== undefined &&
payload.toolAgentId === undefined &&
payload.isReplay !== true &&
payload.providerSessionOnly !== true &&
isNewTurnEvent(payload.source, payload.hookEventName)
) {
return payload.payload.sessionBoundary === true ? undefined : at
}
// Why: a settled main agent running again with no opening event (an OSC repaint racing the
// prompt hook) is a turn edge nothing dated; carrying the old stamp would count from that turn.
if (
previous &&
mainAgentState(previous.payload) === 'done' &&
mainAgentState(payload.payload) !== 'done'
) {
return undefined
}
return previous?.turnStartedAt
}
export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition {
protected refreshTerminalStatusEvidence(
previous: EnrichedAgentHookEventPayload,
@@ -113,12 +149,15 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn
? previous.stateStartedAt
: (observedAt ?? now)
const turnStartedAt = resolveTurnStartedAt(previous, payload, observedAt ?? now)
// Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival.
return {
...payload,
receivedAt: now,
evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now),
stateStartedAt
stateStartedAt,
// Always written here, so a producer can never declare it.
turnStartedAt
}
}
@@ -63,6 +63,7 @@ export function toAgentStatusIpcPayload(
? { evidenceObservedAt: entry.evidenceObservedAt }
: {}),
stateStartedAt: entry.stateStartedAt,
...(entry.turnStartedAt !== undefined ? { turnStartedAt: entry.turnStartedAt } : {}),
...(entry.providerSession ? { providerSession: entry.providerSession } : {}),
...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}),
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
@@ -19,6 +19,9 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
* main restart; absent means "never separately observed" and consumers use `receivedAt`. */
evidenceObservedAt?: number
stateStartedAt: number
/** When the main agent's current turn began, on this server's clock. Stamped only here, from the
* main agent's own turn-opening event; absent when no such event was seen. */
turnStartedAt?: number
/** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */
observation?: AgentStatusObservation
/** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */
@@ -35,6 +35,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
receivedAt,
evidenceObservedAt,
stateStartedAt,
turnStartedAt,
launchToken,
providerSession,
providerSessionOnly,
@@ -89,6 +90,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
receivedAt,
...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}),
stateStartedAt,
...(turnStartedAt !== undefined ? { turnStartedAt } : {}),
...(providerSession ? { providerSession } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
@@ -94,3 +94,11 @@ it('forwards retirement acknowledgement only on live status delivery', () => {
expect(sent[0].event).toHaveProperty('authorityRestartId', 'retirement-id')
expect(sent[1].event).not.toHaveProperty('authorityRestartId')
})
// The live push picks fields one by one; the host's turn start must be one of them.
it("forwards the host's turn start, and nothing when the host stamped none", () => {
hooks.listener!(statusPayload({ turnStartedAt: 1 }))
hooks.listener!(statusPayload({}))
expect(sent[0].event).toHaveProperty('turnStartedAt', 1)
expect(sent[1].event).not.toHaveProperty('turnStartedAt')
})
@@ -72,9 +72,15 @@ export function NativeChatAwaitingInputRow({
const questions = subject?.kind === 'questions' && listsQuestions ? subject.questions : null
// A count always hides its questions, so the list needs no clipping measurement.
const toggles = questions !== null || (question !== null && (open || clipped))
const label = pending
? translate('components.native-chat.ask.awaiting', NATIVE_CHAT_ASK_ROW_COPY.awaiting)
: translate('components.native-chat.ask.asked', NATIVE_CHAT_ASK_ROW_COPY.asked)
// With nothing to name, the label must read as a whole phrase, not end on a colon.
const label = !pending
? translate('components.native-chat.ask.asked', NATIVE_CHAT_ASK_ROW_COPY.asked)
: subject === null
? translate(
'components.native-chat.ask.awaitingUnnamed',
NATIVE_CHAT_ASK_ROW_COPY.awaitingUnnamed
)
: translate('components.native-chat.ask.awaiting', NATIVE_CHAT_ASK_ROW_COPY.awaiting)
const text =
subject === null
? null
@@ -33,6 +33,7 @@ vi.mock('../../store', () => ({
}))
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card'
const mocks = {
sendAnswer: vi.fn<NativeChatInteractiveSend['sendAnswer']>(),
@@ -45,6 +46,8 @@ function renderCard(canSend = true): ReturnType<typeof render> {
return render(cardElement(canSend))
}
const NO_MESSAGES: readonly NativeChatMessage[] = []
function cardElement(
canSend = true,
messages?: readonly NativeChatMessage[],
@@ -52,11 +55,36 @@ function cardElement(
transcriptSettled = true
): React.JSX.Element {
return (
<NativeChatInteractiveCard
paneKey="tab-1:leaf-1"
<CardHarness
canSend={canSend}
messages={messages}
onShowingQuestionChange={onShowingQuestionChange}
transcriptSettled={transcriptSettled}
/>
)
}
// The view derives the card and hands it over; this stands in for that view.
function CardHarness({
canSend,
messages,
onShowingQuestionChange,
transcriptSettled
}: {
canSend: boolean
messages?: readonly NativeChatMessage[]
onShowingQuestionChange?: (showing: boolean) => void
transcriptSettled: boolean
}): React.JSX.Element | null {
const card = useNativeChatInteractivePromptCard({
paneKey: 'tab-1:leaf-1',
messages: messages ?? NO_MESSAGES,
transcriptSettled: transcriptSettled && messages !== undefined
})
return (
<NativeChatInteractiveCard
card={card}
canSend={canSend}
onShowingQuestionChange={onShowingQuestionChange}
send={{
sendAnswer: mocks.sendAnswer,
@@ -1,19 +1,16 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '../../store'
import { resolveNativeChatAsk } from '../../../../shared/native-chat-ask'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { parseInteractivePrompt } from './native-chat-interactive-prompt'
import type { InteractivePromptCard } from './native-chat-interactive-prompt'
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send'
/**
* Render the live interactive card for the pane while the agent's
* `interactivePrompt` is present: a question wizard (precedence) or a tool
* approval. Cleared by the host once the agent moves on, so it disappears
* automatically. Sends through the composer's verified runtime path (R8/R6):
* answers via agent-specific paste or selector keystrokes; cancel/deny as ESC.
* Render the pane's interactive card (see useNativeChatInteractivePromptCard): a
* question wizard or a tool approval. Cleared by the host once the agent moves
* on, so it disappears automatically. Sends through the composer's verified
* runtime path (R8/R6): answers via agent-specific paste or selector
* keystrokes; cancel/deny as ESC.
* Guarded by `canSend` so a mobile presence-lock blocks desktop sends too.
*
* Dismiss-on-answer (mobile parity): the live status lingers after answering —
@@ -21,29 +18,17 @@ import type { NativeChatInteractiveSend } from './use-native-chat-interactive-se
* answered prompt by content key and hide the card until a genuinely different
* prompt arrives. The dismissal resets once the prompt clears, so a later
* (even identical) prompt shows again instead of staying hidden.
*
* The transcript is the second source (mobile parity again): a question that the
* live status never delivered — headless host, relay gap, replay, reconnect —
* still has its unresolved tool call in the messages we already parsed. Without
* it the composer stays mounted over a pane parked on a selector, and the next
* send commits the highlighted option instead of the typed message (#11761).
*/
export function NativeChatInteractiveCard({
paneKey,
card,
send,
canSend,
messages,
transcriptSettled,
onShowingQuestionChange,
answerInputRef
}: {
paneKey: string
card: InteractivePromptCard
send: NativeChatInteractiveSend
canSend: boolean
/** Transcript to fall back on when live status carries no prompt. Pass the
* command-boundary-trimmed messages so an ask abandoned via `/clear` stays gone. */
messages?: readonly NativeChatMessage[]
transcriptSettled: boolean
/** Reports whether a question card is on screen so the view can replace the
* composer with it (the card's free-text row is the answer input). */
onShowingQuestionChange?: (showing: boolean) => void
@@ -51,26 +36,7 @@ export function NativeChatInteractiveCard({
* a target while the composer is unmounted. */
answerInputRef?: React.RefObject<HTMLInputElement | null>
}): React.JSX.Element | null {
const interactivePrompt = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null
)
// Thread the sibling `toolName` from the same status entry so the question
// parser can dispatch through the tool's registered parser (mobile parity).
const interactiveToolName = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null)
const { sendAnswer, sendRaw, cancelPending, cancel } = send
const card = useMemo(() => {
const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined)
if (statusCard?.kind === 'approval') {
return statusCard
}
const prompt = resolveNativeChatAsk({
liveAsk: statusCard?.prompt ?? null,
messages: messages ?? [],
transcriptSettled: transcriptSettled && messages != null
})
return prompt ? { kind: 'question' as const, prompt } : null
}, [interactivePrompt, interactiveToolName, messages, transcriptSettled])
const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card])
const [dismissedKey, setDismissedKey] = useState<string | null>(null)
// A question answer is a paced multi-step write (body→Enter per question); keep
@@ -70,7 +70,6 @@ function transcript(messages: NativeChatMessage[], sessionId = 'live-codex') {
isWorking={false}
expandSignal
fontScale={1}
showTurnStatus={false}
/>
)
}
@@ -44,15 +44,7 @@ function Transcript({ items }: { items: AgentJournalRenderItem[] }) {
loadEarlier,
readPhase: 'ready'
}
return (
<NativeChatMessageList
session={session}
isWorking
expandSignal
fontScale={1}
showTurnStatus={false}
/>
)
return <NativeChatMessageList session={session} isWorking expandSignal fontScale={1} />
}
function row(index: number, body: AgentJournalRenderItem['body']): AgentJournalRenderItem {
@@ -10,9 +10,8 @@ import { nativeChatTaskListPredecessors } from './native-chat-task-list-history'
import { NativeChatTaskList } from './NativeChatTaskList'
import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames'
import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows'
import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator'
import { useNativeChatTurnStatus } from './use-native-chat-turn-status'
import { NativeChatTypingIndicatorRow } from './NativeChatTypingIndicatorRow'
import { NativeChatAwaitingInputRow } from './NativeChatAwaitingInputRow'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity'
import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine'
@@ -59,6 +58,10 @@ export { ProviderFrameRow } from './NativeChatTranscriptChrome'
const MAX_EXPANDED_TURNS = 128
/** The turn is blocked on the reader. `shown`: the pane draws the prompt itself, as a card;
* `unshown`: it cannot (the prompt is only in the agent's terminal). */
export type NativeChatAwaitingInput = 'shown' | 'unshown'
type NativeChatNavigationRequest =
| { kind: 'diff'; target: NativeChatDiffReveal }
| { kind: 'rail'; messageId: string; requestId: number }
@@ -78,8 +81,7 @@ export function NativeChatMessageList({
activeTurnOpenedBy,
turnKeysByItemId = null,
deliveryNotices,
showTurnStatus = true,
showLiveTurnActivity = true,
awaitingInput = null,
turnActivity,
runtimeContext
}: {
@@ -94,7 +96,8 @@ export function NativeChatMessageList({
/** Chat-only text multiplier (1 = default), driven by the zoom shortcuts. */
fontScale: number
workingStartedAt?: number | null
/** Host-recorded turn durations keyed by user message id (structured lane). */
/** Recorded turn durations keyed by user message id (the host's, or the transcript's).
* A turn missing here shows the duration this list observed, if it saw the turn run. */
settledTurns?: NativeChatSettledTurns
/** The key the host says anchors the running turn's bar (structured lane). */
activeTurnOpenedBy?: string | null
@@ -104,10 +107,8 @@ export function NativeChatMessageList({
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
deliveryNotices?: ReadonlyMap<string, NativeChatDeliveryNotice>
/** Turn timing and disclosure are available on structured agent sessions. */
showTurnStatus?: boolean
/** Whether the active turn's foreground activity row should be visible. */
showLiveTurnActivity?: boolean
/** Set while the turn waits on the reader; the live activity line yields to it. */
awaitingInput?: NativeChatAwaitingInput | null
turnActivity?: NativeChatTurnActivity | null
runtimeContext?: RuntimeFileOperationArgs | null
}): React.JSX.Element {
@@ -162,14 +163,11 @@ export function NativeChatMessageList({
const subagentLabels = useMemo(() => nativeChatSubagentLabels(messages), [messages])
const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages])
const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages])
const showTypingIndicator = showTurnStatus
? isWorking
: shouldShowNativeChatTypingIndicator({ messages, isWorking })
// Resolve each row's owning turn once. Prefix slice/findLast in the render
// loop becomes quadratic for long transcripts.
const turnKeys = useMemo(
() => nativeChatRowTurnKeys(messages, showTurnStatus ? turnKeysByItemId : null),
[messages, showTurnStatus, turnKeysByItemId]
() => nativeChatRowTurnKeys(messages, turnKeysByItemId),
[messages, turnKeysByItemId]
)
const turnDiffs = useMemo(
() =>
@@ -188,11 +186,19 @@ export function NativeChatMessageList({
const turnStatuses = useNativeChatTurnStatus({
messages,
activeTurnKey,
isWorking: showTurnStatus && isWorking,
workingStartedAt: showTurnStatus ? workingStartedAt : null,
settledTurns: showTurnStatus ? settledTurns : null,
isWorking,
workingStartedAt,
settledTurns,
thinking
})
// The transcript tail: what the running turn is doing, or that it waits on a
// prompt nothing else on screen shows. A prompt card says so itself.
const tailRow =
awaitingInput === 'unshown'
? 'awaiting-input'
: isWorking && awaitingInput === null
? 'activity'
: null
const lifecycleWorking = session.transcriptLifecycle?.state === 'working'
const slots = useMemo(
() =>
@@ -203,7 +209,6 @@ export function NativeChatMessageList({
receipts,
turnStatuses,
turnDiffs,
showTurnStatus,
expandedTurnKeys: expandedTurnIds,
isWorking,
lifecycleWorking,
@@ -216,7 +221,6 @@ export function NativeChatMessageList({
lifecycleWorking,
messages,
receipts,
showTurnStatus,
subagentLabels,
turnDiffs,
turnKeys,
@@ -236,7 +240,7 @@ export function NativeChatMessageList({
contentRef,
itemCount: slots.length,
isWorking,
showTypingIndicator,
showsTailRow: tailRow !== null,
isVisible,
alignToViewportTop: transcriptWindow.alignToViewportTop,
scrollToEnd: transcriptWindow.scrollToEnd,
@@ -331,7 +335,6 @@ export function NativeChatMessageList({
const rowContext = useMemo<NativeChatTranscriptRowContext>(
() => ({
expandSignal,
showTurnStatus,
revealedDiff,
taskListPredecessors,
expandedTurnIds,
@@ -353,7 +356,6 @@ export function NativeChatMessageList({
revealedDiff,
runtimeContext,
scrollMessageToTop,
showTurnStatus,
taskListPredecessors,
toggleExpandedTurn
]
@@ -398,13 +400,14 @@ export function NativeChatMessageList({
context={rowContext}
window={transcriptWindow}
/>
{showTurnStatus && showLiveTurnActivity && isWorking ? (
{tailRow === 'activity' ? (
<NativeChatTurnActivityLine
activity={turnActivity}
thinking={turnStatuses.active?.thinking === true}
/>
) : tailRow === 'awaiting-input' ? (
<NativeChatAwaitingInputRow subject={null} pending />
) : null}
{!showTurnStatus && showTypingIndicator ? <NativeChatTypingIndicatorRow /> : null}
</div>
</div>
</div>
@@ -166,7 +166,7 @@ describe('NativeChatMessageList turn indicator', () => {
}}
journalItems={[journalItem(1, turnItem), journalItem(2, reasoningRow)]}
isWorking
showLiveTurnActivity={false}
awaitingInput="shown"
expandSignal={false}
fontScale={1}
/>
@@ -178,6 +178,38 @@ describe('NativeChatMessageList turn indicator', () => {
expect(screen.getByText('Running 1 command')).toHaveClass('animate-pulse')
})
// A terminal-backed pane can learn its agent is waiting without being handed
// the prompt; the tail then says so, in the row a question uses.
it('says the turn waits on the reader when no card shows the prompt', () => {
const { container } = render(
<NativeChatMessageList
session={{
...session,
messages: [
{
id: 'user-wait',
role: 'user',
blocks: [{ type: 'text', text: 'Deploy it' }],
timestamp: 1,
source: 'transcript'
}
]
}}
isWorking
workingStartedAt={Date.now() - 5000}
awaitingInput="unshown"
expandSignal={false}
fontScale={1}
/>
)
expect(container.querySelector('[data-native-chat-ask-row="awaiting"]')).toHaveTextContent(
/^Awaiting user input$/
)
expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull()
expect(screen.getByText('Working for 5s')).toBeInTheDocument()
})
it('keeps the live row up after a tool settles', () => {
render(
<NativeChatMessageList
@@ -286,42 +318,6 @@ describe('NativeChatMessageList turn indicator', () => {
expect(liveRun?.querySelector('.lucide-check')).toBeInTheDocument()
})
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('replaces a bridge ask row and settles it from the FIFO tool result', () => {
const user = {
id: 'bridge-user',
@@ -355,7 +351,6 @@ describe('NativeChatMessageList turn indicator', () => {
isWorking={false}
expandSignal={false}
fontScale={1}
showTurnStatus={false}
/>
)
@@ -382,7 +377,6 @@ describe('NativeChatMessageList turn indicator', () => {
isWorking={false}
expandSignal={false}
fontScale={1}
showTurnStatus={false}
/>
)
@@ -47,7 +47,6 @@ export const MessageRow = memo(function MessageRow({
onLinkClick,
allowFileUriLinks = false,
deliveryNotice,
structuredActivityUi = true,
folded = false,
subagentLabel,
runtimeContext
@@ -65,7 +64,6 @@ export const MessageRow = memo(function MessageRow({
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
deliveryNotice?: NativeChatDeliveryNotice
structuredActivityUi?: boolean
/** Behind a folded turn: the row keeps only what outlives the turn. */
folded?: boolean
/** The roster's name for the subagent that wrote this row, when one names it. */
@@ -269,7 +267,6 @@ export const MessageRow = memo(function MessageRow({
expandSignal={expandSignal}
activeTurnIsWorking={activeTurnIsWorking}
trailing={trailingRun}
structuredActivityUi={structuredActivityUi}
disclosureId={message.id}
/>
) : null}
@@ -11,12 +11,12 @@ import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatC
import { useNativeChatFontScale } from './use-native-chat-font-scale'
import { useNativeChatCanSend } from './use-native-chat-can-send'
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { useNativeChatInteractivePromptCard } from './use-native-chat-interactive-prompt-card'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send'
import {
shouldClearNativeChatWorkingSuppression,
shouldShowNativeChatWorking
} from './native-chat-working-suppression'
import { shouldClearNativeChatWorkingSuppression } from './native-chat-working-suppression'
import { resolveNativeChatTerminalTurn } from './native-chat-terminal-turn'
import { useNativeChatTerminalTurnTiming } from './use-native-chat-terminal-turn-timing'
import {
appendPendingSendCache,
launchPromptAsMessage,
@@ -251,6 +251,11 @@ export function NativeChatResolvedView({
paneLaunchPrompt?.failed ? launchPromptMessage?.id : null,
sessionAfterCommandBoundaries.messages
)
const promptCard = useNativeChatInteractivePromptCard({
paneKey,
messages: sessionAfterCommandBoundaries.messages,
transcriptSettled: session.readPhase === 'ready'
})
// The streaming preview bubble (if any) sits after the transcript but before
// the optimistic user echoes — same order mobile uses.
@@ -313,11 +318,14 @@ export function NativeChatResolvedView({
previousWorkingEpochRef.current = null
}
}, [liveWorking, workingInterrupted, hookWorkingEpoch])
const isWorking = shouldShowNativeChatWorking({
const { isWorking, turnActive, awaitingInput } = resolveNativeChatTerminalTurn({
isConversation,
working: liveWorking,
interrupted: workingInterrupted
hookAwaitingInput: session.hookAwaitingInput === true,
interrupted: workingInterrupted,
hasPromptCard: promptCard !== null
})
const turnTiming = useNativeChatTerminalTurnTiming(paneKey, session.messages, turnActive)
const stopAgent = useCallback(() => {
setWorkingInterrupted(true)
@@ -399,11 +407,11 @@ export function NativeChatResolvedView({
<NativeChatMessageList
session={sessionWithPending}
isVisible={isVisible}
isWorking={isWorking}
isWorking={turnActive}
expandSignal={false}
fontScale={fontScale.scale}
workingStartedAt={hookWorkingEpoch}
showTurnStatus={false}
{...turnTiming}
awaitingInput={awaitingInput}
onLinkClick={onLinkClick}
allowFileUriLinks={fileLinkContext !== null}
deliveryNotices={launchPromptDeliveryNotices}
@@ -414,11 +422,9 @@ export function NativeChatResolvedView({
(mobile parity). A question card supplies its own answer input, so it
fully replaces the composer while active — no stray "Send a message". */}
<NativeChatInteractiveCard
paneKey={paneKey}
card={promptCard}
send={interactiveSend}
canSend={canSend}
messages={sessionAfterCommandBoundaries.messages}
transcriptSettled={session.readPhase === 'ready'}
onShowingQuestionChange={setQuestionActive}
answerInputRef={questionAnswerInputRef}
/>
@@ -0,0 +1,401 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import type { AgentStatusEntry, AgentStatusPayload } from '../../../../shared/agent-status-types'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
// The transcript source and the composer are stubbed; the wire under test is
// store status -> resolved view -> the shared message list and prompt card.
const retained = vi.hoisted((): { session: NativeChatLiveSession | null } => ({ session: null }))
vi.mock('./use-native-chat-retained-session', () => ({
useNativeChatRetainedSession: () => retained.session
}))
vi.mock('./NativeChatComposer', () => ({ NativeChatComposer: () => null }))
const { NativeChatResolvedView } = await import('./NativeChatResolvedView')
const { useAppStore } = await import('../../store')
const { installNativeChatMessageListTestViewport } =
await import('./native-chat-message-list-test-viewport')
const paneKey = 'tab-state:leaf-state'
let restoreViewport = (): void => {}
const userTurn: NativeChatMessage = {
id: 'user-1',
role: 'user',
blocks: [{ type: 'text', text: 'Rename the module' }],
timestamp: 1,
source: 'transcript'
}
const askCall: NativeChatMessage = {
id: 'ask-1',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'AskUserQuestion',
input: {
questions: [
{
question: 'Which name?',
multiSelect: false,
options: [{ label: 'core' }, { label: 'base' }]
}
]
}
}
],
timestamp: 2,
source: 'transcript'
}
const stepCall: NativeChatMessage = {
id: 'step-1',
role: 'assistant',
blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'pnpm build' } }],
timestamp: 3,
source: 'transcript'
}
// `status` and `hookAwaitingInput` are what the live session reconciles from the
// same hook row; the test states both so the view is fed a consistent session.
function transcript(
status: NativeChatLiveSession['status'],
hookAwaitingInput: boolean,
messages: NativeChatMessage[] = [userTurn]
): NativeChatLiveSession {
return {
messages,
status,
sessionId: 'session-state',
agent: 'claude',
hookAwaitingInput,
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}
}
function setStatus(payload: Omit<AgentStatusPayload, 'prompt' | 'agentType'>, age = 0): void {
useAppStore
.getState()
.setAgentStatus(paneKey, { prompt: 'Rename the module', agentType: 'claude', ...payload })
const live = useAppStore.getState().agentStatusByPaneKey[paneKey]
if (!live) {
throw new Error('the store dropped the status row this test depends on')
}
useAppStore.setState((store) => ({
agentStatusByPaneKey: {
...store.agentStatusByPaneKey,
[paneKey]: { ...live, stateStartedAt: Date.now() - age }
}
}))
}
function patchStatus(fields: Partial<AgentStatusEntry>): void {
useAppStore.setState((store) => ({
agentStatusByPaneKey: {
...store.agentStatusByPaneKey,
[paneKey]: { ...store.agentStatusByPaneKey[paneKey]!, ...fields }
}
}))
}
function renderPane(): void {
render(
<NativeChatResolvedView
paneKey={paneKey}
agent="claude"
sessionId="session-state"
transcriptPath={null}
isVisible
isFocusedGroup={false}
targetPtyId="pty-state"
terminalTabId="tab-state"
ownsTabWideLaunchDraft={false}
/>
)
}
function liveActivityLine(): Element | null {
return document.querySelector('[data-native-chat-turn-activity]')
}
beforeEach(() => {
restoreViewport = installNativeChatMessageListTestViewport()
useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} })
})
afterEach(() => {
cleanup()
restoreViewport()
useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} })
})
// A terminal-backed pane renders through the structured chat's own turn-status
// UI: the clock bar under the prompt, the live line at the tail, and the row a
// question uses when the agent waits on the reader.
describe('NativeChatResolvedView turn status', () => {
it('counts the working turn from the host-stamped epoch, with the live line at the tail', () => {
retained.session = transcript('working', false)
setStatus({ state: 'working' }, 75_000)
renderPane()
expect(screen.getByText('Working for 1m 15s')).toBeInTheDocument()
expect(liveActivityLine()).toHaveTextContent('Working…')
expect(document.querySelectorAll('.animate-bounce')).toHaveLength(0)
})
it.each(['waiting', 'blocked'] as const)(
'says a %s agent waits on the reader when only its terminal shows the prompt',
(state) => {
retained.session = transcript('ready', true)
setStatus({ state }, 20_000)
renderPane()
// A whole phrase: there is no question to name after a colon.
expect(screen.getByText('Awaiting user input')).toBeInTheDocument()
expect(liveActivityLine()).toBeNull()
// The turn runs on behind the wait, as a structured turn does behind its card.
expect(screen.getByText('Working for 20s')).toBeInTheDocument()
}
)
it('lets the approval card speak for the wait', () => {
retained.session = transcript('ready', true)
setStatus({
state: 'waiting',
interactivePrompt: JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf dist' } })
})
renderPane()
expect(screen.getByText('Allow Bash?')).toBeInTheDocument()
expect(screen.queryByText(/Awaiting user input/)).toBeNull()
expect(liveActivityLine()).toBeNull()
expect(screen.getByText('Working for 0s')).toBeInTheDocument()
})
it("keeps a pending question's transcript row awaiting while the agent waits", () => {
retained.session = transcript('ready', true, [userTurn, askCall])
setStatus({ state: 'waiting' })
renderPane()
// One row says it: the question's own, not a second one at the tail.
expect(document.querySelectorAll('[data-native-chat-ask-row="awaiting"]')).toHaveLength(1)
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
expect(screen.getAllByText('Which name?').length).toBeGreaterThan(0)
expect(screen.queryByText('Asked:')).toBeNull()
})
// Answering a terminal-only prompt means leaving the chat, which remounts it on return; a reload
// also starts the store with no state history. The host's stamp covers both.
it("counts a turn it first sees after a wait from the host's turn start, not the last state", () => {
retained.session = transcript('working', false)
setStatus({ state: 'working' }, 5_000)
patchStatus({ turnStartedAt: Date.now() - 90_000, stateHistory: [] })
renderPane()
expect(screen.getByText('Working for 1m 30s')).toBeInTheDocument()
})
// A background task holds the row 'working' past the last turn's end, so the row's own epoch is
// that turn's; the host stamps the new turn from the main agent's own prompt.
it('counts a new turn from its stamp, not a row held open by background work', () => {
retained.session = transcript('working', false)
setStatus({ state: 'working' })
const now = Date.now()
patchStatus({
stateStartedAt: now - 45 * 60_000,
turnStartedAt: now - 5_000,
mainAgent: { state: 'working', stateStartedAt: now - 5_000 },
stateHistory: []
})
renderPane()
expect(screen.getByText('Working for 5s')).toBeInTheDocument()
})
it("falls back to the main agent's own clock on a host that stamps no turn", () => {
retained.session = transcript('working', false)
setStatus({ state: 'working' })
const now = Date.now()
patchStatus({
stateStartedAt: now - 45 * 60_000,
mainAgent: { state: 'working', stateStartedAt: now - 5_000 },
stateHistory: []
})
renderPane()
expect(screen.getByText('Working for 5s')).toBeInTheDocument()
})
// An Esc at a permission prompt fires no hook, then the same text is sent again: a new turn.
it('counts the same prompt sent again as a new turn', () => {
retained.session = transcript('working', false)
setStatus({ state: 'working' }, 5_000)
const now = Date.now()
patchStatus({
turnStartedAt: now - 5_000,
stateHistory: [
{ state: 'working', prompt: 'Rename the module', startedAt: now - 600_000 },
{ state: 'waiting', prompt: 'Rename the module', startedAt: now - 500_000 }
]
})
renderPane()
expect(screen.getByText('Working for 5s')).toBeInTheDocument()
})
// The reader no longer rebuilds a turn from the history, so an old host reads the state's start.
it('falls back to the current state start on a host that stamps no turn', () => {
retained.session = transcript('working', false)
setStatus({ state: 'working' }, 20_000)
const now = Date.now()
patchStatus({
stateHistory: [
{ state: 'working', prompt: 'Rename the module', startedAt: now - 90_000 },
{ state: 'waiting', prompt: 'Rename the module', startedAt: now - 40_000 }
]
})
renderPane()
expect(screen.getByText('Working for 20s')).toBeInTheDocument()
})
// The host recorded the turn's end, so a pane mounted after it (a switch to the terminal and
// back) folds it behind its duration instead of drawing it unfinished.
it('shows the host-ended latest turn with its host duration after a remount', () => {
retained.session = transcript('ready', false, [userTurn, stepCall])
setStatus({ state: 'done' })
// One clock read: the duration is the difference of these two stamps.
const now = Date.now()
patchStatus({ stateStartedAt: now - 10_000, turnStartedAt: now - 100_000 })
renderPane()
expect(screen.getByText('Worked for 1m 30s')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Toggle turn details' })).toBeInTheDocument()
})
// The store's staleness drops a silent row after 30 minutes. A wait left unanswered that long
// must not read as a finished turn: nothing says it ended.
it('does not settle a turn the host went quiet on', () => {
retained.session = transcript('ready', true, [userTurn, stepCall])
setStatus({ state: 'waiting' }, 60_000)
patchStatus({ turnStartedAt: Date.now() - 60_000 })
renderPane()
expect(screen.getByText('Working for 1m 0s')).toBeInTheDocument()
// The live session reads no hook state once the row is stale, so the wait no longer holds the turn.
retained.session = transcript('ready', false, [userTurn, stepCall])
act(() => {
patchStatus({ updatedAt: Date.now() - 31 * 60_000 })
})
expect(screen.queryByText(/Work(ing|ed) for/)).toBeNull()
expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull()
expect(screen.getByText('pnpm build')).toBeInTheDocument()
})
it('folds finished turns from history behind their transcript duration', () => {
const at = Date.parse('2026-09-28T10:00:00.000Z')
retained.session = transcript('ready', false, [
{ ...userTurn, timestamp: at },
{
id: 'answer-1',
role: 'assistant',
blocks: [{ type: 'text', text: 'Renamed.' }],
timestamp: at + 45_000,
source: 'transcript'
},
{
id: 'user-2',
role: 'user',
blocks: [{ type: 'text', text: 'Now add tests' }],
timestamp: at + 100_000,
source: 'transcript'
}
])
setStatus({ state: 'done' }, 60_000)
renderPane()
expect(screen.getByText('Worked for 45s')).toBeInTheDocument()
// The latest turn has no recorded end, and this pane never watched it.
expect(screen.getAllByText(/Work(ing|ed) for/)).toHaveLength(1)
})
// The transcript draws no row for harness notices, so they cannot end a turn either.
it('keeps a running turn open across a harness notice injected mid-turn', () => {
const at = Date.parse('2026-09-28T10:00:00.000Z')
retained.session = transcript('working', false, [
{ ...userTurn, timestamp: at },
{
id: 'progress-1',
role: 'assistant',
blocks: [{ type: 'text', text: 'Starting the build.' }],
timestamp: at + 10_000,
source: 'transcript'
},
{
id: 'notice-1',
role: 'user',
blocks: [{ type: 'text', text: '<task-notification>\n<task-id>b1</task-id>' }],
timestamp: at + 20_000,
source: 'transcript'
},
{
id: 'progress-2',
role: 'assistant',
blocks: [{ type: 'text', text: 'Build finished, checking output.' }],
timestamp: at + 25_000,
source: 'transcript'
}
])
setStatus({ state: 'working' }, 30_000)
renderPane()
expect(screen.getByText('Working for 30s')).toBeInTheDocument()
expect(screen.getByText('Starting the build.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull()
})
it('stays quiet on a settled turn it never watched', () => {
retained.session = transcript('ready', false)
setStatus({ state: 'done' }, 60_000)
renderPane()
expect(screen.queryByText(/Awaiting user input/)).toBeNull()
expect(screen.queryByText(/Work(ing|ed) for/)).toBeNull()
expect(liveActivityLine()).toBeNull()
})
it('stays quiet when the pane has no status at all', () => {
retained.session = transcript('ready', false)
renderPane()
expect(screen.queryByText(/Awaiting user input/)).toBeNull()
expect(screen.queryByText(/Work(ing|ed) for/)).toBeNull()
expect(liveActivityLine()).toBeNull()
})
})
@@ -52,8 +52,7 @@ type StructuredSessionMessageListProps = {
allowFileUriLinks?: boolean
isVisible?: boolean
onLinkClick?: (...args: unknown[]) => void
showTurnStatus?: boolean
showLiveTurnActivity?: boolean
awaitingInput?: 'shown' | 'unshown' | null
isWorking?: boolean
runtimeContext?: unknown
session?: { hasMore: boolean; loadingEarlier: boolean; loadEarlier: () => Promise<void> }
@@ -177,8 +177,8 @@ describe('NativeChatStructuredSession', () => {
expect(mocks.loadOlder).toHaveBeenCalledOnce()
})
// Turn status and transcript image previews shipped Codex-first. Every
// structured session renders through the same list, so neither is agent-gated.
// Transcript image previews shipped Codex-first. Every structured session
// renders through the same list, so they are not agent-gated.
it.each(['codex', 'claude'] as const)(
'renders the same structured transcript chrome for %s',
(agent) => {
@@ -193,7 +193,6 @@ describe('NativeChatStructuredSession', () => {
/>
)
expect(mocks.messageListProps?.showTurnStatus).toBe(true)
expect(mocks.messageListProps?.runtimeContext).not.toBeUndefined()
}
)
@@ -216,7 +215,7 @@ describe('NativeChatStructuredSession', () => {
expect(mocks.messageListProps).toMatchObject({
isWorking: true,
showLiveTurnActivity: false
awaitingInput: 'shown'
})
expect(
document
@@ -231,13 +230,13 @@ describe('NativeChatStructuredSession', () => {
itemId: 'legacy-question-item',
expectedRevision: 1
})
expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false)
expect(mocks.messageListProps?.awaitingInput).toBe('shown')
mocks.promptItems = []
rerender(view())
expect(mocks.messageListProps).toMatchObject({
isWorking: true,
showLiveTurnActivity: true
awaitingInput: null
})
expect(screen.getByTestId('structured-composer')).toBeTruthy()
expect(mocks.composerProps?.isWorking).toBe(true)
@@ -285,7 +284,7 @@ describe('NativeChatStructuredSession', () => {
expect(mocks.messageListProps).toMatchObject({
isWorking: true,
showLiveTurnActivity: false
awaitingInput: 'shown'
})
expect(mocks.approvalCardProps?.approval.title).toBe('Allow command?')
expect(screen.queryByTestId('structured-composer')).toBeNull()
@@ -296,7 +295,7 @@ describe('NativeChatStructuredSession', () => {
kind: 'option',
optionId: 'allow'
})
expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false)
expect(mocks.messageListProps?.awaitingInput).toBe('shown')
act(() => mocks.approvalCardProps?.onCancel?.())
expect(mocks.cancel).toHaveBeenCalledWith('turn-approval', {
@@ -281,8 +281,7 @@ export function NativeChatStructuredSession(
settledTurns={controller.settledTurns}
activeTurnOpenedBy={controller.activeTurnOpenedBy}
turnKeysByItemId={controller.turnKeysByItemId}
showTurnStatus
showLiveTurnActivity={prompt === null}
awaitingInput={prompt === null ? null : 'shown'}
turnActivity={controller.turnActivity}
onLinkClick={onLinkClick}
allowFileUriLinks={onLinkClick !== undefined}
@@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({
messageListProps: null as null | {
allowFileUriLinks?: boolean
onLinkClick?: (...args: unknown[]) => void
showTurnStatus?: boolean
runtimeContext?: unknown
},
composerProps: null as null | {
@@ -231,7 +231,7 @@ describe('NativeChatToolRun awaiting-input row', () => {
/>
)
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
expect(screen.getByText('Awaiting user input')).toBeInTheDocument()
expect(screen.queryByText(/request_user_input/)).toBeNull()
})
})
@@ -58,7 +58,6 @@ export function NativeChatToolRun({
activeTurnIsWorking,
trailing,
expandOverride,
structuredActivityUi = true,
disclosureId,
onLinkClick
}: {
@@ -81,7 +80,6 @@ export function NativeChatToolRun({
* the agent has already moved past reads as settled even mid-call. Left
* unset, a working turn's run is taken to be its last. */
trailing?: boolean
structuredActivityUi?: boolean
/** Message this run belongs to. Windowing unmounts rows, so a run the reader
* opened has to be remembered somewhere that outlives the row. */
disclosureId?: string
@@ -130,10 +128,9 @@ export function NativeChatToolRun({
// caller with no turn state, or a turn blocked on the reader's answer, falls
// back to the calls themselves.
const live =
structuredActivityUi &&
(activeTurnIsWorking === true && !askIsActive
activeTurnIsWorking === true && !askIsActive
? trailing !== false
: selectActiveToolCall(headerBlocks, { activeTurnIsWorking }) !== null)
: selectActiveToolCall(headerBlocks, { activeTurnIsWorking }) !== null
// One sentence for the whole run, or the command itself when the run is one
// call — the reader recognizes `git push` faster than "Ran 1 command".
const runSentence = nativeChatToolRunSentence(headerBlocks, { live })
@@ -199,7 +196,6 @@ export function NativeChatToolRun({
// 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 &&
!(revealedDiff && open) &&
!live &&
@@ -237,7 +233,7 @@ export function NativeChatToolRun({
aria-live="polite"
data-native-chat-tool-run-state={live ? 'live' : 'settled'}
>
{structuredActivityUi && settledHeaderIcon ? (
{settledHeaderIcon ? (
<NativeChatToolRunIcon iconName={settledHeaderIcon} className="text-muted-foreground" />
) : null}
{/* The run in words, in the transcript's own type. Present tense while
@@ -278,7 +274,7 @@ export function NativeChatToolRun({
{/* Only a stated success is marked done — see nativeChatToolRunOutcome —
and never while live: between two calls nothing is running, and a
mark that appeared then would flash on every call. */}
{structuredActivityUi && !live && runSucceeded ? (
{!live && runSucceeded ? (
<Check aria-hidden className="size-3 shrink-0 text-muted-foreground" />
) : null}
{latestCallLabel ? (
@@ -13,7 +13,6 @@ import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-t
* object so a row's props change only when that row's own slot does. */
export type NativeChatTranscriptRowContext = {
expandSignal: boolean
showTurnStatus: boolean
revealedDiff: NativeChatDiffReveal | null
taskListPredecessors: ReadonlyMap<string, NativeChatTaskListPredecessors>
expandedTurnIds: ReadonlySet<string>
@@ -74,7 +73,6 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
onLinkClick={context.onLinkClick}
allowFileUriLinks={context.allowFileUriLinks}
deliveryNotice={context.deliveryNotices?.get(message.id)}
structuredActivityUi={context.showTurnStatus}
folded={slot.folded}
subagentLabel={slot.subagentLabel}
runtimeContext={context.runtimeContext}
@@ -1,21 +0,0 @@
import { translate } from '@/i18n/i18n'
export function NativeChatTypingIndicatorRow(): React.JSX.Element {
return (
<div
className="flex items-center justify-start"
aria-label={translate('components.native-chat.status.responding', 'Agent is responding')}
aria-live="polite"
>
<div className="flex h-8 items-center gap-1.5 text-muted-foreground">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/70"
style={{ animationDelay: `${i * 160}ms` }}
/>
))}
</div>
</div>
)
}
@@ -27,6 +27,9 @@ export function isNativeChatTranscriptUnsettled(phase: ReadState['phase']): bool
export type NativeChatLiveSession = NativeChatSession & {
/** Latest provider turn boundary, used to settle orphaned running tool rows. */
transcriptLifecycle?: NativeChatTurnLifecycle
/** The pane's hook says the agent stopped mid-turn for the reader, and the transcript
* has not since ended that turn. Absent on lanes with no hook status. */
hookAwaitingInput?: boolean
/** True when an older page may still exist (the last read filled the window). */
hasMore: boolean
/** Whether an older-history page is currently loading. */
@@ -3,7 +3,7 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { mergeNativeChatLiveSession } from './native-chat-live-status'
import { mergeNativeChatLiveSession, nativeChatHookAwaitsInput } from './native-chat-live-status'
import { selectNativeChatViewState } from './native-chat-view-state'
import { shouldShowNativeChatWorking } from './native-chat-working-suppression'
@@ -154,6 +154,36 @@ describe('mergeNativeChatLiveSession', () => {
expect(session.status).toBe('ready')
})
it("keeps omp working while its latest row is a tool call written after the turn's start", () => {
// Shaped as decodeOmpTranscriptLine emits them: every omp row carries its timestamp.
const turnStartedAt = Date.parse('2026-09-28T10:00:00.000Z')
const toolCall: NativeChatMessage = {
id: 'rec-2',
role: 'assistant',
blocks: [
{ type: 'text', text: 'Running the suite.' },
{ type: 'tool-call', name: 'bash', input: { command: 'pnpm test' } }
],
timestamp: turnStartedAt + 4_000,
source: 'transcript'
}
const session = mergeNativeChatLiveSession({
messages: [{ ...user('rec-1', 'run the tests'), timestamp: turnStartedAt }, toolCall],
sessionId: 'sess',
agent: 'omp',
hookState: 'working',
stateStartedAt: turnStartedAt
})
expect(session.status).toBe('working')
expect(
shouldShowNativeChatWorking({
isConversation: true,
working: session.status === 'working',
interrupted: false
})
).toBe(true)
})
it('keeps working while the hook reports a live background child', () => {
const session = mergeNativeChatLiveSession({
messages: [assistant('a-1', 'lead done')],
@@ -201,7 +231,7 @@ describe('mergeNativeChatLiveSession', () => {
}).status
).toBe('working')
// Regression: a non-null sessionId used to force 'loading' over live work,
// so the pane rendered idle mid-turn — Send instead of Stop, no typing
// so the pane rendered idle mid-turn — Send instead of Stop, no working
// indicator, no streaming preview. The empty-transcript loading SURFACE is
// selectNativeChatViewState's job; the status must stay 'working'.
expect(
@@ -249,7 +279,7 @@ describe('mergeNativeChatLiveSession', () => {
// The whole chain the defect broke: a fresh Claude session reports its id
// before the transcript flushes, so the pane rendered Send (not Stop) with no
// typing indicator while the agent was working.
// working indicator while the agent was working.
it('keeps the Stop affordance for a working known session mid-flush', () => {
const session = mergeNativeChatLiveSession({
messages: [user('u-1', 'run it')],
@@ -271,3 +301,46 @@ describe('mergeNativeChatLiveSession', () => {
).toBe(true)
})
})
describe('nativeChatHookAwaitsInput', () => {
it.each(['waiting', 'blocked'] as const)('reads a %s hook as a wait on the reader', (state) => {
expect(nativeChatHookAwaitsInput(state, 5, undefined)).toBe(true)
expect(
nativeChatHookAwaitsInput(state, 5, { state: 'working', turnId: 'turn-1', timestamp: 1 })
).toBe(true)
})
it('reads no other hook state as a wait', () => {
expect(nativeChatHookAwaitsInput('working', 5, undefined)).toBe(false)
expect(nativeChatHookAwaitsInput('done', 5, undefined)).toBe(false)
expect(nativeChatHookAwaitsInput(null, 5, undefined)).toBe(false)
})
// An interrupt at the prompt fires no hook; the transcript's marker is the only end.
it('ends a wait the transcript closed after it began', () => {
expect(
nativeChatHookAwaitsInput('waiting', 5, {
state: 'interrupted',
turnId: 'turn-1',
timestamp: 6
})
).toBe(false)
expect(
nativeChatHookAwaitsInput('waiting', 5, {
state: 'completed',
turnId: 'turn-1',
timestamp: 6
})
).toBe(false)
})
it('keeps a wait that began after the last closed turn', () => {
expect(
nativeChatHookAwaitsInput('waiting', 5, {
state: 'interrupted',
turnId: 'turn-1',
timestamp: 2
})
).toBe(true)
})
})
@@ -63,14 +63,15 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat
}
const status = liveStatusOverride(
agent,
hookState,
statusTailMessage ?? messages.at(-1),
stateStartedAt,
transcriptLifecycle,
hookHasWorkingSubagents ?? false
)
// Why live work still wins: 'working' is what drives Stop-vs-Send, the typing
// indicator and the streaming preview, so forcing 'loading' over it renders an
// Why live work still wins: 'working' is what drives Stop-vs-Send, the turn
// status and the streaming preview, so forcing 'loading' over it renders an
// idle pane while the agent works. A known session with nothing to show yet is
// held on the loading surface by selectNativeChatViewState instead.
if (loading && status !== 'working') {
@@ -84,10 +85,36 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat
}
}
/**
* Whether the agent stopped mid-turn for the reader (`blocked` and `waiting` both mean a human
* must decide). Reconciled like 'working': an interrupt at the prompt fires no hook, so only the
* transcript's terminal marker can end a wait the hook never cleared.
*/
export function nativeChatHookAwaitsInput(
hookState: AgentStatusState | null,
stateStartedAt: number | null | undefined,
transcriptLifecycle: NativeChatTurnLifecycle | undefined
): boolean {
return (
(hookState === 'blocked' || hookState === 'waiting') &&
!lifecycleTerminatesCurrentTurn(transcriptLifecycle, stateStartedAt)
)
}
/** Slack for comparing transcript timestamps to hook receipt times across hosts. */
export const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000
/**
* Agents whose own runtime reports the turn end: omp posts `agent_end` from inside the agent,
* retried until the host takes it and held back while the run continues. Their hook 'working'
* is never a dropped Stop, so a trailing assistant row (a tool call mid-run) cannot end it.
*/
function hookDeliversTurnEnd(agent: AgentType): boolean {
return agent === 'omp'
}
function liveStatusOverride(
agent: AgentType,
hookState: AgentStatusState | null,
statusTailMessage: NativeChatMessage | undefined,
stateStartedAt: number | null | undefined,
@@ -121,6 +148,7 @@ function liveStatusOverride(
// do not settle early on capable providers.
if (
transcriptLifecycle?.state !== 'working' &&
!hookDeliversTurnEnd(agent) &&
trailingAssistantPostDates(statusTailMessage, stateStartedAt)
) {
return undefined
@@ -47,7 +47,6 @@ function slotsOf(messages: NativeChatMessage[]) {
receipts: new Map<string, NativeChatResolvedPrompt>(),
turnStatuses: { active: null, completedByTurn: {} },
turnDiffs: new Map<string, NativeChatTurnDiff>(),
showTurnStatus: false,
expandedTurnKeys: new Set<string>(),
isWorking: false,
lifecycleWorking: false
@@ -86,7 +86,6 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour
receipts: new Map<string, NativeChatResolvedPrompt>(),
turnStatuses: { active: null, completedByTurn: {} },
turnDiffs: new Map<string, NativeChatTurnDiff>(),
showTurnStatus: true,
expandedTurnKeys: new Set<string>(),
isWorking: false,
lifecycleWorking: false
@@ -0,0 +1,243 @@
import { describe, expect, it } from 'vitest'
import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatMessage
} from '../../../../shared/native-chat-types'
import {
nativeChatHookLatestTurnWorkedSeconds,
nativeChatHookTurnStartedAt,
nativeChatLatestTurnId,
nativeChatTranscriptSettledTurns,
resolveNativeChatTerminalTurn
} from './native-chat-terminal-turn'
const idle = {
isConversation: true,
working: false,
hookAwaitingInput: false,
interrupted: false,
hasPromptCard: false
}
describe('resolveNativeChatTerminalTurn', () => {
it('keeps the turn running while the agent waits, without calling it generating', () => {
expect(resolveNativeChatTerminalTurn({ ...idle, hookAwaitingInput: true })).toEqual({
isWorking: false,
turnActive: true,
awaitingInput: 'unshown'
})
})
it('lets a prompt card speak for the wait', () => {
expect(
resolveNativeChatTerminalTurn({ ...idle, hookAwaitingInput: true, hasPromptCard: true })
).toEqual({ isWorking: false, turnActive: true, awaitingInput: 'shown' })
})
it('reports a generating turn with nothing awaited', () => {
expect(resolveNativeChatTerminalTurn({ ...idle, working: true })).toEqual({
isWorking: true,
turnActive: true,
awaitingInput: null
})
})
// Stop is the reader's word that the turn is over, whatever the hook still says.
it('ends the turn on local Stop, wait included', () => {
expect(
resolveNativeChatTerminalTurn({
...idle,
working: true,
hookAwaitingInput: true,
interrupted: true
})
).toEqual({ isWorking: false, turnActive: false, awaitingInput: null })
})
it('stays quiet with no turn at all', () => {
expect(resolveNativeChatTerminalTurn(idle)).toEqual({
isWorking: false,
turnActive: false,
awaitingInput: null
})
})
})
describe('nativeChatHookTurnStartedAt', () => {
const entry = { state: 'working' as const, stateStartedAt: 3_000 }
// Answered in the terminal: the current state began at 3s, the turn at 1s.
it("dates the turn by the host's stamp, not the state it came back from", () => {
expect(nativeChatHookTurnStartedAt({ ...entry, turnStartedAt: 1_000 })).toBe(1_000)
})
// A background task held the row 'working' since 500; the main agent's turn began at 8s.
it('keeps the stamp when child work holds the row open', () => {
expect(
nativeChatHookTurnStartedAt({
...entry,
stateStartedAt: 500,
turnStartedAt: 8_000,
mainAgent: { state: 'working', stateStartedAt: 8_000 }
})
).toBe(8_000)
})
it("falls back to the main agent's own state start on a host that stamps no turn", () => {
expect(
nativeChatHookTurnStartedAt({
...entry,
stateStartedAt: 500,
mainAgent: { state: 'working', stateStartedAt: 8_000 }
})
).toBe(8_000)
})
it('falls back to the row once the main agent is done, or where it has none', () => {
expect(
nativeChatHookTurnStartedAt({ ...entry, mainAgent: { state: 'done', stateStartedAt: 2_500 } })
).toBe(3_000)
expect(nativeChatHookTurnStartedAt(entry)).toBe(3_000)
})
it('knows nothing without a status row', () => {
expect(nativeChatHookTurnStartedAt(undefined)).toBeNull()
})
})
describe('nativeChatHookLatestTurnWorkedSeconds', () => {
const done = { state: 'done' as const, stateStartedAt: 91_000, turnStartedAt: 1_000 }
it("times a turn the host ended from its turn start to the main agent's done", () => {
expect(nativeChatHookLatestTurnWorkedSeconds(done, false)).toBe(90)
// Child work still holds the row; the main agent's own end closes the turn.
expect(
nativeChatHookLatestTurnWorkedSeconds(
{ ...done, state: 'working', mainAgent: { state: 'done', stateStartedAt: 31_000 } },
false
)
).toBe(30)
})
// Staleness ages the row out of the pane, not the host's record that the turn finished.
it('keeps a host-ended duration once the row is stale', () => {
expect(nativeChatHookLatestTurnWorkedSeconds(done, true)).toBe(90)
})
it('hides a local end while the host went quiet mid-turn', () => {
expect(
nativeChatHookLatestTurnWorkedSeconds(
{ ...done, state: 'waiting', stateStartedAt: 2_000 },
true
)
).toBeNull()
})
// A pane-side end (Stop, the transcript's end marker) is the pane's own word; keep what it saw.
it('keeps the local reading while the host is fresh and the turn not host-ended', () => {
expect(
nativeChatHookLatestTurnWorkedSeconds(
{ ...done, state: 'waiting', stateStartedAt: 2_000 },
false
)
).toBeUndefined()
})
it('invents no duration without the host turn stamp, or across a session boundary', () => {
expect(
nativeChatHookLatestTurnWorkedSeconds({ state: 'done', stateStartedAt: 91_000 }, false)
).toBeUndefined()
expect(
nativeChatHookLatestTurnWorkedSeconds({ ...done, sessionBoundary: true }, false)
).toBeUndefined()
expect(nativeChatHookLatestTurnWorkedSeconds(undefined, true)).toBeUndefined()
})
})
function row(
id: string,
role: NativeChatMessage['role'],
timestamp: number | null
): NativeChatMessage {
return { id, role, blocks: [{ type: 'text', text: id }], timestamp, source: 'transcript' }
}
describe('nativeChatTranscriptSettledTurns', () => {
it('times each finished turn from its prompt to its last row, leaving the latest out', () => {
const settled = nativeChatTranscriptSettledTurns([
row('u1', 'user', 10_000),
row('a1', 'assistant', 20_000),
row('a2', 'assistant', 55_900),
row('u2', 'user', 70_000),
row('a3', 'assistant', 80_000)
])
expect([...settled]).toEqual([['u1', { startedAt: 10_000, workedSeconds: 45 }]])
})
it('ends an interrupted turn at its interruption', () => {
const settled = nativeChatTranscriptSettledTurns([
row('u1', 'user', 0),
row('a1', 'assistant', 5_000),
{
...row('stop', 'system', 12_000),
blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }]
},
row('u2', 'user', 60_000)
])
expect(settled.get('u1')).toEqual({ startedAt: 0, workedSeconds: 12 })
})
// An attachment row sits beside the next prompt; it does not stretch the turn before it.
it('does not end a turn at a system row the agent did not write', () => {
const settled = nativeChatTranscriptSettledTurns([
row('u1', 'user', 0),
row('a1', 'assistant', 5_000),
row('@src/index.ts', 'system', 3_600_000),
row('u2', 'user', 3_601_000)
])
expect(settled.get('u1')).toEqual({ startedAt: 0, workedSeconds: 5 })
})
// A harness notice (task notification, reminder) is user-role in the transcript but not a prompt.
it('times a turn across a harness notice injected mid-turn', () => {
const settled = nativeChatTranscriptSettledTurns([
row('u1', 'user', 0),
row('a1', 'assistant', 5_000),
{
...row('notice', 'user', 10_000),
blocks: [{ type: 'text', text: '<task-notification>\n<task-id>b1</task-id>' }]
},
row('a2', 'assistant', 30_000),
row('u2', 'user', 60_000)
])
expect([...settled]).toEqual([['u1', { startedAt: 0, workedSeconds: 30 }]])
})
// Absent, not null: null would also hide the duration the pane measured itself.
it('leaves out a turn missing either end', () => {
const settled = nativeChatTranscriptSettledTurns([
row('u1', 'user', null),
row('a1', 'assistant', 5_000),
row('u2', 'user', 10_000),
row('a2', 'assistant', null),
row('u3', 'user', 20_000)
])
expect(settled.size).toBe(0)
})
})
describe('nativeChatLatestTurnId', () => {
it('names the last prompt, not a harness notice after it', () => {
expect(
nativeChatLatestTurnId([
row('u1', 'user', 0),
row('a1', 'assistant', 5_000),
{
...row('notice', 'user', 10_000),
blocks: [{ type: 'text', text: '<task-notification>\n<task-id>b1</task-id>' }]
}
])
).toBe('u1')
expect(nativeChatLatestTurnId([row('a1', 'assistant', 0)])).toBeNull()
})
})
@@ -0,0 +1,132 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import {
isInterruptedStatusMessage,
type NativeChatMessage
} from '../../../../shared/native-chat-types'
import type {
NativeChatSettledTurn,
NativeChatSettledTurns
} from '../../../../shared/native-chat-turn-status'
import { isNoiseMessage } from '../../../../shared/native-chat-noise'
import type { NativeChatAwaitingInput } from './NativeChatMessageList'
import { shouldShowNativeChatWorking } from './native-chat-working-suppression'
export type NativeChatTerminalTurn = {
/** The agent is generating: drives Stop-vs-Send and the streaming preview. */
isWorking: boolean
/** The turn is running, including while its agent waits on the reader, as a
* structured turn runs on behind its prompt card: its clock keeps counting. */
turnActive: boolean
awaitingInput: NativeChatAwaitingInput | null
}
/** The turn facts a terminal-backed pane feeds the shared turn-status UI. */
export function resolveNativeChatTerminalTurn(args: {
isConversation: boolean
/** Hook 'working', reconciled with the transcript's turn boundaries. */
working: boolean
/** Hook 'waiting'/'blocked', reconciled the same way. */
hookAwaitingInput: boolean
/** Local Stop suppression. */
interrupted: boolean
/** The pane draws the agent's prompt as a card. */
hasPromptCard: boolean
}): NativeChatTerminalTurn {
const { isConversation, working, hookAwaitingInput, interrupted, hasPromptCard } = args
const turnActive = shouldShowNativeChatWorking({
isConversation,
working: working || hookAwaitingInput,
interrupted
})
return {
isWorking: shouldShowNativeChatWorking({ isConversation, working, interrupted }),
turnActive,
// A prompt only the terminal shows is the one wait the transcript has to report.
awaitingInput: hasPromptCard ? 'shown' : turnActive && hookAwaitingInput ? 'unshown' : null
}
}
type NativeChatHookTurnEntry = Pick<
AgentStatusEntry,
'state' | 'stateStartedAt' | 'turnStartedAt' | 'mainAgent' | 'sessionBoundary'
>
/**
* When the pane's current turn began: the host's stamp from the main agent's own turn-opening
* event, which survives a remount, a reload and child work holding the row open. A host too old to
* stamp it leaves the current state's start, the main agent's while it is mid-turn.
*/
export function nativeChatHookTurnStartedAt(
entry: NativeChatHookTurnEntry | undefined
): number | null {
if (!entry) {
return null
}
if (entry.turnStartedAt !== undefined) {
return entry.turnStartedAt
}
return entry.mainAgent && entry.mainAgent.state !== 'done'
? entry.mainAgent.stateStartedAt
: entry.stateStartedAt
}
/**
* The latest turn's duration by the host, for a turn the pane is not running: a number once the
* main agent is done (its done stamp minus the host's turn start), null when the host went quiet
* mid-turn (nothing says the turn ended, so a locally measured end would be a false claim), and
* undefined to keep what the pane observed (an old host, a session boundary, a pane-side end).
*/
export function nativeChatHookLatestTurnWorkedSeconds(
entry: NativeChatHookTurnEntry | undefined,
hookSilent: boolean
): number | null | undefined {
if (!entry) {
return undefined
}
const mainAgent = entry.mainAgent ?? entry
if (mainAgent.state === 'done') {
return entry.sessionBoundary === true || entry.turnStartedAt === undefined
? undefined
: Math.max(0, Math.floor((mainAgent.stateStartedAt - entry.turnStartedAt) / 1000))
}
return hookSilent ? null : undefined
}
/** The transcript's latest turn: its last prompt, harness notices aside. */
export function nativeChatLatestTurnId(messages: readonly NativeChatMessage[]): string | null {
const latest = messages.findLast((message) => message.role === 'user' && !isNoiseMessage(message))
return latest?.id ?? null
}
/**
* Durations of the transcript's finished turns, from its own timestamps (one clock): a turn runs
* from its prompt to the agent's last timestamped row or its interruption. Other system rows (file
* mentions, extension notes) can land long after, next to the following prompt. The latest turn is
* left out, since nothing here says it has ended, and so is a turn missing either end, which keeps
* what the pane observed.
*/
export function nativeChatTranscriptSettledTurns(
messages: readonly NativeChatMessage[]
): NativeChatSettledTurns {
const settled = new Map<string, NativeChatSettledTurn>()
let turn: { id: string; startedAt: number | null; endedAt: number | null } | null = null
for (const message of messages) {
// A harness notice is user-role but draws no row, so it neither starts nor extends a turn.
if (message.role !== 'user' || isNoiseMessage(message)) {
const agentRow =
message.role === 'system' ? isInterruptedStatusMessage(message) : message.role !== 'user'
if (turn && agentRow && message.timestamp != null) {
turn.endedAt = Math.max(turn.endedAt ?? message.timestamp, message.timestamp)
}
continue
}
if (turn?.startedAt != null && turn.endedAt != null) {
settled.set(turn.id, {
startedAt: turn.startedAt,
workedSeconds: Math.max(0, Math.floor((turn.endedAt - turn.startedAt) / 1000))
})
}
turn = { id: message.id, startedAt: message.timestamp, endedAt: null }
}
return settled
}
@@ -43,7 +43,6 @@ function build(
receipts: new Map<string, NativeChatResolvedPrompt>(),
turnStatuses: NO_STATUSES,
turnDiffs: new Map<string, NativeChatTurnDiff>(),
showTurnStatus: true,
expandedTurnKeys: new Set<string>(),
isWorking: false,
lifecycleWorking: false,
@@ -75,7 +75,6 @@ export type NativeChatTranscriptSlotsInput = {
completedByTurn: Readonly<Record<string, NativeChatTurnStatus>>
}
turnDiffs: ReadonlyMap<string, NativeChatTurnDiff>
showTurnStatus: boolean
/** Turns the reader opened. Everything else with a duration stays folded. */
expandedTurnKeys: ReadonlySet<string>
isWorking: boolean
@@ -95,7 +94,6 @@ export function buildNativeChatTranscriptSlots(
receipts,
turnStatuses,
turnDiffs,
showTurnStatus,
expandedTurnKeys,
isWorking,
lifecycleWorking,
@@ -141,11 +139,9 @@ export function buildNativeChatTranscriptSlots(
}
}
const settledTurnKeys = new Set(
showTurnStatus
? Object.entries(turnStatuses.completedByTurn)
.filter(([, status]) => status.workedSeconds != null)
.map(([turnKey]) => turnKey)
: []
Object.entries(turnStatuses.completedByTurn)
.filter(([, status]) => status.workedSeconds != null)
.map(([turnKey]) => turnKey)
)
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
rows: foldRows,
@@ -180,7 +176,7 @@ export function buildNativeChatTranscriptSlots(
: turnStatuses.completedByTurn[turnKey]
: undefined
// The live turn's bar carries its running clock; it settles in place.
const status = showTurnStatus ? (candidateStatus ?? undefined) : undefined
const status = candidateStatus ?? undefined
const turnDiff =
turnKey && lastRowByTurn.get(turnKey) === index ? turnDiffs.get(turnKey) : undefined
const folded = foldedRows.has(index)
@@ -1,154 +0,0 @@
import { describe, expect, it } from 'vitest'
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator'
function message(id: string, role: NativeChatMessage['role'], text = id): NativeChatMessage {
return { id, role, blocks: [{ type: 'text', text }], timestamp: null, source: 'transcript' }
}
describe('shouldShowNativeChatTypingIndicator', () => {
it('stays hidden when the session is idle', () => {
expect(
shouldShowNativeChatTypingIndicator({
messages: [message('u1', 'user')],
isWorking: false
})
).toBe(false)
})
it('shows once a send lands and no assistant row exists yet', () => {
expect(
shouldShowNativeChatTypingIndicator({
messages: [message('a0', 'assistant'), message('u1', 'user')],
isWorking: true
})
).toBe(true)
})
it('hides as soon as the structured reply row arrives, before working clears', () => {
expect(
shouldShowNativeChatTypingIndicator({
messages: [message('u1', 'user'), message('orca-item', 'assistant')],
isWorking: true
})
).toBe(false)
})
it('hides behind the PTY streaming bubble', () => {
expect(
shouldShowNativeChatTypingIndicator({
messages: [message('u1', 'user'), message(NATIVE_CHAT_STREAMING_ID, 'assistant')],
isWorking: true
})
).toBe(false)
})
it('does not flicker back on when a system row interleaves mid-turn', () => {
expect(
shouldShowNativeChatTypingIndicator({
messages: [
message('u1', 'user'),
message('a1', 'assistant'),
message('s1', 'system', 'Ran /status')
],
isWorking: true
})
).toBe(false)
})
it('shows again for the next send even though an earlier turn replied', () => {
expect(
shouldShowNativeChatTypingIndicator({
messages: [message('u1', 'user'), message('a1', 'assistant'), message('u2', 'user')],
isWorking: true
})
).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({
messages: [
message('a1', 'assistant'),
message('command:compact', 'system', 'Ran /compact')
],
isWorking: true
})
).toBe(true)
})
it('shows on a session whose transcript is still empty', () => {
expect(shouldShowNativeChatTypingIndicator({ messages: [], isWorking: true })).toBe(true)
})
})
// These build rows through the REAL structured projection instead of hand-made
// `command:` marker ids. The hand-made ids only exist on the PTY transport, so
// tests using them were blind to how the shipping transport actually looks.
describe('with rows projected from the structured journal', () => {
function toolCallItem(sequence: number): AgentJournalRenderItem {
return {
itemId: `codex:thread-1:turn-1:${sequence}`,
revision: 1,
sequence,
observedAt: 1_800_000_000_000,
body: {
kind: 'tool-call',
name: 'shell',
state: 'running',
input: { command: 'sed -n 1,240p README.md' }
}
} as AgentJournalRenderItem
}
function assistantTextItem(sequence: number): AgentJournalRenderItem {
return {
itemId: `codex:thread-1:turn-1:${sequence}`,
revision: 1,
sequence,
observedAt: 1_800_000_000_000,
body: {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: "I'm checking PR 14696's metadata." }]
}
} as AgentJournalRenderItem
}
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('hides once prose is the newest row', () => {
const messages = projectStructuredItemsToNativeChat([toolCallItem(1), assistantTextItem(2)])
expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: true })).toBe(false)
})
it('stays hidden when the turn is not working, command row or not', () => {
const messages = projectStructuredItemsToNativeChat([toolCallItem(1)])
expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: false })).toBe(false)
})
})
@@ -1,34 +0,0 @@
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'
function isToolActivityOnlyRow(message: NativeChatMessage): boolean {
const blocks = message.blocks
if (!blocks || blocks.length === 0) {
return false
}
return blocks.every((block) => block.type === 'tool-call' || block.type === 'tool-result')
}
export function shouldShowNativeChatTypingIndicator(args: {
messages: readonly NativeChatMessage[]
isWorking: boolean
}): boolean {
if (!args.isWorking) {
return false
}
const { messages } = args
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]
if (!message || message.role === 'user' || isCommandMarkerId(message.id)) {
return true
}
if (isToolActivityOnlyRow(message)) {
return true
}
if (message.role === 'assistant' || message.id === NATIVE_CHAT_STREAMING_ID) {
return false
}
}
return true
}
@@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import type { AgentStatusPayload } from '../../../../shared/agent-status-types'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
const { useAppStore } = await import('../../store')
const { useNativeChatInteractivePromptCard } =
await import('./use-native-chat-interactive-prompt-card')
const paneKey = 'tab-card:leaf-card'
const NO_MESSAGES: readonly NativeChatMessage[] = []
function setStatus(payload: Omit<AgentStatusPayload, 'prompt' | 'agentType'>): void {
useAppStore
.getState()
.setAgentStatus(paneKey, { prompt: 'Rename', agentType: 'claude', ...payload })
}
afterEach(() => {
cleanup()
useAppStore.setState({ agentStatusByPaneKey: {} })
})
describe('useNativeChatInteractivePromptCard', () => {
// The hook runs in the pane's view, so each render here re-renders the whole transcript.
it('does not re-render for a tool call that carries no prompt', () => {
setStatus({ state: 'working', toolName: 'Read' })
let renders = 0
renderHook(() => {
renders += 1
return useNativeChatInteractivePromptCard({
paneKey,
messages: NO_MESSAGES,
transcriptSettled: true
})
})
const settled = renders
act(() => setStatus({ state: 'working', toolName: 'Bash' }))
act(() => setStatus({ state: 'working', toolName: 'Edit' }))
expect(renders).toBe(settled)
})
it('still reads the prompt that arrives with a tool call', () => {
setStatus({ state: 'working', toolName: 'Read' })
const { result } = renderHook(() =>
useNativeChatInteractivePromptCard({
paneKey,
messages: NO_MESSAGES,
transcriptSettled: true
})
)
expect(result.current).toBeNull()
act(() =>
setStatus({
state: 'waiting',
toolName: 'Bash',
interactivePrompt: JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf dist' } })
})
)
expect(result.current).toMatchObject({ kind: 'approval' })
})
})
@@ -0,0 +1,48 @@
import { useMemo } from 'react'
import { useAppStore } from '../../store'
import { resolveNativeChatAsk } from '../../../../shared/native-chat-ask'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import {
parseInteractivePrompt,
type InteractivePromptCard
} from './native-chat-interactive-prompt'
/**
* The prompt a terminal-backed pane can draw as a card: a tool approval from the
* live status, else a question from the live status or, failing that, from the
* transcript's unresolved ask (headless host, relay gap, replay, reconnect — a
* pane parked on a selector must not take the next message as its answer, #11761).
*/
export function useNativeChatInteractivePromptCard({
paneKey,
messages,
transcriptSettled
}: {
paneKey: string
/** Pass the command-boundary-trimmed messages so an ask abandoned via `/clear` stays gone. */
messages: readonly NativeChatMessage[]
transcriptSettled: boolean
}): InteractivePromptCard {
const interactivePrompt = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null
)
// The sibling `toolName` lets the question parser dispatch through the tool's
// registered parser (mobile parity). Read only beside a prompt: it changes on
// every tool call, and this hook re-renders the whole pane.
const interactiveToolName = useAppStore((s) => {
const entry = s.agentStatusByPaneKey[paneKey]
return entry?.interactivePrompt ? (entry.toolName ?? null) : null
})
return useMemo(() => {
const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined)
if (statusCard?.kind === 'approval') {
return statusCard
}
const prompt = resolveNativeChatAsk({
liveAsk: statusCard?.prompt ?? null,
messages,
transcriptSettled
})
return prompt ? { kind: 'question' as const, prompt } : null
}, [interactivePrompt, interactiveToolName, messages, transcriptSettled])
}
@@ -547,6 +547,42 @@ describe('useNativeChatLiveSession — transport routing', () => {
expect(latest?.status).toBe('ready')
})
// Declining a prompt with an interrupt fires no hook, so the row keeps saying
// `waiting`; the transcript's marker is what ends the wait.
it('ends a hook wait once the transcript records the turn interrupted', async () => {
const waitStartedAt = Date.now() - 1_000
useAppStore.getState().setAgentStatus(PANE, { state: 'waiting', prompt: '', agentType: AGENT })
const row = useAppStore.getState().agentStatusByPaneKey[PANE]
if (!row) {
throw new Error('the store dropped the status row this test depends on')
}
useAppStore.setState({
agentStatusByPaneKey: { [PANE]: { ...row, stateStartedAt: waitStartedAt } }
})
const transport = getMockTransport('env-1')
await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' })
await act(async () =>
transport.emit({
type: 'snapshot',
messages: [user('u-1', 'go')],
hasMore: false,
lifecycle: { state: 'working', turnId: 'turn-1', timestamp: waitStartedAt - 500 }
})
)
expect(latest?.hookAwaitingInput).toBe(true)
expect(latest?.status).toBe('ready')
await act(async () =>
transport.emit({
type: 'appended',
messages: [],
lifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: Date.now() }
})
)
expect(latest?.hookAwaitingInput).toBe(false)
})
it('does not let an older pagination read rewind a live completion', async () => {
useAppStore.setState({
agentStatusByPaneKey: {
@@ -9,7 +9,7 @@ import {
createNativeChatMerger,
replaceList
} from '../../../../shared/native-chat-merge'
import { mergeNativeChatLiveSession } from './native-chat-live-status'
import { mergeNativeChatLiveSession, nativeChatHookAwaitsInput } from './native-chat-live-status'
import {
hasMoreNativeChatHistory,
NATIVE_CHAT_INITIAL_LIMIT,
@@ -342,6 +342,11 @@ export function useNativeChatLiveSession(
})
return {
...session,
hookAwaitingInput: nativeChatHookAwaitsInput(
hookState,
hookStateStartedAt,
transcriptLifecycle
),
hasMore,
loadingEarlier,
olderHistoryGeneration,
@@ -36,7 +36,6 @@ function slotsOf(messages: NativeChatMessage[]) {
receipts: new Map<string, NativeChatResolvedPrompt>(),
turnStatuses: { active: null, completedByTurn: {} },
turnDiffs: new Map<string, NativeChatTurnDiff>(),
showTurnStatus: false,
expandedTurnKeys: new Set<string>(),
isWorking: false,
lifecycleWorking: false
@@ -0,0 +1,45 @@
import { useMemo } from 'react'
import { useAppStore } from '../../store'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status'
import {
nativeChatHookLatestTurnWorkedSeconds,
nativeChatHookTurnStartedAt,
nativeChatLatestTurnId,
nativeChatTranscriptSettledTurns
} from './native-chat-terminal-turn'
import { resolveNativeChatHookState } from './use-native-chat-hook-status'
/** The terminal-backed pane's turn timing for the message list: when its running turn began, by
* the host, and each finished turn's duration (the host's for the latest, the transcript's for
* history). All of it survives a remount of the pane. */
export function useNativeChatTerminalTurnTiming(
paneKey: string,
messages: readonly NativeChatMessage[],
turnActive: boolean
): { workingStartedAt: number | null; settledTurns: NativeChatSettledTurns } {
// Freshness is time-based: re-read when the scheduler says a silent row aged out.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
void agentStatusEpoch
const turnStartedAt = useAppStore((s) =>
nativeChatHookTurnStartedAt(s.agentStatusByPaneKey[paneKey])
)
const latestWorkedSeconds = useAppStore((s) => {
const entry = s.agentStatusByPaneKey[paneKey]
return nativeChatHookLatestTurnWorkedSeconds(entry, resolveNativeChatHookState(entry) === null)
})
const transcriptSettled = useMemo(() => nativeChatTranscriptSettledTurns(messages), [messages])
const latestTurnId = useMemo(() => nativeChatLatestTurnId(messages), [messages])
const settledTurns = useMemo(() => {
if (turnActive || latestWorkedSeconds === undefined || latestTurnId === null) {
return transcriptSettled
}
return new Map(transcriptSettled).set(
latestTurnId,
latestWorkedSeconds === null || turnStartedAt === null
? null
: { startedAt: turnStartedAt, workedSeconds: latestWorkedSeconds }
)
}, [latestTurnId, latestWorkedSeconds, transcriptSettled, turnActive, turnStartedAt])
return { workingStartedAt: turnActive ? turnStartedAt : null, settledTurns }
}
@@ -23,7 +23,7 @@ function TranscriptHarness({
contentRef,
itemCount,
isWorking: false,
showTypingIndicator: false,
showsTailRow: false,
isVisible,
alignToViewportTop: vi.fn(),
scrollToEnd,
@@ -52,7 +52,7 @@ export function useNativeChatTranscriptScroll({
contentRef,
itemCount,
isWorking,
showTypingIndicator,
showsTailRow,
isVisible,
alignToViewportTop,
scrollToEnd,
@@ -64,7 +64,8 @@ export function useNativeChatTranscriptScroll({
contentRef: React.RefObject<HTMLDivElement | null>
itemCount: number
isWorking: boolean
showTypingIndicator: boolean
/** Whether the list draws a row after the transcript (live activity or a wait). */
showsTailRow: boolean
isVisible: boolean
alignToViewportTop: (element: HTMLElement) => void
scrollToEnd: () => void
@@ -156,7 +157,7 @@ export function useNativeChatTranscriptScroll({
itemCount,
isWorking,
restoreScrollOffset,
showTypingIndicator,
showsTailRow,
scrollToEndWhenMeasurable
])
@@ -23,7 +23,7 @@ export function useNativeChatTurnStatus({
activeTurnKey: string
isWorking: boolean
workingStartedAt?: number | null
/** Host-recorded durations; they outrank whatever this client observed. */
/** Recorded durations (the host's journal or the transcript); they outrank what this client observed. */
settledTurns?: NativeChatSettledTurns | null
/** Whether the turn is reasoning right now, derived from its journal content. */
thinking?: boolean
@@ -47,6 +47,7 @@ export type AgentStatusSetData = {
connectionId?: string | null
receivedAt: number
stateStartedAt: number
turnStartedAt?: number
}
export type StoreLike = Record<string, unknown>
export type StoreSubscribeListener = (state: StoreLike, previousState: StoreLike) => void
@@ -227,7 +227,8 @@ export function createAgentStatusEventApplicator(args: {
...(data.evidenceObservedAt !== undefined
? { evidenceObservedAt: data.evidenceObservedAt }
: {}),
stateStartedAt: data.stateStartedAt
stateStartedAt: data.stateStartedAt,
...(Number.isFinite(data.turnStartedAt) ? { turnStartedAt: data.turnStartedAt } : {})
},
routing: {
tabId: ownerTabId,
@@ -79,7 +79,9 @@ describe('useIpcEvents agent status snapshot integration', () => {
toolInput: 'pnpm test',
terminalHandle: 'term-future',
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000
stateStartedAt: 1_699_999_999_000,
// A reload starts with no state history; the host's turn start must arrive with the row.
turnStartedAt: 1_699_999_990_000
}
])
await Promise.resolve()
@@ -113,7 +115,11 @@ describe('useIpcEvents agent status snapshot integration', () => {
toolInput: 'pnpm test'
}),
'SSH Tab',
{ updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 },
{
updatedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000,
turnStartedAt: 1_699_999_990_000
},
expectWorktreeRouting('wt-1'),
undefined
)
+1
View File
@@ -2674,6 +2674,7 @@
"ask": {
"asked": "Asked:",
"awaiting": "Awaiting user input:",
"awaitingUnnamed": "Awaiting user input",
"questionCount": "{{value0}} questions"
},
"composer": {
+1
View File
@@ -17800,6 +17800,7 @@
},
"ask": {
"awaiting": "Awaiting user input:",
"awaitingUnnamed": "Awaiting user input",
"asked": "Asked:",
"questionCount": "{{value0}} questions"
},
+1
View File
@@ -17745,6 +17745,7 @@
"railEmptyMessage": "Message",
"ask": {
"awaiting": "En attente de la saisie de l'utilisateur :",
"awaitingUnnamed": "En attente de la saisie de l'utilisateur",
"asked": "Demandé:",
"questionCount": "{{value0}} questions"
},
+1
View File
@@ -17681,6 +17681,7 @@
"railEmptyMessage": "メッセージ",
"ask": {
"awaiting": "ユーザー入力を待っています:",
"awaitingUnnamed": "ユーザー入力を待っています",
"asked": "質問:",
"questionCount": "{{value0}} の質問"
},
+1
View File
@@ -17681,6 +17681,7 @@
"railEmptyMessage": "메시지",
"ask": {
"awaiting": "사용자 입력을 기다리는 중:",
"awaitingUnnamed": "사용자 입력을 기다리는 중",
"asked": "질문:",
"questionCount": "{{value0}} 질문"
},
+1
View File
@@ -17646,6 +17646,7 @@
"railEmptyMessage": "信息",
"ask": {
"awaiting": "等待用户输入:",
"awaitingUnnamed": "等待用户输入",
"asked": "问:",
"questionCount": "{{value0}} 问题"
},
@@ -29,6 +29,8 @@ function referenceProjection(map: AppState['agentStatusByPaneKey']): string {
prompt: entry.prompt,
updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS),
stateStartedAt: entry.stateStartedAt,
// A new turn can keep the state (child work held it open); its stamp alone must republish.
turnStartedAt: entry.turnStartedAt ?? null,
agentType: entry.agentType ?? null,
terminalTitle: entry.terminalTitle ?? null,
stateHistory: entry.stateHistory.map((history) => ({
@@ -77,6 +79,7 @@ describe('mobile agent-status projection equivalence', () => {
statusMaps.push({})
statusMaps.push({ 'tab-0:leaf-0': makeEntry(0) })
statusMaps.push({ 'tab-0:leaf-0': makeEntry(0, { workingMode: 'monitoring' }) })
statusMaps.push({ 'tab-0:leaf-0': makeEntry(0, { turnStartedAt: 1740000000500 }) })
const many: AppState['agentStatusByPaneKey'] = {}
for (let index = 0; index < 12; index += 1) {
many[`tab-${index}:leaf-0`] = makeEntry(index)
@@ -111,6 +111,7 @@ function referenceAgentStatusProjection(map: AppState['agentStatusByPaneKey']):
prompt: entry.prompt,
updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS),
stateStartedAt: entry.stateStartedAt,
turnStartedAt: entry.turnStartedAt ?? null,
agentType: entry.agentType ?? null,
terminalTitle: entry.terminalTitle ?? null,
stateHistory: entry.stateHistory.map((history) => ({
@@ -19,6 +19,7 @@ function serializeAgentStatusEntry(
prompt: entry.prompt,
updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS),
stateStartedAt: entry.stateStartedAt,
turnStartedAt: entry.turnStartedAt ?? null,
agentType: entry.agentType ?? null,
terminalTitle: entry.terminalTitle ?? null,
stateHistory: entry.stateHistory.map((history) => ({
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { AgentStateHistoryEntry } from '../../../../shared/agent-status-types'
import { sameAgentStateHistory } from './state-equality-core'
import type {
AgentStateHistoryEntry,
AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { agentStatusEntryEqual, sameAgentStateHistory } from './state-equality-core'
function doneEntry(mainAgent?: AgentStateHistoryEntry['mainAgent']): AgentStateHistoryEntry {
return { state: 'done', prompt: 'ship it', startedAt: 1_000, ...(mainAgent ? { mainAgent } : {}) }
@@ -25,3 +28,20 @@ describe('sameAgentStateHistory', () => {
).toBe(false)
})
})
describe('agentStatusEntryEqual', () => {
// A paired client must take a host row whose only change is a new turn under the same state.
it('sees a new host turn start under an unchanged state', () => {
const entry: AgentStatusEntry = {
state: 'working',
prompt: 'ship it',
updatedAt: 2_000,
stateStartedAt: 1_000,
turnStartedAt: 1_000,
paneKey: 'tab-1:leaf-1',
stateHistory: []
}
expect(agentStatusEntryEqual(entry, { ...entry })).toBe(true)
expect(agentStatusEntryEqual(entry, { ...entry, turnStartedAt: 1_500 })).toBe(false)
})
})
@@ -48,6 +48,7 @@ export function agentStatusEntryEqual(
a.prompt === b.prompt &&
a.updatedAt === b.updatedAt &&
a.stateStartedAt === b.stateStartedAt &&
a.turnStartedAt === b.turnStartedAt &&
a.agentType === b.agentType &&
a.paneKey === b.paneKey &&
a.worktreeId === b.worktreeId &&
@@ -88,6 +88,8 @@ export type AgentStatusTiming = {
/** Observation clock for staleness; see `AgentStatusEntry.evidenceObservedAt`. */
evidenceObservedAt?: number
stateStartedAt?: number
/** The host's turn start; see `AgentStatusEntry.turnStartedAt`. */
turnStartedAt?: number
}
export type AgentStatusRouting = {
@@ -113,6 +113,11 @@ export function buildAgentStatusLiveEntry(
: existing && existing.state === payload.state
? existing.stateStartedAt
: updatedAt)
// Why: a writer with no turn clock (an OSC repaint) keeps the host's stamp only within one state;
// a state change it cannot date must not inherit another turn's start.
const turnStartedAt =
timing?.turnStartedAt ??
(existing && existing.state === payload.state ? existing.turnStartedAt : undefined)
if (
existing &&
shouldSuppressInheritedTerminalStatus({
@@ -205,6 +210,7 @@ export function buildAgentStatusLiveEntry(
...(metadata?.structuredHostOwned === true ? { structuredHostOwned: true as const } : {}),
stateStartedAt,
stateObservedAt,
...(turnStartedAt !== undefined ? { turnStartedAt } : {}),
agentType: identity.agentType,
model:
payload.model ?? (existing?.agentType === identity.agentType ? existing.model : undefined),
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { createTestStore } from './store-test-helpers'
const PANE = 'tab-1:11111111-1111-4111-8111-111111111111'
describe("the host's turn start on a renderer status entry", () => {
it('lands on the entry from the host timing and follows each host write', () => {
const store = createTestStore()
store
.getState()
.setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude' }, undefined, {
updatedAt: 1_000,
stateStartedAt: 1_000,
turnStartedAt: 900
})
expect(store.getState().agentStatusByPaneKey[PANE]?.turnStartedAt).toBe(900)
// The same prompt sent again: the state never left working, the host stamped a new turn.
store
.getState()
.setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude' }, undefined, {
updatedAt: 2_000,
stateStartedAt: 1_000,
turnStartedAt: 1_900
})
expect(store.getState().agentStatusByPaneKey[PANE]?.turnStartedAt).toBe(1_900)
})
it('keeps the stamp behind an unchanged state when a writer carries none, and drops it on a state edge', () => {
const store = createTestStore()
store
.getState()
.setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude' }, undefined, {
updatedAt: 1_000,
stateStartedAt: 1_000,
turnStartedAt: 900
})
// A renderer-side OSC repaint has no turn clock of its own.
store.getState().setAgentStatus(PANE, {
state: 'working',
prompt: 'go',
agentType: 'claude',
toolName: 'Bash'
})
expect(store.getState().agentStatusByPaneKey[PANE]?.turnStartedAt).toBe(900)
store.getState().setAgentStatus(PANE, { state: 'done', prompt: 'go', agentType: 'claude' })
expect(store.getState().agentStatusByPaneKey[PANE]?.turnStartedAt).toBeUndefined()
})
})
+4
View File
@@ -55,6 +55,10 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
evidenceObservedAt?: number
/** Timestamp (ms) when the current state first appeared for this pane. */
stateStartedAt: number
/** When the main agent's current turn began, stamped by the hook server from the main agent's
* own turn-opening event. Optional: old hosts and turns opened unseen omit it, and readers fall
* back to `stateStartedAt`. */
turnStartedAt?: number
orchestration?: AgentStatusOrchestrationContext
providerSession?: AgentProviderSessionMetadata
/** Resume identity update only; the status-shaped fields are transport placeholders. */
+3
View File
@@ -95,6 +95,9 @@ export type AgentStatusEntry = {
stateStartedAt: number
/** `updatedAt` of the write that switched into `state`; see AgentStateHistoryEntry.observedAt. */
stateObservedAt?: number
/** When the main agent's current turn began, as the hook server stamped it. Absent from old hosts
* and writers without a turn clock; readers fall back to `stateStartedAt`. */
turnStartedAt?: number
agentType?: AgentType
/** Provider model currently used by this session. */
model?: string
+1
View File
@@ -9,6 +9,7 @@ import { pairToolBlocks } from './native-chat-tool-fold'
export const NATIVE_CHAT_ASK_ROW_COPY = {
awaiting: 'Awaiting user input:',
awaitingUnnamed: 'Awaiting user input',
asked: 'Asked:',
questionCount: '{{value0}} questions'
} as const