From 8e2e60fcc34659dc4dfff519e7907b458bb68e88 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 08:31:54 +0100 Subject: [PATCH] Show Assistant thinking progress as a typed event --- .../v6/internal/subsystems/ai-runtime.md | 19 ++++++++---- .../src/components/AI/Chat/MessageItem.tsx | 22 ++++++++++++- .../AI/Chat/__tests__/MessageItem.test.tsx | 31 +++++++++++-------- .../AI/Chat/__tests__/useChat.test.ts | 6 ++-- .../src/components/AI/Chat/hooks/useChat.ts | 3 +- 5 files changed, 57 insertions(+), 24 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c311a0a34..762afeec0 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 5e7cefdd9..a2fe9aec2 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -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 = (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 = (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 = (props) => { {/* Stream events - chronological display */} - {(evt) => ( + {(evt, index) => ( + + + + 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 0a4ac0c4c..f91f228f1 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -7,7 +7,7 @@ import type { ChatMessage, PendingApproval, PendingQuestion, StreamDisplayEvent vi.mock('../ThinkingBlock', () => ({ ThinkingBlock: (props: { content: string; isStreaming?: boolean }) => (
- {props.content} + {props.isStreaming ? 'Thinking...' : 'Thinking complete'}
), })); @@ -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', () => { 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 96250a9b2..cefcbbd47 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -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(); }); }); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 8ffe5217c..d80d315d6 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -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, }; }