From bf7b0bf1cb9815260ec8d2dae3aa78fddf58c8e8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 16:08:07 +0100 Subject: [PATCH] Show Assistant live workflow activity --- .../v6/internal/subsystems/ai-runtime.md | 18 ++++ .../src/components/AI/Chat/MessageItem.tsx | 37 +++++++-- .../AI/Chat/__tests__/MessageItem.test.tsx | 47 +++++++++-- .../Chat/__tests__/activeTurnStatus.test.ts | 41 ++++++++++ .../AI/Chat/__tests__/useChat.test.ts | 65 ++++++++++++++- .../components/AI/Chat/activeTurnStatus.ts | 16 ++++ .../src/components/AI/Chat/hooks/useChat.ts | 82 +++++++++++++++---- .../src/components/AI/Chat/types.ts | 2 + 8 files changed, 278 insertions(+), 30 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5bda1311b..d2694480f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -1523,6 +1523,24 @@ attempts. The frontend active-turn footer renders that same typed workflow state as compact attempt/backoff progress, so the user sees Pulse moving through a retry instead of staring at an obsolete provider-wait message. +Assistant workflow progress is also a live typed activity row while a turn is +in flight, not only hidden footer state. The referenced OpenCode source at +fetched `dev` commit `7ae856a9e97130f664f6f11fa5871a2795de9902` stores +session status separately from message parts in +`packages/opencode/src/cli/cmd/tui/context/sync.tsx` (`session_status`, +`session.status` event handling) and renders a session from live messages, +permissions, questions, and running tool parts in +`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` (`Session`, +`messages`, `permissions`, `questions`, foreground `ToolPart` selection, and +`session.status` handling). Pulse adapts that source pattern with a transient +frontend `workflow_status` display event: each incoming backend +`workflow_state` replaces the prior workflow row, the active footer reads the +same typed status, and visible assistant content, reasoning, tool, +approval/question, terminal `done`, and terminal `error` events clear that row. +The transcript therefore shows current motion while the provider is starting, +retrying, or reasoning, but completed answers do not retain stale +internal-progress prose. + Primary nav moved to governed platform/runtime destinations on 2026-05-16 and was clarified on 2026-05-25 through `frontend-modern/src/App.tsx` and `frontend-modern/src/AppLayout.tsx`: the top of the app may expose canonical diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index a141b8024..2de4b3854 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -24,6 +24,7 @@ import { ApprovalCard } from './ApprovalCard'; import { QuestionCard } from './QuestionCard'; import { ThinkingBlock } from './ThinkingBlock'; import { stripAssistantOutputArtifacts } from './assistantOutputHygiene'; +import { formatAssistantWorkflowStatus } from './activeTurnStatus'; import { groupStreamEventsForDisplay } from './streamEventGrouping'; import type { ChatMessage, @@ -111,6 +112,8 @@ export const MessageItem: Component = (props) => { switch (evt.type) { case 'thinking': return !!evt.thinking?.trim(); + case 'workflow_status': + return !!formatAssistantWorkflowStatus(evt.workflowStatus); case 'content': return !!stripAssistantOutputArtifacts(evt.content || '').text; case 'tool': @@ -185,10 +188,8 @@ export const MessageItem: Component = (props) => { onCleanup(() => window.clearInterval(interval)); }); const formatWorkflowStatus = (status?: WorkflowStatus, includeElapsed = false) => { - const message = status?.message?.trim(); + const message = formatAssistantWorkflowStatus(status); if (!message) return ''; - const tool = status?.tool?.trim(); - const toolSuffix = tool && !message.includes(tool) ? ` · ${formatIdentifierLabel(tool)}` : ''; let elapsedSuffix = ''; if (includeElapsed && status?.startedAt) { const elapsedSeconds = Math.max(0, Math.floor((statusNow() - status.startedAt) / 1000)); @@ -196,7 +197,7 @@ export const MessageItem: Component = (props) => { elapsedSuffix = ` (${elapsedSeconds}s)`; } } - return `${message}${toolSuffix}${elapsedSuffix}`; + return `${message}${elapsedSuffix}`; }; const workflowStatusText = createMemo(() => formatWorkflowStatus(props.message.workflowStatus, true), @@ -377,6 +378,31 @@ export const MessageItem: Component = (props) => { /> + +
+
+
+ @@ -409,8 +435,7 @@ export const MessageItem: Component = (props) => { when={isProviderFallbackEvent(evt)} fallback={ <> - Switched to - {' '} + Switched to{' '} {modelRouteLabel(evt.model)} diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index f10ba8669..9ce35378f 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -204,10 +204,7 @@ describe('MessageItem', () => { }); expect(retryViaOpenRouter).toHaveTextContent('Retry via OpenRouter'); fireEvent.click(retryViaOpenRouter); - expect(onUseModelRoute).toHaveBeenCalledWith( - 'openrouter:deepseek/deepseek-v4-pro', - 'msg-1', - ); + expect(onUseModelRoute).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro', 'msg-1'); const changeModel = screen.getByRole('button', { name: /change model/i }); fireEvent.click(changeModel); @@ -584,7 +581,47 @@ describe('MessageItem', () => { )); expect( - screen.getByText('Planning governed action and safety checks before execution. · pulse exec'), + screen.getByText('Planning governed action and safety checks before execution. · exec'), + ).toBeInTheDocument(); + expect(screen.queryByText('Thinking...')).not.toBeInTheDocument(); + }); + + it('shows live workflow progress as a transcript activity row', () => { + render(() => ( + + )); + + expect( + screen.getByText( + 'Provider connection failed before any output; retrying. · attempt 2/3 · retrying in 1.2s', + ), ).toBeInTheDocument(); expect(screen.queryByText('Thinking...')).not.toBeInTheDocument(); }); 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 c6e950c9a..5ea594bfd 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts @@ -576,6 +576,47 @@ describe('getAssistantActiveTurnStatus', () => { }); }); + it('surfaces the latest workflow activity row when it replaces older hidden status', () => { + expect( + getAssistantActiveTurnStatus( + [ + assistantMessage({ + isStreaming: true, + streamEvents: [ + { + type: 'tool', + toolId: 'tool-1', + tool: { + name: 'pulse_alerts', + input: '{}', + output: '11 active alerts', + success: true, + }, + startedAt: 1000, + updatedAt: 1100, + }, + { + type: 'workflow_status', + workflowStatus: { + phase: 'model_thinking', + message: 'Model is reasoning before responding.', + startedAt: 2000, + }, + startedAt: 2000, + updatedAt: 2000, + }, + ], + }), + ], + true, + ), + ).toEqual({ + type: 'thinking', + text: 'Model is reasoning before responding.', + startedAt: 2000, + }); + }); + it('ignores stale workflow status on a completed assistant turn', () => { expect( getAssistantActiveTurnStatus( 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 e333698b9..9a12967ff 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -1113,7 +1113,17 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.content).toBe(''); - expect(assistant.streamEvents).toEqual([]); + expect(assistant.streamEvents).toEqual([ + expect.objectContaining({ + type: 'workflow_status', + workflowStatus: expect.objectContaining({ + phase: 'plan', + message: 'Planning governed action and safety checks before execution.', + state: 'READING', + tool: 'pulse_exec', + }), + }), + ]); expect(assistant.workflowStatus).toEqual( expect.objectContaining({ phase: 'plan', @@ -1194,6 +1204,17 @@ describe('useChat', () => { retryAfterMs: 200, }), ); + expect(assistant.streamEvents).toEqual([ + expect.objectContaining({ + type: 'workflow_status', + workflowStatus: expect.objectContaining({ + phase: 'provider_retry', + attempt: 2, + maxAttempts: 2, + retryAfterMs: 200, + }), + }), + ]); dispose(); }); @@ -1243,7 +1264,14 @@ describe('useChat', () => { }, }); let assistant = chat.messages().find((m) => m.role === 'assistant')!; - expect(assistant.streamEvents).toEqual([]); + expect(assistant.streamEvents).toEqual([ + expect.objectContaining({ + type: 'workflow_status', + workflowStatus: expect.objectContaining({ + message: 'Reading current Pulse inventory with pulse_query.', + }), + }), + ]); expect(assistant.workflowStatus).toEqual( expect.objectContaining({ message: 'Reading current Pulse inventory with pulse_query.', @@ -1264,6 +1292,14 @@ describe('useChat', () => { message: 'Built compact inventory context for the model.', }), ); + expect(assistant.streamEvents).toEqual([ + expect.objectContaining({ + type: 'workflow_status', + workflowStatus: expect.objectContaining({ + message: 'Built compact inventory context for the model.', + }), + }), + ]); fire({ type: 'workflow_state', @@ -1278,6 +1314,14 @@ describe('useChat', () => { message: 'Sent request to OpenRouter; waiting for the first token.', }), ); + expect(assistant.streamEvents).toEqual([ + expect.objectContaining({ + type: 'workflow_status', + workflowStatus: expect.objectContaining({ + message: 'Sent request to OpenRouter; waiting for the first token.', + }), + }), + ]); dispose(); }); @@ -1301,6 +1345,7 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.content).toBe('Here is the answer.'); expect(assistant.workflowStatus).toBeUndefined(); + expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['content']); dispose(); }); @@ -1323,10 +1368,11 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.workflowStatus).toBeUndefined(); expect(assistant.pendingTools).toHaveLength(1); + expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['pending_tool']); dispose(); }); - it('keeps late workflow progress live after typed tool evidence without adding transcript rows', async () => { + it('keeps late workflow progress live after typed tool evidence as one current activity row', async () => { const { getFireEvent } = setupWithEventCapture(); const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); @@ -1366,7 +1412,18 @@ describe('useChat', () => { message: 'Model is reasoning before responding.', }), ); - expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['tool']); + expect(assistant.streamEvents?.map((event) => event.type)).toEqual([ + 'tool', + 'workflow_status', + ]); + expect(assistant.streamEvents?.[1]).toEqual( + expect.objectContaining({ + workflowStatus: expect.objectContaining({ + phase: 'model_thinking', + message: 'Model is reasoning before responding.', + }), + }), + ); expect(assistant.toolCalls).toHaveLength(1); dispose(); }); diff --git a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts index 670558168..8f2ae0dad 100644 --- a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts +++ b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts @@ -154,6 +154,9 @@ const thinkingStatusText = (event: StreamDisplayEvent): string => { }; const eventActivityAt = (event: StreamDisplayEvent): number | undefined => { + if (event.type === 'workflow_status') { + return event.workflowStatus?.startedAt || event.updatedAt || event.startedAt || undefined; + } if (event.type === 'pending_tool') { return ( latestPendingToolActivity(event.pendingTool) || @@ -228,6 +231,19 @@ const latestStreamActivityStatus = ( } break; } + case 'workflow_status': { + const text = formatAssistantWorkflowStatus(event.workflowStatus); + if (text) { + candidate = { + type: event.workflowStatus?.tool ? 'tool' : 'thinking', + text, + startedAt: event.workflowStatus?.startedAt || event.startedAt, + activityAt: eventActivityAt(event), + order: index, + }; + } + break; + } case 'content': { if (event.content?.trim()) { candidate = { diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 1e4eb29cd..8be9b3c5b 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -166,7 +166,10 @@ export function useChat(options: UseChatOptions = {}) { if (!events) return events; return events.filter( (event) => - event.type !== 'pending_tool' && event.type !== 'approval' && event.type !== 'question', + event.type !== 'pending_tool' && + event.type !== 'approval' && + event.type !== 'question' && + event.type !== 'workflow_status', ); }; @@ -310,6 +313,31 @@ export function useChat(options: UseChatOptions = {}) { }; }; + const streamEventsWithoutWorkflowStatus = ( + events: StreamDisplayEvent[] | undefined, + ): StreamDisplayEvent[] | undefined => + events?.filter((event) => event.type !== 'workflow_status'); + + const withClearedWorkflowStatusEvent = (msg: ChatMessage): ChatMessage => { + const streamEvents = streamEventsWithoutWorkflowStatus(msg.streamEvents); + if (streamEvents === msg.streamEvents) return msg; + return { ...msg, streamEvents }; + }; + + const withWorkflowStatusEvent = (msg: ChatMessage, workflowStatus: WorkflowStatus): ChatMessage => + addStreamEvent( + { + ...msg, + streamEvents: streamEventsWithoutWorkflowStatus(msg.streamEvents) || [], + }, + { + type: 'workflow_status', + workflowStatus, + startedAt: workflowStatus.startedAt, + updatedAt: workflowStatus.startedAt, + }, + ); + const normalizePendingToolStatus = (phase?: string): PendingTool['status'] => { const normalized = (phase || 'running').trim().toLowerCase(); if (normalized === 'pending') return 'pending'; @@ -426,7 +454,7 @@ export function useChat(options: UseChatOptions = {}) { return { ...msg, streamEvents: replacePendingToolStreamEvents( - msg.streamEvents || [], + streamEventsWithoutWorkflowStatus(msg.streamEvents) || [], resolvedTool, matchesTool, now, @@ -505,7 +533,12 @@ export function useChat(options: UseChatOptions = {}) { return { ...msg, streamEvents: resolvedTool - ? replacePendingToolStreamEvents(msg.streamEvents || [], resolvedTool, matchesTool, now) + ? replacePendingToolStreamEvents( + streamEventsWithoutWorkflowStatus(msg.streamEvents) || [], + resolvedTool, + matchesTool, + now, + ) : msg.streamEvents, workflowStatus: undefined, pendingTools: updatedPendingTools, @@ -859,6 +892,13 @@ export function useChat(options: UseChatOptions = {}) { ); } if (workflowStatus) { + setMessages((prev) => + prev.map((msg) => { + if (msg.id !== assistantId) return msg; + if (msg.isStreaming === false) return msg; + return withWorkflowStatusEvent(msg, workflowStatus); + }), + ); setAssistantWorkflowStatus(assistantId, requestId, workflowStatus); } return; @@ -892,9 +932,10 @@ export function useChat(options: UseChatOptions = {}) { ) : msg; if (!visible.text) return baseMsg; + const clearedMsg = withClearedWorkflowStatusEvent(baseMsg); // Add to streamEvents for chronological display const now = Date.now(); - const updated = addStreamEvent(baseMsg, { + const updated = addStreamEvent(clearedMsg, { type: 'content', content: visible.text, startedAt: now, @@ -902,7 +943,7 @@ export function useChat(options: UseChatOptions = {}) { }); return { ...updated, - content: appendMessageContent(baseMsg, visible.text), + content: appendMessageContent(clearedMsg, visible.text), workflowStatus: undefined, }; } @@ -911,7 +952,8 @@ export function useChat(options: UseChatOptions = {}) { const thinking = extractText(event.data); if (!thinking) return msg; const now = Date.now(); - const updated = addStreamEvent(msg, { + const clearedMsg = withClearedWorkflowStatusEvent(msg); + const updated = addStreamEvent(clearedMsg, { type: 'thinking', thinking, startedAt: now, @@ -976,7 +1018,7 @@ export function useChat(options: UseChatOptions = {}) { pendingTools: (msg.pendingTools || []).filter( (tool) => !matchesTool(tool, tool.id), ), - streamEvents: (msg.streamEvents || []).filter( + streamEvents: (streamEventsWithoutWorkflowStatus(msg.streamEvents) || []).filter( (streamEvent) => streamEvent.type !== 'pending_tool' || !matchesTool(streamEvent.pendingTool, streamEvent.toolId), @@ -995,7 +1037,7 @@ export function useChat(options: UseChatOptions = {}) { success: boolean; }; const pendingTools = msg.pendingTools || []; - const events = msg.streamEvents || []; + const events = streamEventsWithoutWorkflowStatus(msg.streamEvents) || []; const normalizedEndName = normalizeChatToolName(data.name || ''); @@ -1187,7 +1229,10 @@ export function useChat(options: UseChatOptions = {}) { } // Add to streamEvents for chronological display - const updated = addStreamEvent(msg, { type: 'approval', approval }); + const updated = addStreamEvent(withClearedWorkflowStatusEvent(msg), { + type: 'approval', + approval, + }); return { ...updated, @@ -1223,7 +1268,10 @@ export function useChat(options: UseChatOptions = {}) { }; // Add to streamEvents for chronological display - const updated = addStreamEvent(msg, { type: 'question', question: pendingQuestion }); + const updated = addStreamEvent(withClearedWorkflowStatusEvent(msg), { + type: 'question', + question: pendingQuestion, + }); return { ...updated, @@ -1234,6 +1282,7 @@ export function useChat(options: UseChatOptions = {}) { case 'done': { const completedAt = new Date(); + const terminalMsg = withClearedWorkflowStatusEvent(msg); const pendingText = suppressedRawContentMessageIds.has(assistantId) ? '' : flushPendingAssistantOutputText(outputArtifactStateFor(assistantId)); @@ -1241,15 +1290,15 @@ export function useChat(options: UseChatOptions = {}) { clearOutputArtifactState(assistantId); const flushedMsg = pendingText ? { - ...addStreamEvent(msg, { + ...addStreamEvent(terminalMsg, { type: 'content', content: pendingText, startedAt: Date.now(), updatedAt: Date.now(), }), - content: appendMessageContent(msg, pendingText), + content: appendMessageContent(terminalMsg, pendingText), } - : msg; + : terminalMsg; const tokens = extractTokens(event.data); const completedModel = extractCompletedModel(event.data); if (tokens && (tokens.input > 0 || tokens.output > 0)) { @@ -1284,16 +1333,19 @@ export function useChat(options: UseChatOptions = {}) { case 'error': { clearSuppressedOutputBoundary(assistantId); const errorMsg = extractErrorMessage(event.data); + const terminalMsg = withClearedWorkflowStatusEvent(msg); // Keep any content streamed before the failure; surface the error // as a distinct, recoverable block rather than overwriting the answer. return { - ...msg, + ...terminalMsg, isStreaming: false, completedAt: new Date(), pendingTools: [], pendingApprovals: [], pendingQuestions: [], - streamEvents: streamEventsWithoutUnresolvedInteractiveRows(msg.streamEvents), + streamEvents: streamEventsWithoutUnresolvedInteractiveRows( + terminalMsg.streamEvents, + ), workflowStatus: undefined, error: errorMsg || 'Request failed', }; diff --git a/frontend-modern/src/components/AI/Chat/types.ts b/frontend-modern/src/components/AI/Chat/types.ts index 47148b701..d3ff52458 100644 --- a/frontend-modern/src/components/AI/Chat/types.ts +++ b/frontend-modern/src/components/AI/Chat/types.ts @@ -96,6 +96,7 @@ export interface PendingQuestion { // Unified event for chronological display export type StreamEventType = | 'thinking' + | 'workflow_status' | 'tool' | 'content' | 'pending_tool' @@ -106,6 +107,7 @@ export type StreamEventType = export interface StreamDisplayEvent { type: StreamEventType; thinking?: string; + workflowStatus?: WorkflowStatus; startedAt?: number; updatedAt?: number; tool?: ToolExecution;