Show Assistant thinking progress with source-backed summaries

This commit is contained in:
rcourtman
2026-06-06 10:09:53 +01:00
parent f7ee1244f0
commit def74d18de
10 changed files with 162 additions and 20 deletions
@@ -268,6 +268,16 @@ runtime cost control, and shared AI transport surfaces.
transcript while raw command/tool output remains an explicit Details expansion
instead of a default preview. This preserves evidence for inspection without
letting large command output dominate the Assistant answer flow.
Streaming thinking rows follow the same source-anchored reasoning-display
contract: OpenCode commit
`9ed17da55ab1f7360cc0e01075f763e27fa899e9` renders reasoning through
`ReasoningPart`/`ReasoningHeader` in
`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` and extracts only
provider summary metadata with `reasoningSummary` in
`packages/opencode/src/cli/cmd/tui/context/thinking.ts`. Pulse adapts that by
showing a live `Thinking:`/completed `Thought:` row with duration and optional
provider summary title while keeping the raw reasoning body out of the
transcript.
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
@@ -363,6 +363,8 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
<ThinkingBlock
content={evt.thinking || ''}
isStreaming={props.message.isStreaming}
startedAt={evt.startedAt}
updatedAt={evt.updatedAt}
/>
</Match>
@@ -1,20 +1,76 @@
import { Component } from 'solid-js';
import { Component, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import BrainIcon from 'lucide-solid/icons/brain';
interface ThinkingBlockProps {
content?: string;
isStreaming?: boolean;
startedAt?: number;
updatedAt?: number;
}
export const ThinkingBlock: Component<ThinkingBlockProps> = (props) => (
<div
class="my-2 inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface-alt px-2.5 py-1.5 text-xs text-muted"
role="status"
>
<BrainIcon
class={`h-3.5 w-3.5 text-blue-500 ${props.isStreaming ? 'animate-pulse' : ''}`}
aria-hidden="true"
/>
<span>{props.isStreaming ? 'Thinking...' : 'Thinking complete'}</span>
</div>
);
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';
const totalSeconds = Math.max(1, Math.floor(durationMs / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};
export const ThinkingBlock: Component<ThinkingBlockProps> = (props) => {
const [now, setNow] = createSignal(Date.now());
createEffect(() => {
if (!props.isStreaming || !props.startedAt) return;
setNow(Date.now());
const interval = window.setInterval(() => setNow(Date.now()), 1000);
onCleanup(() => window.clearInterval(interval));
});
const summaryTitle = createMemo(() => extractReasoningSummaryTitle(props.content));
const durationLabel = createMemo(() => {
if (!props.startedAt) return '';
const end = props.isStreaming ? now() : props.updatedAt;
if (!end) return '';
const duration = formatThinkingDuration(end - props.startedAt);
if (props.isStreaming && duration === '<1s') return '';
return duration;
});
const statusText = createMemo(() => {
const title = summaryTitle();
if (props.isStreaming) {
return title ? `Thinking: ${title}` : 'Thinking...';
}
return title ? `Thought: ${title}` : 'Thinking complete';
});
return (
<div
class="my-2 inline-flex max-w-full items-center gap-2 rounded-md border border-border-subtle bg-surface-alt px-2.5 py-1.5 text-xs text-muted"
role="status"
>
<BrainIcon
class={`h-3.5 w-3.5 shrink-0 text-blue-500 ${props.isStreaming ? 'animate-pulse' : ''}`}
aria-hidden="true"
/>
<span class="min-w-0 truncate">
{statusText()}
<span class="text-muted/80">
{durationLabel() ? (props.isStreaming ? ` (${durationLabel()})` : ` · ${durationLabel()}`) : ''}
</span>
</span>
</div>
);
};
@@ -5,8 +5,18 @@ import type { ChatMessage, PendingApproval, PendingQuestion, StreamDisplayEvent
// Mock child components to isolate MessageItem logic
vi.mock('../ThinkingBlock', () => ({
ThinkingBlock: (props: { content: string; isStreaming?: boolean }) => (
<div data-testid="thinking-block" data-streaming={props.isStreaming}>
ThinkingBlock: (props: {
content: string;
isStreaming?: boolean;
startedAt?: number;
updatedAt?: number;
}) => (
<div
data-testid="thinking-block"
data-streaming={props.isStreaming}
data-started-at={props.startedAt}
data-updated-at={props.updatedAt}
>
{props.isStreaming ? 'Thinking...' : 'Thinking complete'}
</div>
),
@@ -840,7 +850,7 @@ describe('MessageItem', () => {
it('renders mixed event types in correct DOM order', () => {
const events: StreamDisplayEvent[] = [
{ type: 'thinking', thinking: 'Analyzing...' },
{ type: 'thinking', thinking: 'Analyzing...', startedAt: 1_000, updatedAt: 2_000 },
{ type: 'content', content: 'Step 1' },
{
type: 'tool',
@@ -857,6 +867,8 @@ describe('MessageItem', () => {
));
expect(screen.getByTestId('thinking-block')).toHaveTextContent('Thinking complete');
expect(screen.getByTestId('thinking-block')).toHaveAttribute('data-started-at', '1000');
expect(screen.getByTestId('thinking-block')).toHaveAttribute('data-updated-at', '2000');
expect(screen.getByTestId('tool-execution-block')).toBeInTheDocument();
// Verify DOM order: thinking → content(Step 1) → tool → content(Step 2).
@@ -24,4 +24,30 @@ describe('ThinkingBlock', () => {
expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
});
it('uses provider reasoning summary metadata without showing the reasoning body', () => {
render(() => (
<ThinkingBlock
content={'**Inspecting storage state**\n\nHidden provider reasoning body.'}
startedAt={1_000}
updatedAt={4_250}
/>
));
expect(screen.getByRole('status')).toHaveTextContent('Thought: Inspecting storage state · 3s');
expect(screen.queryByText(/Hidden provider reasoning body/i)).not.toBeInTheDocument();
});
it('shows summary metadata while streaming without exposing raw reasoning text', () => {
render(() => (
<ThinkingBlock
content={'**Checking resource context**\n\nNeed to inspect hidden state.'}
isStreaming={true}
startedAt={Date.now()}
/>
));
expect(screen.getByRole('status')).toHaveTextContent('Thinking: Checking resource context');
expect(screen.queryByText(/Need to inspect hidden state/i)).not.toBeInTheDocument();
});
});
@@ -42,6 +42,22 @@ describe('groupStreamEventsForDisplay', () => {
expect(grouped[0].type).toBe('thinking');
});
it('preserves merged thinking activity timing', () => {
const grouped = groupStreamEventsForDisplay([
{ type: 'thinking', thinking: 'A', startedAt: 1_000, updatedAt: 1_100 },
content('answer'),
{ type: 'thinking', thinking: 'B', startedAt: 1_500, updatedAt: 2_500 },
]);
const thinkingBlock = grouped.find((event) => event.type === 'thinking');
expect(thinkingBlock).toMatchObject({
type: 'thinking',
thinking: 'AB',
startedAt: 1_000,
updatedAt: 2_500,
});
});
it('keeps content separated across a tool boundary so order is preserved', () => {
const tool: StreamDisplayEvent = {
type: 'tool',
@@ -854,6 +854,9 @@ describe('useChat', () => {
const thinkingEvents = assistant.streamEvents?.filter((e) => e.type === 'thinking') ?? [];
expect(thinkingEvents).toHaveLength(1);
expect(thinkingEvents[0].thinking).toBe('Let me think...');
expect(thinkingEvents[0].startedAt).toEqual(expect.any(Number));
expect(thinkingEvents[0].updatedAt).toEqual(expect.any(Number));
expect(thinkingEvents[0].updatedAt).toBeGreaterThanOrEqual(thinkingEvents[0].startedAt || 0);
dispose();
});
@@ -252,11 +252,17 @@ export function useChat(options: UseChatOptions = {}) {
if (event.type === 'thinking' && events.length > 0) {
const last = events[events.length - 1];
if (last.type === 'thinking') {
const now = Date.now();
return {
...msg,
streamEvents: [
...events.slice(0, -1),
{ ...last, thinking: (last.thinking || '') + (event.thinking || '') },
{
...last,
thinking: (last.thinking || '') + (event.thinking || ''),
startedAt: last.startedAt || event.startedAt || now,
updatedAt: event.updatedAt || now,
},
],
};
}
@@ -726,7 +732,13 @@ export function useChat(options: UseChatOptions = {}) {
case 'thinking': {
const thinking = extractText(event.data);
if (!thinking) return msg;
const updated = addStreamEvent(msg, { type: 'thinking', thinking });
const now = Date.now();
const updated = addStreamEvent(msg, {
type: 'thinking',
thinking,
startedAt: now,
updatedAt: now,
});
return {
...updated,
thinking: (msg.thinking || '') + thinking,
@@ -43,9 +43,12 @@ export const groupStreamEventsForDisplay = (
case 'thinking': {
if (!evt.thinking) break; // skip empty deltas
if (thinkingIdx >= 0) {
const current = grouped[thinkingIdx];
grouped[thinkingIdx] = {
...grouped[thinkingIdx],
thinking: (grouped[thinkingIdx].thinking || '') + evt.thinking,
...current,
thinking: (current.thinking || '') + evt.thinking,
startedAt: current.startedAt || evt.startedAt,
updatedAt: evt.updatedAt || current.updatedAt,
};
} else {
grouped.push({ ...evt });
@@ -105,6 +105,8 @@ export type StreamEventType =
export interface StreamDisplayEvent {
type: StreamEventType;
thinking?: string;
startedAt?: number;
updatedAt?: number;
tool?: ToolExecution;
pendingTool?: PendingTool;
content?: string;