Show Assistant live workflow activity

This commit is contained in:
rcourtman
2026-06-06 16:08:07 +01:00
parent 2291924cb6
commit bf7b0bf1cb
8 changed files with 278 additions and 30 deletions
@@ -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
@@ -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<MessageItemProps> = (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<MessageItemProps> = (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<MessageItemProps> = (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<MessageItemProps> = (props) => {
/>
</Match>
<Match
when={
evt.type === 'workflow_status' &&
formatAssistantWorkflowStatus(evt.workflowStatus)
}
>
<div
class="my-1 inline-flex max-w-full items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-xs font-medium text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
role="status"
aria-live="polite"
title={formatWorkflowStatus(
evt.workflowStatus,
props.message.isStreaming,
)}
>
<span
class="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500 animate-pulse"
aria-hidden="true"
/>
<span class="min-w-0 truncate">
{formatWorkflowStatus(evt.workflowStatus, props.message.isStreaming)}
</span>
</div>
</Match>
<Match when={evt.type === 'pending_tool' && evt.pendingTool}>
<PendingToolBlock tool={evt.pendingTool!} />
</Match>
@@ -409,8 +435,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
when={isProviderFallbackEvent(evt)}
fallback={
<>
<span class="shrink-0">Switched to</span>
{' '}
<span class="shrink-0">Switched to</span>{' '}
<span class="truncate font-medium text-base-content">
{modelRouteLabel(evt.model)}
</span>
@@ -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(() => (
<MessageItem
message={makeMessage({
role: 'assistant',
content: '',
isStreaming: true,
pendingTools: [],
streamEvents: [
{
type: 'workflow_status',
workflowStatus: {
phase: 'provider_retry',
message: 'Provider connection failed before any output; retrying.',
attempt: 2,
maxAttempts: 3,
retryAfterMs: 1200,
},
},
],
workflowStatus: {
phase: 'provider_retry',
message: 'Provider connection failed before any output; retrying.',
attempt: 2,
maxAttempts: 3,
retryAfterMs: 1200,
},
})}
{...makeHandlers()}
/>
));
expect(
screen.getByText(
'Provider connection failed before any output; retrying. · attempt 2/3 · retrying in 1.2s',
),
).toBeInTheDocument();
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument();
});
@@ -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(
@@ -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();
});
@@ -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 = {
@@ -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',
};
@@ -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;