diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index bceadb8e6..694a94e78 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -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(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 ( - - {children} - - ) -} - /** * 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 ( - - - - - + + + ) } diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index ccd672dac..0e1a2e7e0 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -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 ( } - isRunning={isRunning} - text={displayText} + isRunning={status.type === 'running' || messageRunning} + text={text.trimStart()} /> ) }