diff --git a/src/main/agent-hooks/server-turn-started-at.test.ts b/src/main/agent-hooks/server-turn-started-at.test.ts new file mode 100644 index 00000000000..63ebd136be6 --- /dev/null +++ b/src/main/agent-hooks/server-turn-started-at.test.ts @@ -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 { + 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 { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production', userDataPath }) + return server + } + + async function post(server: AgentHookServer, payload: Record): Promise { + 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) + }) +}) diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index f88766217c1..7203f174a0e 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -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 } + : {}) } } diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 8863dc178b9..674fa8a0760 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -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 } } diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 1694c4b1b67..4400b43efee 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -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 } : {}), diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index d1466797798..79b4e569cdd 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -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. */ diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 5899b2fe6bd..38bb380d724 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -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 } : {}), diff --git a/src/main/startup/main-window-structured-status-filter.test.ts b/src/main/startup/main-window-structured-status-filter.test.ts index c7fe55da626..da26a593d00 100644 --- a/src/main/startup/main-window-structured-status-filter.test.ts +++ b/src/main/startup/main-window-structured-status-filter.test.ts @@ -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') +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx index 8e621a8a441..9549fc933e7 100644 --- a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx @@ -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 diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx index cd08f779e71..303ab2472c7 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx @@ -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(), @@ -45,6 +46,8 @@ function renderCard(canSend = true): ReturnType { 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 ( - + ) +} + +// 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 ( + void @@ -51,26 +36,7 @@ export function NativeChatInteractiveCard({ * a target while the composer is unmounted. */ answerInputRef?: React.RefObject }): 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(null) // A question answer is a paced multi-step write (body→Enter per question); keep diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx index 4be1ea7cdb3..82b2c43d468 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx @@ -70,7 +70,6 @@ function transcript(messages: NativeChatMessage[], sessionId = 'live-codex') { isWorking={false} expandSignal fontScale={1} - showTurnStatus={false} /> ) } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx index 2a2401bea52..a77d2907b6a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx @@ -44,15 +44,7 @@ function Transcript({ items }: { items: AgentJournalRenderItem[] }) { loadEarlier, readPhase: 'ready' } - return ( - - ) + return } function row(index: number, body: AgentJournalRenderItem['body']): AgentJournalRenderItem { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index ee697732671..6e0c29d9d71 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -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 - /** 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( () => ({ 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' ? ( + ) : tailRow === 'awaiting-input' ? ( + ) : null} - {!showTurnStatus && showTypingIndicator ? : null} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx index 8400b592e7d..e8d714fad1a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx @@ -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( + + ) + + 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( { expect(liveRun?.querySelector('.lucide-check')).toBeInTheDocument() }) - it('keeps bridge chats on the legacy activity chrome', () => { - render( - - ) - - expect(screen.queryByText('Thinking')).toBeNull() - expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() - expect(screen.queryByText('Running sleep 5')).toBeNull() - expect(document.querySelectorAll('.animate-bounce')).toHaveLength(3) - }) - it('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} /> ) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 59e70cfc7e1..e43a758051b 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -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} diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index fb432e1e5b4..786ade25ebc 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -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({ diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx new file mode 100644 index 00000000000..ba2a7c00de2 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx @@ -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, 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): void { + useAppStore.setState((store) => ({ + agentStatusByPaneKey: { + ...store.agentStatusByPaneKey, + [paneKey]: { ...store.agentStatusByPaneKey[paneKey]!, ...fields } + } + })) +} + +function renderPane(): void { + render( + + ) +} + +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: '\nb1' }], + 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() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 5689cf9689f..1f97046363c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -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 } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index e766955b83e..9a40f63530d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -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', { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 26f04ae2e3c..a6bb8ad8d28 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -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} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 0e6994a7b8b..81a8f53300f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -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 | { diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx index 870bf39a409..b4257648efa 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx @@ -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() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 141ae739886..a6cb100ff58 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -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 ? ( ) : 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 ? ( ) : null} {latestCallLabel ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index 54499e3dc31..91e71df2140 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -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 expandedTurnIds: ReadonlySet @@ -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} diff --git a/src/renderer/src/components/native-chat/NativeChatTypingIndicatorRow.tsx b/src/renderer/src/components/native-chat/NativeChatTypingIndicatorRow.tsx deleted file mode 100644 index 59969c1dece..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatTypingIndicatorRow.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { translate } from '@/i18n/i18n' - -export function NativeChatTypingIndicatorRow(): React.JSX.Element { - return ( -
-
- {[0, 1, 2].map((i) => ( - - ))} -
-
- ) -} diff --git a/src/renderer/src/components/native-chat/native-chat-live-session-contract.ts b/src/renderer/src/components/native-chat/native-chat-live-session-contract.ts index e3097ca085f..d61d11b9ac1 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-session-contract.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-session-contract.ts @@ -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. */ diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.test.ts b/src/renderer/src/components/native-chat/native-chat-live-status.test.ts index ce8a2875df0..9dcd683dcd9 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/renderer/src/components/native-chat/native-chat-live-status.ts index eefd7fcbeca..f3d2bb58be9 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.ts @@ -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 diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts index fa4995b4755..3a08c6a503e 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts @@ -47,7 +47,6 @@ function slotsOf(messages: NativeChatMessage[]) { receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), - showTurnStatus: false, expandedTurnKeys: new Set(), isWorking: false, lifecycleWorking: false diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index ba8e6fa8f21..b1efe034acd 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -86,7 +86,6 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), - showTurnStatus: true, expandedTurnKeys: new Set(), isWorking: false, lifecycleWorking: false diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts b/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts new file mode 100644 index 00000000000..4e77c18ace6 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts @@ -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: '\nb1' }] + }, + 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: '\nb1' }] + } + ]) + ).toBe('u1') + expect(nativeChatLatestTurnId([row('a1', 'assistant', 0)])).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts b/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts new file mode 100644 index 00000000000..2926c5d75f2 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts @@ -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() + 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 +} diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 21e48166e9c..4c908375576 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -43,7 +43,6 @@ function build( receipts: new Map(), turnStatuses: NO_STATUSES, turnDiffs: new Map(), - showTurnStatus: true, expandedTurnKeys: new Set(), isWorking: false, lifecycleWorking: false, diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 1b20671ce24..b3631fb8012 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -75,7 +75,6 @@ export type NativeChatTranscriptSlotsInput = { completedByTurn: Readonly> } turnDiffs: ReadonlyMap - showTurnStatus: boolean /** Turns the reader opened. Everything else with a duration stays folded. */ expandedTurnKeys: ReadonlySet 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) diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts deleted file mode 100644 index b53b217eb15..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts deleted file mode 100644 index 144560278a2..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts +++ /dev/null @@ -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 -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.test.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.test.ts new file mode 100644 index 00000000000..a13df87c3e8 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.test.ts @@ -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): 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' }) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts new file mode 100644 index 00000000000..243fea0ac57 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts @@ -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]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index cd4b701e270..4c7f784eb31 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -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: { diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index a62237f1b35..0f4a4058155 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -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, diff --git a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts index cbbd819ce22..6772eec7a47 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts @@ -36,7 +36,6 @@ function slotsOf(messages: NativeChatMessage[]) { receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), - showTurnStatus: false, expandedTurnKeys: new Set(), isWorking: false, lifecycleWorking: false diff --git a/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts b/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts new file mode 100644 index 00000000000..13aa5a93977 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-terminal-turn-timing.ts @@ -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 } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx index 1f08a51299a..948b83c5dbb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx @@ -23,7 +23,7 @@ function TranscriptHarness({ contentRef, itemCount, isWorking: false, - showTypingIndicator: false, + showsTailRow: false, isVisible, alignToViewportTop: vi.fn(), scrollToEnd, diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts index 23bebf0f1e8..6df6d3eb562 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts @@ -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 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 ]) diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts index 95db05141d2..33a795b8b9d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts @@ -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 diff --git a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts index 7cabf081db9..8f676cada26 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts @@ -47,6 +47,7 @@ export type AgentStatusSetData = { connectionId?: string | null receivedAt: number stateStartedAt: number + turnStartedAt?: number } export type StoreLike = Record export type StoreSubscribeListener = (state: StoreLike, previousState: StoreLike) => void diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index 47aa5521a26..41957078f41 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -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, diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts index e26448f0b63..f8f77eced61 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts @@ -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 ) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 90fc6192a11..4f72d98bb17 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2674,6 +2674,7 @@ "ask": { "asked": "Asked:", "awaiting": "Awaiting user input:", + "awaitingUnnamed": "Awaiting user input", "questionCount": "{{value0}} questions" }, "composer": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5db365466e6..30ac3af4db9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17800,6 +17800,7 @@ }, "ask": { "awaiting": "Awaiting user input:", + "awaitingUnnamed": "Awaiting user input", "asked": "Asked:", "questionCount": "{{value0}} questions" }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 04e9bb284ab..f378465a943 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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" }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 90f55805342..bcb68593d90 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17681,6 +17681,7 @@ "railEmptyMessage": "メッセージ", "ask": { "awaiting": "ユーザー入力を待っています:", + "awaitingUnnamed": "ユーザー入力を待っています", "asked": "質問:", "questionCount": "{{value0}} の質問" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index dd0dbdfa8a7..8aa0335d891 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17681,6 +17681,7 @@ "railEmptyMessage": "메시지", "ask": { "awaiting": "사용자 입력을 기다리는 중:", + "awaitingUnnamed": "사용자 입력을 기다리는 중", "asked": "질문:", "questionCount": "{{value0}} 질문" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1514dd25697..70cbc25bdc5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17646,6 +17646,7 @@ "railEmptyMessage": "信息", "ask": { "awaiting": "等待用户输入:", + "awaitingUnnamed": "等待用户输入", "asked": "问:", "questionCount": "{{value0}} 问题" }, diff --git a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts index 5bd9460041a..b78bb317585 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts @@ -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) diff --git a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts index 0adbd768fe5..587e30a769e 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts @@ -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) => ({ diff --git a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts index ba41d169e8c..25b65573839 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts @@ -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) => ({ diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts index ae51553ea7b..aa466c0f9b5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts index 197fb698137..407b7f6c2f3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts @@ -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 && diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index afff64d3303..64ff3e10dcf 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -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 = { diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 28bf952460c..ec80692f95e 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -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), diff --git a/src/renderer/src/store/slices/agent-status-turn-started-at.test.ts b/src/renderer/src/store/slices/agent-status-turn-started-at.test.ts new file mode 100644 index 00000000000..324b70e075f --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-turn-started-at.test.ts @@ -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() + }) +}) diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index a15282b0018..68398a6d0de 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -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. */ diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index acc7e4f4255..5a9d34ae7d3 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -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 diff --git a/src/shared/native-chat-ask-row.ts b/src/shared/native-chat-ask-row.ts index 11900c5c4ea..6d92f244f8e 100644 --- a/src/shared/native-chat-ask-row.ts +++ b/src/shared/native-chat-ask-row.ts @@ -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