From d4cb73a1b38fb5ffaf0a6f0717c7b2beda760d3f Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Thu, 6 Aug 2026 19:55:17 +0000 Subject: [PATCH 1/2] fix(chat): render completed reasoning in full instead of simulating streaming (#2020) Co-authored-by: Serhii Dziupin --- .../chat/message/parts/DOCUMENTATION.md | 1 + .../chat/message/parts/ReasoningPart.test.tsx | 81 ++++++++++++++++++- .../chat/message/parts/ReasoningPart.tsx | 8 +- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 466942f3..55a2fbc5 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -61,6 +61,7 @@ Use this doc when you ask an agent to change tool/header/description behavior. - `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render. - Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following 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 c2abb190..4458502b 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -452,8 +452,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 throttledText = useStreamingTextThrottle({ text: textContent, isStreaming, From e64fc710fcd1ab4ed3963122e70b233473b8eb6c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 28 Aug 2026 23:46:14 +0300 Subject: [PATCH 2/2] fix(chat): render completed reasoning in full instead of simulating streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasoning streaming state now derives only from the live stream phase (streaming/cooldown), never from missing persisted timing data. A cached part without time.end is no longer treated as live just because the timing field is absent, so completed reasoning renders in full on load instead of replaying a fake stream. While resolving the merge onto main, also fixed a regression the merge uncovered: main had added block-level streaming reveal (commitStreamedText) to ReasoningPart since this fix was authored, which caused the busy "Thinking…" header to stay hidden for the first moments of a short, single-paragraph streaming response (no committed line yet). The busy header now mounts as soon as streaming starts, independent of whether any text has been committed for display. Closes #2020 --- .../chat/message/parts/ReasoningPart.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 3d4bc2a2..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; } @@ -445,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; }