diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 821d462e..6ca59ba8 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -89,6 +89,7 @@ Use this doc when you ask an agent to change tool/header/description behavior. - The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`. - Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering. - Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`). +- Reasoning streaming presentation derives from the live stream phase (`streaming`/`cooldown`), never from missing persisted timing: a cached part without `time.end` is not live, and a part whose `time.end` is set never streams (issue #2020). ## "I want to change description for Perplexity" (example recipe) diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx index 90691c57..ac55bf68 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { describe, expect, test } from 'bun:test'; import { renderToStaticMarkup } from 'react-dom/server'; +import type { Part } from '@opencode-ai/sdk/v2'; import { I18nProvider } from '@/lib/i18n'; -import { ReasoningTimelineBlock } from './ReasoningPart'; +import ReasoningPart, { ReasoningTimelineBlock } from './ReasoningPart'; +import type { StreamPhase } from '../types'; // A reasoning text whose summary (first 120 chars) fits in the header but // whose expanded body content should only appear when the disclosure is open. @@ -113,3 +115,80 @@ describe('ReasoningTimelineBlock', () => { expect(markup).not.toContain('<!-- -->'); }); }); + +// Regression tests for issue #2020: a persisted reasoning part must not be +// presented as live streaming just because cached data lacks `time.end` or a +// stream phase. Live activity derives from the live stream phase only. +describe('ReasoningPart streaming gating (issue #2020)', () => { + // Short enough (< 80 chars) that the collapsed header summary contains the + // complete text, letting us assert full content on first paint. + const SHORT_REASONING = 'Persisted reasoning text that is already fully available.'; + + const BUSY_INDICATOR = 'animate-busy-pulse'; + + const makeReasoningPart = (time?: { start?: number; end?: number }): Part => + ({ + id: 'prt_reasoning_2020', + sessionID: 'ses_2020', + messageID: 'msg_2020', + type: 'reasoning', + text: SHORT_REASONING, + time, + }) as unknown as Part; + + // Server rendering reads the UI store's initial state, which is + // chatRenderMode 'live' — the mode in which the streaming presentation is + // reachable and the issue reproduces. + const renderPart = (part: Part, streamPhase?: StreamPhase): string => + renderToStaticMarkup( + + + , + ); + + test('reasoning without time.end and without a live stream phase renders complete, not streaming', () => { + // Freshly opened completed session: cached part never received `time.end` + // and no message-level stream phase is available. The full text is already + // local, so the block must render as finished content on first paint. + const markup = renderPart(makeReasoningPart({ start: 1_000 }), undefined); + + expect(markup).not.toContain(BUSY_INDICATOR); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain(SHORT_REASONING); + }); + + test('reasoning without time.end in a completed message renders complete, not streaming', () => { + const markup = renderPart(makeReasoningPart({ start: 1_000 }), 'completed'); + + expect(markup).not.toContain(BUSY_INDICATOR); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain(SHORT_REASONING); + }); + + test('reasoning with time.end is never treated as streaming, even when the phase claims streaming', () => { + const markup = renderPart(makeReasoningPart({ start: 1_000, end: 2_000 }), 'streaming'); + + expect(markup).not.toContain(BUSY_INDICATOR); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain(SHORT_REASONING); + }); + + test('live in-progress reasoning still renders as streaming', () => { + // Genuinely live: the message-level stream phase reports streaming and the + // part has not ended. The block auto-expands and shows the busy indicator. + const markup = renderPart(makeReasoningPart({ start: 1_000 }), 'streaming'); + + expect(markup).toContain(BUSY_INDICATOR); + expect(markup).toContain('aria-expanded="true"'); + }); + + test('remounting a completed reasoning part does not re-trigger the streaming presentation', () => { + const part = makeReasoningPart({ start: 1_000 }); + const first = renderPart(part, undefined); + const second = renderPart(part, undefined); + + expect(second).toBe(first); + expect(second).not.toContain(BUSY_INDICATOR); + expect(second).toContain(SHORT_REASONING); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 1922100c..169c0eb7 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -261,7 +261,11 @@ export const ReasoningTimelineBlock: React.FC = ({ }; }, []); - if (!text || text.trim().length === 0) { + // While genuinely streaming, the busy header must appear as soon as + // reasoning starts even before the block-level reveal (commitStreamedText) + // has committed a first complete line — otherwise "Thinking…" never shows + // for the first moments of a short, single-paragraph response. + if (!isStreaming && (!text || text.trim().length === 0)) { return null; } @@ -430,8 +434,12 @@ const ReasoningPart = React.memo(({ const rawText = partWithText.text || partWithText.content || ''; const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]); const time = partWithText.time; - const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed'; - const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number'; + // Live activity derives from the live stream phase, never from the absence + // of persisted timing data: cached parts may lack `time.end` even though + // the message finished long ago (issue #2020). A part that has ended is + // never streaming, even while the rest of the message still streams. + const isLiveStreamPhase = streamPhase === 'streaming' || streamPhase === 'cooldown'; + const isStreaming = chatRenderMode === 'live' && isLiveStreamPhase && typeof time?.end !== 'number'; const throttledTextRaw = useStreamingTextThrottle({ text: textContent, isStreaming, @@ -441,9 +449,11 @@ const ReasoningPart = React.memo(({ // never mutates in place. const throttledText = isStreaming ? commitStreamedText(throttledTextRaw) : throttledTextRaw; - // Show reasoning even if time.end isn't set yet (during streaming) - // Only hide if there's no text content - if (!throttledText || throttledText.trim().length === 0) { + // Show reasoning even if time.end isn't set yet (during streaming). + // While genuinely streaming, keep the block mounted even before the + // block-level reveal commits a first line, so the busy header appears + // immediately instead of waiting on committed text. + if (!isStreaming && (!throttledText || throttledText.trim().length === 0)) { return null; }