fix(desktop): render reasoning text in the Thinking widget

The Thinking disclosure rendered blank for every reasoning-emitting model
(Fable, DeepSeek, GPT-5.5, ...). Two causes:

1. ReasoningTextPart read a `text` prop that assistant-ui never populates —
   reasoning parts arrive via context, same as text parts — so it always got
   an empty string. Read the text via useMessagePartReasoning() instead,
   mirroring how MarkdownText uses useMessagePartText().

2. The reasoning-only SmoothStreamingText / useSmoothReveal layer stalled at
   revealed="": the reasoning part stays isRunning for the whole message while
   the answer streams and thrashes re-renders, so the char-reveal never
   advanced past 0. Render reasoning through the same DeferStreamingText →
   surface path the assistant answer uses, and drop the dead smoothing code.
This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:43:09 -04:00
parent d14bf23fa3
commit 8bd4a419de
2 changed files with 19 additions and 115 deletions

View File

@ -348,112 +348,6 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
)
}
// Steady character-reveal for streaming text: decouples visible cadence from
// bursty arrival so text flows instead of popping (cf. assistant-ui's useSmooth,
// reimplemented for a tunable rate). Proportional drain — each frame reveals a
// slice of the backlog so the reveal converges within ~REVEAL_DRAIN_MS whatever
// the size; the per-frame cap stops a huge dump rendering as one slab. The loop
// is gated on backlog, not isRunning, so a stream that completes mid-reveal
// keeps draining its tail instead of snapping.
const REVEAL_DRAIN_MS = 500
const REVEAL_MAX_CHARS_PER_FRAME = 30
// Floor between reveal commits. Each commit republishes the text context and
// re-runs the whole Streamdown pipeline (preprocess → remend → lex → micromark
// on the open block) over the full accumulated text — at raw rAF cadence
// that's 60 full parses/second and was the dominant streaming cost for
// reasoning text. ~33ms keeps the reveal visually fluid (2 frames) while
// halving the parse work.
const REVEAL_MIN_COMMIT_MS = 33
function useSmoothReveal(text: string, isRunning: boolean): string {
const [displayed, setDisplayed] = useState(isRunning ? '' : text)
const targetRef = useRef(text)
const shownRef = useRef(displayed)
const frameRef = useRef<number | null>(null)
const lastTickRef = useRef(0)
shownRef.current = displayed
targetRef.current = text
useEffect(() => {
if (typeof window === 'undefined') {
return
}
// Non-extending change (regenerate / branch / history swap): restart from
// empty while streaming, else snap to the replacement.
if (!text.startsWith(shownRef.current)) {
shownRef.current = isRunning ? '' : text
setDisplayed(shownRef.current)
}
if (shownRef.current.length >= text.length || frameRef.current !== null) {
return
}
lastTickRef.current = performance.now()
const tick = () => {
const now = performance.now()
const dt = now - lastTickRef.current
// Skip this frame if the floor hasn't elapsed — the backlog math below
// is dt-proportional, so delayed commits reveal proportionally more.
if (dt < REVEAL_MIN_COMMIT_MS) {
frameRef.current = requestAnimationFrame(tick)
return
}
lastTickRef.current = now
const remaining = targetRef.current.length - shownRef.current.length
const add = Math.min(
remaining,
// dt-scaled so the per-commit cap stays equivalent to the old
// per-frame cap at any commit cadence.
Math.ceil((REVEAL_MAX_CHARS_PER_FRAME * dt) / 16.7),
Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS))
)
shownRef.current = targetRef.current.slice(0, shownRef.current.length + add)
setDisplayed(shownRef.current)
frameRef.current = shownRef.current.length < targetRef.current.length ? requestAnimationFrame(tick) : null
}
frameRef.current = requestAnimationFrame(tick)
}, [text, isRunning])
useEffect(
() => () => {
if (frameRef.current !== null && typeof window !== 'undefined') {
cancelAnimationFrame(frameRef.current)
}
},
[]
)
return displayed
}
// Re-publish the part context with a smooth character-reveal, above
// DeferStreamingText so the reveal feeds the deferred markdown pipeline. Status
// stays running while revealing so the caret persists past the underlying part
// settling.
function SmoothStreamingText({ children }: { children: ReactNode }) {
const { text, status } = useMessagePartText()
const isRunning = status.type === 'running'
const revealed = useSmoothReveal(text, isRunning)
return (
<TextMessagePartProvider isRunning={isRunning || revealed !== text} text={revealed}>
{children}
</TextMessagePartProvider>
)
}
/**
* Re-publish the active message-part context with React's `useDeferredValue`
* applied to the streaming text and status. The outer wrapper still re-renders
@ -694,13 +588,17 @@ interface MarkdownTextContentProps extends MarkdownTextSurfaceProps {
}
export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) {
// Render through the same path as the assistant answer (DeferStreamingText →
// surface). The previous SmoothStreamingText/useSmoothReveal wrapper stalled
// at empty for reasoning: the reasoning part stays isRunning for the whole
// message while the answer streams and thrashes re-renders, and the reveal
// never advanced past 0 — so the Thinking widget rendered blank even though
// the part carried text. Chunked (per-delta) reveal matches the answer.
return (
<TextMessagePartProvider isRunning={isRunning} text={text}>
<SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface {...surfaceProps} />
</DeferStreamingText>
</SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface {...surfaceProps} />
</DeferStreamingText>
</TextMessagePartProvider>
)
}

View File

@ -1,4 +1,4 @@
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { type ToolCallMessagePartProps, useAuiState, useMessagePartReasoning } from '@assistant-ui/react'
import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useState } from 'react'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
@ -174,10 +174,16 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
)
}
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
const displayText = text.trimStart()
const ReasoningTextPart: FC = () => {
// Read the reasoning text from the assistant-ui part context (same contract
// as MarkdownText's useMessagePartText). The `Reasoning` component is NOT
// handed a `text` prop — the runtime provides the part via context — so the
// previous prop-based read rendered an empty string and the Thinking widget
// showed blank even though the part carried text.
const part = useMessagePartReasoning() as { text?: string; status?: { type?: string } }
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
const isRunning = status?.type === 'running' || messageRunning
const isRunning = part.status?.type === 'running' || messageRunning
const displayText = (part.text ?? '').trimStart()
return (
<MarkdownTextContent