Show Assistant thinking progress as a typed event

This commit is contained in:
rcourtman
2026-06-06 08:31:54 +01:00
parent 8c3a0e8dbc
commit 8e2e60fcc3
5 changed files with 57 additions and 24 deletions
@@ -377,12 +377,19 @@ runtime cost control, and shared AI transport surfaces.
`packages/opencode/src/session/message.ts`, while
`packages/opencode/src/session/processor.ts` updates text and reasoning
parts through `*-delta` events instead of rendering raw provider tool-call
syntax as assistant prose. Pulse's frontend stream reducer must preserve the
same user-facing invariant: visible transcript content is typed assistant
text or a typed Pulse tool/approval/question row. Suspicious compacted
provider prelude text that looks like tool-call narration must be buffered
until it is proven to be normal answer text or stripped when a raw tool-call
marker arrives; it must not flash as run-on prose such as
syntax as assistant prose. The referenced OpenCode source at fetched
`origin/dev` commit `1399323b78a04229d9bfe00c7436d7f41770fda8` renders
reasoning with `AssistantReasoning` and `ReasoningHeader` in
`packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx`,
separate from `AssistantText` and `AssistantTool`. Pulse's frontend stream
reducer must preserve the same user-facing invariant: visible transcript
content is typed assistant text, a neutral typed thinking-progress row, or a
typed Pulse tool/approval/question row. The thinking-progress row may expose
live activity state such as `Thinking...` / `Thinking complete`, but it must
not render raw provider reasoning text. Suspicious compacted provider
prelude text that looks like tool-call narration must be buffered until it is
proven to be normal answer text or stripped when a raw tool-call marker
arrives; it must not flash as run-on prose such as
`I'llcheckthedevicenodes...` while the actual governed tool row is still
being assembled.
Streamed provider startup and mid-stream progress must be bounded by the
@@ -22,6 +22,7 @@ import { renderMarkdown } from '../aiChatUtils';
import { PendingToolBlock, ToolExecutionBlock } from './ToolExecutionBlock';
import { ApprovalCard } from './ApprovalCard';
import { QuestionCard } from './QuestionCard';
import { ThinkingBlock } from './ThinkingBlock';
import { stripAssistantOutputArtifacts } from './assistantOutputHygiene';
import { groupStreamEventsForDisplay } from './streamEventGrouping';
import type {
@@ -90,6 +91,8 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
);
const isRenderableStreamEvent = (evt: StreamDisplayEvent) => {
switch (evt.type) {
case 'thinking':
return !!evt.thinking?.trim();
case 'content':
return !!stripAssistantOutputArtifacts(evt.content || '').text;
case 'tool':
@@ -105,6 +108,10 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
}
};
const hasRenderableStreamEvents = () => groupedEvents().some(isRenderableStreamEvent);
const isLeadingThinkingEvent = (index: number) =>
groupedEvents()
.slice(0, index)
.every((evt) => evt.type === 'thinking');
const contextTools = createMemo(() => {
const events = props.message.streamEvents || [];
@@ -309,8 +316,21 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
{/* Stream events - chronological display */}
<Show when={hasRenderableStreamEvents()}>
<For each={groupedEvents()}>
{(evt) => (
{(evt, index) => (
<Switch>
<Match
when={
evt.type === 'thinking' &&
evt.thinking?.trim() &&
isLeadingThinkingEvent(index())
}
>
<ThinkingBlock
content={evt.thinking || ''}
isStreaming={props.message.isStreaming}
/>
</Match>
<Match when={evt.type === 'pending_tool' && evt.pendingTool}>
<PendingToolBlock tool={evt.pendingTool!} />
</Match>
@@ -7,7 +7,7 @@ import type { ChatMessage, PendingApproval, PendingQuestion, StreamDisplayEvent
vi.mock('../ThinkingBlock', () => ({
ThinkingBlock: (props: { content: string; isStreaming?: boolean }) => (
<div data-testid="thinking-block" data-streaming={props.isStreaming}>
{props.content}
{props.isStreaming ? 'Thinking...' : 'Thinking complete'}
</div>
),
}));
@@ -587,7 +587,7 @@ describe('MessageItem', () => {
});
describe('stream events rendering', () => {
it('does not render raw thinking from stream events', () => {
it('renders neutral thinking progress without raw reasoning text', () => {
const events: StreamDisplayEvent[] = [
{ type: 'thinking', thinking: 'We need to inspect the user prompt before answering.' },
];
@@ -599,11 +599,11 @@ describe('MessageItem', () => {
/>
));
expect(screen.queryByTestId('thinking-block')).not.toBeInTheDocument();
expect(screen.getByTestId('thinking-block')).toHaveTextContent('Thinking complete');
expect(screen.queryByText(/inspect the user prompt/i)).not.toBeInTheDocument();
});
it('keeps the neutral first-token indicator when only thinking has streamed', () => {
it('keeps neutral thinking progress when only thinking has streamed', () => {
const events: StreamDisplayEvent[] = [
{ type: 'thinking', thinking: 'Hidden reasoning should not be visible.' },
];
@@ -622,6 +622,7 @@ describe('MessageItem', () => {
));
expect(screen.getByText('Thinking...')).toBeInTheDocument();
expect(screen.getByTestId('thinking-block')).toHaveAttribute('data-streaming', 'true');
expect(screen.queryByText(/Hidden reasoning/i)).not.toBeInTheDocument();
});
@@ -798,19 +799,22 @@ describe('MessageItem', () => {
/>
));
expect(screen.queryByTestId('thinking-block')).not.toBeInTheDocument();
expect(screen.getByTestId('thinking-block')).toHaveTextContent('Thinking complete');
expect(screen.getByTestId('tool-execution-block')).toBeInTheDocument();
// Verify DOM order: content(Step 1) → tool → content(Step 2). Thinking is hidden.
// Verify DOM order: thinking → content(Step 1) → tool → content(Step 2).
const allBlocks = Array.from(
container.querySelectorAll('.prose, [data-testid="tool-execution-block"]'),
container.querySelectorAll(
'[data-testid="thinking-block"], .prose, [data-testid="tool-execution-block"]',
),
);
expect(allBlocks.length).toBe(3);
expect(allBlocks[0].classList.contains('prose')).toBe(true);
expect(allBlocks[0].innerHTML).toContain('Step 1');
expect(allBlocks[1].getAttribute('data-testid')).toBe('tool-execution-block');
expect(allBlocks[2].classList.contains('prose')).toBe(true);
expect(allBlocks[2].innerHTML).toContain('Step 2');
expect(allBlocks.length).toBe(4);
expect(allBlocks[0].getAttribute('data-testid')).toBe('thinking-block');
expect(allBlocks[1].classList.contains('prose')).toBe(true);
expect(allBlocks[1].innerHTML).toContain('Step 1');
expect(allBlocks[2].getAttribute('data-testid')).toBe('tool-execution-block');
expect(allBlocks[3].classList.contains('prose')).toBe(true);
expect(allBlocks[3].innerHTML).toContain('Step 2');
});
});
@@ -855,6 +859,7 @@ describe('MessageItem', () => {
const proseBlocks = container.querySelectorAll('.prose');
expect(proseBlocks.length).toBe(1);
expect(proseBlocks[0].innerHTML).toContain('Part 1 Part 2');
expect(screen.queryByTestId('thinking-block')).not.toBeInTheDocument();
});
it('ignores content events with empty content', () => {
@@ -851,7 +851,8 @@ describe('useChat', () => {
expect(assistant.thinking).toBe('Let me think...');
const thinkingEvents = assistant.streamEvents?.filter((e) => e.type === 'thinking') ?? [];
expect(thinkingEvents).toHaveLength(0);
expect(thinkingEvents).toHaveLength(1);
expect(thinkingEvents[0].thinking).toBe('Let me think...');
dispose();
});
@@ -1735,8 +1736,7 @@ describe('useChat', () => {
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
const types = assistant.streamEvents!.map((e) => e.type);
// Content, pending_tool, content. Thinking is retained internally, not rendered.
expect(types).toEqual(['content', 'pending_tool', 'content']);
expect(types).toEqual(['thinking', 'content', 'pending_tool', 'content']);
dispose();
});
});
@@ -741,8 +741,9 @@ export function useChat(options: UseChatOptions = {}) {
case 'thinking': {
const thinking = extractText(event.data);
if (!thinking) return msg;
const updated = addStreamEvent(msg, { type: 'thinking', thinking });
return {
...msg,
...updated,
thinking: (msg.thinking || '') + thinking,
};
}