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

This commit is contained in:
brooklyn! 2026-07-13 20:38:26 -04:00 committed by GitHub
parent 2ccfdb2db4
commit 19641fab72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 19 additions and 118 deletions

View file

@ -15,7 +15,6 @@ import {
useDeferredValue,
useEffect,
useMemo,
useRef,
useState
} from 'react'
@ -348,112 +347,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 +587,14 @@ interface MarkdownTextContentProps extends MarkdownTextSurfaceProps {
}
export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) {
// Same path as the assistant answer. A reasoning-only smoothing wrapper used
// to sit here but stalled its char-reveal at empty (the part stays running
// the whole message), blanking the Thinking widget.
return (
<TextMessagePartProvider isRunning={isRunning} text={text}>
<SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface {...surfaceProps} />
</DeferStreamingText>
</SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface {...surfaceProps} />
</DeferStreamingText>
</TextMessagePartProvider>
)
}

View file

@ -1,4 +1,9 @@
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import {
type ReasoningMessagePartComponent,
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,17 +179,19 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
)
}
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
const displayText = text.trimStart()
// Read the part from context, same contract as MarkdownText's
// useMessagePartText — the reasoning-only smoothing wrapper (removed) stalled
// the char-reveal at empty, blanking the widget.
const ReasoningTextPart: ReasoningMessagePartComponent = () => {
const { status, text } = useMessagePartReasoning()
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
const isRunning = status?.type === 'running' || messageRunning
return (
<MarkdownTextContent
containerClassName="text-xs leading-snug text-muted-foreground/85"
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
isRunning={isRunning}
text={displayText}
isRunning={status.type === 'running' || messageRunning}
text={text.trimStart()}
/>
)
}