From f6dbd0f3959d2f490e8b4dcb52d344afe5af6cd5 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 10:23:34 +0100 Subject: [PATCH] Keep Assistant footer on freshest stream activity --- .../v6/internal/subsystems/ai-runtime.md | 13 ++ .../src/components/AI/Chat/ThinkingBlock.tsx | 6 +- .../Chat/__tests__/activeTurnStatus.test.ts | 126 ++++++++++ .../__tests__/streamEventGrouping.test.ts | 14 ++ .../AI/Chat/__tests__/useChat.test.ts | 10 +- .../components/AI/Chat/activeTurnStatus.ts | 216 ++++++++++++++++-- .../src/components/AI/Chat/hooks/useChat.ts | 48 +++- .../components/AI/Chat/reasoningSummary.ts | 4 + .../components/AI/Chat/streamEventGrouping.ts | 7 +- 9 files changed, 409 insertions(+), 35 deletions(-) create mode 100644 frontend-modern/src/components/AI/Chat/reasoningSummary.ts diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 1109ff10f..e4f8aeb60 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -278,6 +278,19 @@ runtime cost control, and shared AI transport surfaces. showing a live `Thinking:`/completed `Thought:` row with duration and optional provider summary title while keeping the raw reasoning body out of the transcript. + The active-turn status strip follows the same source-backed part freshness + rule: OpenCode commit `9ed17da55ab1f7360cc0e01075f763e27fa899e9` + updates live assistant parts in place through + `packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx` + (`latestTool`, `latestText`, `latestReasoning`, and `apply(event)`), + renders reasoning headers with `ReasoningPart`/`ReasoningHeader` in + `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx`, and keeps the + prompt footer as the replacing live status surface in + `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`. Pulse adapts + that by ranking workflow, content, hidden reasoning, and pending-tool footer + copy by the freshest activity timestamp: a later answer token can replace an + older tool status, and a later in-place tool progress patch can replace the + answer status again without moving the transcript's chronological row. OpenCode-parity Assistant UX work must reference OpenCode's actual source implementation for message parts, tool-state mutation, progress rendering, and model/session selection before changing Pulse behavior; parity means diff --git a/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx b/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx index 610d291f3..be5bd7f9e 100644 --- a/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx @@ -1,5 +1,6 @@ import { Component, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import BrainIcon from 'lucide-solid/icons/brain'; +import { extractReasoningSummaryTitle } from './reasoningSummary'; interface ThinkingBlockProps { content?: string; @@ -8,11 +9,6 @@ interface ThinkingBlockProps { updatedAt?: number; } -const extractReasoningSummaryTitle = (content?: string): string => { - const match = content?.trim().match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/); - return match?.[1]?.trim().replace(/\s+/g, ' ') || ''; -}; - const formatThinkingDuration = (durationMs: number): string => { if (!Number.isFinite(durationMs) || durationMs < 0) return ''; if (durationMs < 1000) return '<1s'; diff --git a/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts index 2b1935c60..ac1455df0 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts @@ -210,6 +210,132 @@ describe('getAssistantActiveTurnStatus', () => { }); }); + it('lets newer streamed content replace an older pending tool footer status', () => { + expect( + getAssistantActiveTurnStatus( + [ + assistantMessage({ + pendingTools: [ + { + id: 'tool-1', + name: 'pulse_get_nodes', + input: '{}', + progress: 'Reading node inventory', + status: 'running', + startedAt: 1_000, + updatedAt: 1_500, + }, + ], + streamEvents: [ + { + type: 'pending_tool', + toolId: 'tool-1', + pendingTool: { + id: 'tool-1', + name: 'pulse_get_nodes', + input: '{}', + progress: 'Reading node inventory', + status: 'running', + startedAt: 1_000, + updatedAt: 1_500, + }, + }, + { + type: 'content', + content: 'The node inventory is healthy.', + startedAt: 2_000, + updatedAt: 2_500, + }, + ], + }), + ], + true, + ), + ).toEqual({ + type: 'generating', + text: 'Generating response', + startedAt: 2_000, + }); + }); + + it('lets a newer in-place tool progress patch replace content footer status', () => { + expect( + getAssistantActiveTurnStatus( + [ + assistantMessage({ + pendingTools: [ + { + id: 'tool-1', + name: 'pulse_read', + input: '{}', + progress: 'Reading storage layout', + status: 'running', + startedAt: 1_000, + updatedAt: 3_000, + }, + ], + streamEvents: [ + { + type: 'pending_tool', + toolId: 'tool-1', + pendingTool: { + id: 'tool-1', + name: 'pulse_read', + input: '{}', + progress: 'Reading storage layout', + status: 'running', + startedAt: 1_000, + updatedAt: 3_000, + }, + }, + { + type: 'content', + content: 'I found the node list.', + startedAt: 2_000, + updatedAt: 2_500, + }, + ], + }), + ], + true, + ), + ).toEqual({ + type: 'tool', + text: 'Reading storage layout', + startedAt: 1_000, + }); + }); + + it('surfaces newer hidden reasoning metadata in the footer after content', () => { + expect( + getAssistantActiveTurnStatus( + [ + assistantMessage({ + streamEvents: [ + { + type: 'content', + content: 'The device count is', + startedAt: 1_000, + updatedAt: 1_500, + }, + { + type: 'thinking', + thinking: '**Checking device nodes**\n\nHidden reasoning body.', + startedAt: 2_000, + updatedAt: 2_500, + }, + ], + }), + ], + true, + ), + ).toEqual({ + type: 'thinking', + text: 'Thinking: Checking device nodes', + startedAt: 2_000, + }); + }); + it('surfaces fresh workflow progress after completed tool evidence', () => { expect( getAssistantActiveTurnStatus( diff --git a/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts index 8b65659bc..c4bda6ad9 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/streamEventGrouping.test.ts @@ -58,6 +58,20 @@ describe('groupStreamEventsForDisplay', () => { }); }); + it('preserves merged content activity timing', () => { + const grouped = groupStreamEventsForDisplay([ + { type: 'content', content: 'Hello ', startedAt: 1_000, updatedAt: 1_100 }, + { type: 'content', content: 'world', startedAt: 1_500, updatedAt: 2_500 }, + ]); + + expect(grouped[0]).toMatchObject({ + type: 'content', + content: 'Hello world', + startedAt: 1_000, + updatedAt: 2_500, + }); + }); + it('keeps content separated across a tool boundary so order is preserved', () => { const tool: StreamDisplayEvent = { type: 'tool', diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index 5646eb534..86723565e 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -835,6 +835,9 @@ describe('useChat', () => { const contentEvents = assistant.streamEvents?.filter((e) => e.type === 'content') ?? []; expect(contentEvents).toHaveLength(1); expect(contentEvents[0].content).toBe('Hello world'); + expect(contentEvents[0].startedAt).toEqual(expect.any(Number)); + expect(contentEvents[0].updatedAt).toEqual(expect.any(Number)); + expect(contentEvents[0].updatedAt).toBeGreaterThanOrEqual(contentEvents[0].startedAt || 0); dispose(); }); @@ -920,7 +923,7 @@ describe('useChat', () => { expect(assistant.content).toBe('I will inspect the device nodes.'); expect(assistant.content).not.toContain('pulse_read'); expect(assistant.content).not.toContain('raw arguments'); - expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toEqual([ + expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toMatchObject([ { type: 'content', content: 'I will inspect the device nodes.' }, ]); dispose(); @@ -945,7 +948,7 @@ describe('useChat', () => { expect(assistant.content).not.toContain('pulse_read'); expect(assistant.content).not.toContain('target_host'); expect(assistant.content).not.toContain('raw arguments'); - expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toEqual([ + expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toMatchObject([ { type: 'content', content: 'I will check ' }, ]); dispose(); @@ -999,7 +1002,7 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.content).toBe(compacted); - expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toEqual([ + expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toMatchObject([ { type: 'content', content: compacted }, ]); expect(assistant.isStreaming).toBe(false); @@ -2450,6 +2453,7 @@ describe('useChat', () => { success: true, }, toolId: 'orphan', + updatedAt: expect.any(Number), }); // pendingTools should remain empty (nothing to remove) expect(assistant.pendingTools).toHaveLength(0); diff --git a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts index 831befc4b..71a0d7b88 100644 --- a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts +++ b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts @@ -1,5 +1,6 @@ import type { ChatMessage, PendingTool, StreamDisplayEvent, WorkflowStatus } from './types'; import { formatIdentifierLabel } from '@/utils/textPresentation'; +import { extractReasoningSummaryTitle } from './reasoningSummary'; export type AssistantActiveTurnStatusKind = 'thinking' | 'tool' | 'generating'; @@ -9,6 +10,11 @@ export interface AssistantActiveTurnStatus { startedAt?: number; } +interface AssistantActiveTurnStatusCandidate extends AssistantActiveTurnStatus { + activityAt?: number; + order: number; +} + const formatToolName = (name?: string) => formatIdentifierLabel(name, { stripPrefix: 'pulse_', fallback: 'tool', maxLength: 36 }); @@ -77,11 +83,169 @@ const activePendingToolFromEvents = ( const latestPendingToolActivity = (tool?: PendingTool): number => tool?.updatedAt ?? tool?.startedAt ?? 0; -const activePendingToolFromState = (pendingTools?: PendingTool[]): PendingTool | undefined => - pendingTools?.reduce((current, tool) => { - if (!current) return tool; - return latestPendingToolActivity(tool) >= latestPendingToolActivity(current) ? tool : current; - }, undefined); +const pendingToolCandidate = ( + tool: PendingTool | undefined, + order: number, +): AssistantActiveTurnStatusCandidate | null => { + const text = formatPendingToolStatus(tool); + if (!text) return null; + const activityAt = latestPendingToolActivity(tool) || undefined; + return { + type: 'tool', + text, + startedAt: tool?.startedAt, + activityAt, + order, + }; +}; + +const activePendingToolCandidateFromState = ( + pendingTools?: PendingTool[], +): AssistantActiveTurnStatusCandidate | null => + (pendingTools || []).reduce((current, tool, index) => { + const candidate = pendingToolCandidate(tool, index); + if (!candidate) return current; + return isFresherStatusCandidate(candidate, current) ? candidate : current; + }, null); + +const thinkingStatusText = (event: StreamDisplayEvent): string => { + if (!event.thinking?.trim()) return ''; + const title = extractReasoningSummaryTitle(event.thinking); + return title ? `Thinking: ${title}` : 'Thinking'; +}; + +const eventActivityAt = (event: StreamDisplayEvent): number | undefined => { + if (event.type === 'pending_tool') { + return ( + latestPendingToolActivity(event.pendingTool) || + event.updatedAt || + event.startedAt || + undefined + ); + } + return event.updatedAt || event.startedAt || undefined; +}; + +const toolCompletionKeys = (event: StreamDisplayEvent): string[] => + [event.toolId, event.tool?.name] + .map((value) => value?.trim()) + .filter((value): value is string => !!value); + +const pendingToolKeys = (event: StreamDisplayEvent): string[] => + [event.toolId, event.pendingTool?.id, event.pendingTool?.name] + .map((value) => value?.trim()) + .filter((value): value is string => !!value); + +const isFresherStatusCandidate = ( + candidate: AssistantActiveTurnStatusCandidate, + current: AssistantActiveTurnStatusCandidate | null, +): boolean => { + if (!current) return true; + const candidateTime = candidate.activityAt; + const currentTime = current.activityAt; + if (candidateTime !== undefined && currentTime !== undefined && candidateTime !== currentTime) { + return candidateTime > currentTime; + } + if (candidateTime !== undefined && currentTime === undefined) return true; + if (candidateTime === undefined && currentTime !== undefined) return false; + return candidate.order >= current.order; +}; + +const latestStreamActivityStatus = ( + events?: StreamDisplayEvent[], +): AssistantActiveTurnStatusCandidate | null => { + const completedToolKeys = new Set(); + let current: AssistantActiveTurnStatusCandidate | null = null; + + for (let index = (events?.length || 0) - 1; index >= 0; index -= 1) { + const event = events?.[index]; + if (!event) continue; + + let candidate: AssistantActiveTurnStatusCandidate | null = null; + switch (event.type) { + case 'tool': { + for (const key of toolCompletionKeys(event)) { + completedToolKeys.add(key); + } + break; + } + case 'pending_tool': { + const keys = pendingToolKeys(event); + if (!keys.some((key) => completedToolKeys.has(key))) { + candidate = pendingToolCandidate(event.pendingTool, index); + } + break; + } + case 'thinking': { + const text = thinkingStatusText(event); + if (text) { + candidate = { + type: 'thinking', + text, + startedAt: event.startedAt, + activityAt: eventActivityAt(event), + order: index, + }; + } + break; + } + case 'content': { + if (event.content?.trim()) { + candidate = { + type: 'generating', + text: 'Generating response', + startedAt: event.startedAt, + activityAt: eventActivityAt(event), + order: index, + }; + } + break; + } + case 'approval': + if (event.approval) { + candidate = { + type: 'thinking', + text: 'Waiting for approval', + activityAt: eventActivityAt(event), + order: index, + }; + } + break; + case 'question': + if (event.question) { + candidate = { + type: 'thinking', + text: 'Waiting for answer', + activityAt: eventActivityAt(event), + order: index, + }; + } + break; + default: + break; + } + + if (candidate && isFresherStatusCandidate(candidate, current)) { + current = candidate; + } + } + + return current; +}; + +const workflowStatusCandidate = ( + status: WorkflowStatus | undefined, +): AssistantActiveTurnStatusCandidate | null => { + const text = formatAssistantWorkflowStatus(status); + if (!text) return null; + return { + type: status?.tool ? 'tool' : 'thinking', + text, + startedAt: status?.startedAt, + activityAt: status?.startedAt, + order: Number.MAX_SAFE_INTEGER, + }; +}; const hasVisibleAssistantOutput = (message: ChatMessage): boolean => { if ((message.content || '').trim() || message.error) return true; @@ -106,24 +270,38 @@ export const getAssistantActiveTurnStatus = ( return { type: 'thinking', text: 'Waiting for assistant' }; } - const pendingTool = - activePendingToolFromState(assistantMessage.pendingTools) || - activePendingToolFromEvents(assistantMessage.streamEvents); - const pendingToolText = formatPendingToolStatus(pendingTool); - if (pendingToolText) { - return { type: 'tool', text: pendingToolText, startedAt: pendingTool?.startedAt }; + const statusCandidates: AssistantActiveTurnStatusCandidate[] = []; + const statePendingToolCandidate = activePendingToolCandidateFromState(assistantMessage.pendingTools); + if (statePendingToolCandidate) { + statusCandidates.push(statePendingToolCandidate); + } + const eventStatusCandidate = latestStreamActivityStatus(assistantMessage.streamEvents); + if (eventStatusCandidate) { + statusCandidates.push(eventStatusCandidate); + } else { + const pendingTool = activePendingToolFromEvents(assistantMessage.streamEvents); + const eventPendingToolCandidate = pendingToolCandidate(pendingTool, 0); + if (eventPendingToolCandidate) { + statusCandidates.push(eventPendingToolCandidate); + } + } + if (assistantMessage.isStreaming !== false) { + const workflowCandidate = workflowStatusCandidate(assistantMessage.workflowStatus); + if (workflowCandidate) { + statusCandidates.push(workflowCandidate); + } } - const workflowStatusText = - assistantMessage.isStreaming === false - ? '' - : formatAssistantWorkflowStatus(assistantMessage.workflowStatus); + const freshestStatus = statusCandidates.reduce( + (current, candidate) => (isFresherStatusCandidate(candidate, current) ? candidate : current), + null, + ); - if (workflowStatusText) { + if (freshestStatus) { return { - type: assistantMessage.workflowStatus?.tool ? 'tool' : 'thinking', - text: workflowStatusText, - startedAt: assistantMessage.workflowStatus?.startedAt, + type: freshestStatus.type, + text: freshestStatus.text, + startedAt: freshestStatus.startedAt, }; } diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 05ac41f25..43ebc96bd 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -238,11 +238,17 @@ export function useChat(options: UseChatOptions = {}) { if (event.type === 'content' && events.length > 0) { const last = events[events.length - 1]; if (last.type === 'content') { + const now = Date.now(); return { ...msg, streamEvents: [ ...events.slice(0, -1), - { ...last, content: (last.content || '') + (event.content || '') }, + { + ...last, + content: (last.content || '') + (event.content || ''), + startedAt: last.startedAt || event.startedAt || now, + updatedAt: event.updatedAt || now, + }, ], }; } @@ -721,7 +727,13 @@ export function useChat(options: UseChatOptions = {}) { : msg; if (!visible.text) return baseMsg; // Add to streamEvents for chronological display - const updated = addStreamEvent(baseMsg, { type: 'content', content: visible.text }); + const now = Date.now(); + const updated = addStreamEvent(baseMsg, { + type: 'content', + content: visible.text, + startedAt: now, + updatedAt: now, + }); return { ...updated, content: appendMessageContent(baseMsg, visible.text), @@ -900,12 +912,20 @@ export function useChat(options: UseChatOptions = {}) { } return true; }); - updatedEvents.push({ type: 'tool', tool: newToolCall, toolId: data.id }); + const completedAt = Date.now(); + updatedEvents.push({ + type: 'tool', + tool: newToolCall, + toolId: data.id, + startedAt: pendingTools[resolvedPendingIndex]?.startedAt, + updatedAt: completedAt, + }); } else { // No approval - replace the pending_tool in place. If the terminal // event is the first visible evidence, keep the completed row. updatedEvents = [...events]; let replacedPendingTool = false; + const completedAt = Date.now(); for (let i = events.length - 1; i >= 0; i--) { const evt = events[i]; if ( @@ -914,13 +934,24 @@ export function useChat(options: UseChatOptions = {}) { ? evt.toolId === data.id : normalizeChatToolName(evt.pendingTool?.name || '') === normalizedEndName) ) { - updatedEvents[i] = { type: 'tool', tool: newToolCall, toolId: data.id }; + updatedEvents[i] = { + type: 'tool', + tool: newToolCall, + toolId: data.id, + startedAt: evt.pendingTool?.startedAt || evt.startedAt, + updatedAt: completedAt, + }; replacedPendingTool = true; break; } } if (!replacedPendingTool) { - updatedEvents.push({ type: 'tool', tool: newToolCall, toolId: data.id }); + updatedEvents.push({ + type: 'tool', + tool: newToolCall, + toolId: data.id, + updatedAt: completedAt, + }); } } @@ -1068,7 +1099,12 @@ export function useChat(options: UseChatOptions = {}) { clearOutputArtifactState(assistantId); const flushedMsg = pendingText ? { - ...addStreamEvent(msg, { type: 'content', content: pendingText }), + ...addStreamEvent(msg, { + type: 'content', + content: pendingText, + startedAt: Date.now(), + updatedAt: Date.now(), + }), content: appendMessageContent(msg, pendingText), } : msg; diff --git a/frontend-modern/src/components/AI/Chat/reasoningSummary.ts b/frontend-modern/src/components/AI/Chat/reasoningSummary.ts new file mode 100644 index 000000000..1539143af --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/reasoningSummary.ts @@ -0,0 +1,4 @@ +export const extractReasoningSummaryTitle = (content?: string): string => { + const match = content?.trim().match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/); + return match?.[1]?.trim().replace(/\s+/g, ' ') || ''; +}; diff --git a/frontend-modern/src/components/AI/Chat/streamEventGrouping.ts b/frontend-modern/src/components/AI/Chat/streamEventGrouping.ts index dc0993ec0..c55c1a626 100644 --- a/frontend-modern/src/components/AI/Chat/streamEventGrouping.ts +++ b/frontend-modern/src/components/AI/Chat/streamEventGrouping.ts @@ -29,9 +29,12 @@ export const groupStreamEventsForDisplay = ( case 'content': { if (!evt.content) break; // skip empty deltas if (contentIdx >= 0) { + const current = grouped[contentIdx]; grouped[contentIdx] = { - ...grouped[contentIdx], - content: (grouped[contentIdx].content || '') + evt.content, + ...current, + content: (current.content || '') + evt.content, + startedAt: current.startedAt || evt.startedAt, + updatedAt: evt.updatedAt || current.updatedAt, }; } else { grouped.push({ ...evt });