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:
parent
d14bf23fa3
commit
8bd4a419de
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue