From def74d18dec482ef7071adb39e028d3282df6d76 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 10:09:53 +0100 Subject: [PATCH] Show Assistant thinking progress with source-backed summaries --- .../v6/internal/subsystems/ai-runtime.md | 10 +++ .../src/components/AI/Chat/MessageItem.tsx | 2 + .../src/components/AI/Chat/ThinkingBlock.tsx | 82 ++++++++++++++++--- .../AI/Chat/__tests__/MessageItem.test.tsx | 18 +++- .../AI/Chat/__tests__/ThinkingBlock.test.tsx | 26 ++++++ .../__tests__/streamEventGrouping.test.ts | 16 ++++ .../AI/Chat/__tests__/useChat.test.ts | 3 + .../src/components/AI/Chat/hooks/useChat.ts | 16 +++- .../components/AI/Chat/streamEventGrouping.ts | 7 +- .../src/components/AI/Chat/types.ts | 2 + 10 files changed, 162 insertions(+), 20 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index b6efdb841..1109ff10f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 53f49ca41..99a55f613 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -363,6 +363,8 @@ export const MessageItem: Component = (props) => { diff --git a/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx b/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx index fa5af42d1..610d291f3 100644 --- a/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx @@ -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 = (props) => ( -
-
-); +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 = (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 ( +
+
+ ); +}; 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 4a4f5f29c..3d784605e 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -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 }) => ( -
+ ThinkingBlock: (props: { + content: string; + isStreaming?: boolean; + startedAt?: number; + updatedAt?: number; + }) => ( +
{props.isStreaming ? 'Thinking...' : 'Thinking complete'}
), @@ -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). diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ThinkingBlock.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ThinkingBlock.test.tsx index 7f13ac6da..2287aea4b 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ThinkingBlock.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ThinkingBlock.test.tsx @@ -24,4 +24,30 @@ describe('ThinkingBlock', () => { expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); }); + + it('uses provider reasoning summary metadata without showing the reasoning body', () => { + render(() => ( + + )); + + 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(() => ( +