mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 11:13:26 +00:00
Show Assistant thinking progress with source-backed summaries
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user