Keep Assistant footer on freshest stream activity

This commit is contained in:
rcourtman
2026-06-06 10:23:34 +01:00
parent def74d18de
commit f6dbd0f395
9 changed files with 409 additions and 35 deletions
@@ -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
@@ -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';
@@ -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(
@@ -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',
@@ -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);
@@ -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<PendingTool | undefined>((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<AssistantActiveTurnStatusCandidate | null>((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<string>();
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<AssistantActiveTurnStatusCandidate | null>(
(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,
};
}
@@ -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;
@@ -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, ' ') || '';
};
@@ -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 });