mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 10:43:36 +00:00
Show Assistant thinking progress as a typed event
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user