import { ActionBarPrimitive, AuiIf, BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, MessagePrimitive, ThreadPrimitive, type ToolCallMessagePartProps, useAuiEvent, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import spinners from 'unicode-animations' // Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the // reference implementation that powers bolt.new and several other streaming // chat UIs. It handles everything we care about — spring-animated catch-up, // resize-vs-user-scroll disambiguation, wheel/touch escape, text-selection // pause, subpixel overshoot, programmatic-scroll event suppression — via 665 // lines of well-tested edge-case handling that we should NOT hand-roll. // // We only own the thin glue: jump-to-bottom on session switch / send, and // keeping `$threadScrolledUp` in sync with `isAtBottom` for the composer's // dim-when-scrolled-away treatment. import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom' import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer' import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text' import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { DirectiveText } from '@/components/assistant-ui/directive-text' import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context' import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder' import { Intro, type IntroProps } from '@/components/assistant-ui/intro' import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment' import { ToolFallback } from '@/components/assistant-ui/tool-fallback' import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Loader } from '@/components/ui/loader' import { triggerHaptic } from '@/lib/haptics' import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, GitBranchIcon, Loader2Icon, MoreHorizontalIcon, PencilIcon, RefreshCwIcon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' import { notifyError } from '@/store/notifications' import { setThreadScrolledUp } from '@/store/thread-scroll' import { $voicePlayback } from '@/store/voice-playback' const RESPONSE_SPINNER = spinners.braille type ThreadLoadingState = 'response' | 'session' interface MessageActionProps { messageId: string messageText: string onBranchInNewChat?: (messageId: string) => void } let readAloudAudio: HTMLAudioElement | null = null function partText(part: unknown): string { if (typeof part === 'string') { return part } if (!part || typeof part !== 'object') { return '' } const row = part as { text?: unknown; type?: unknown } return (!row.type || row.type === 'text') && typeof row.text === 'string' ? row.text : '' } function messageContentText(content: unknown): string { if (typeof content === 'string') { return content.trim() } return Array.isArray(content) ? content.map(partText).join('').trim() : '' } export const Thread: FC<{ intro?: IntroProps loading?: ThreadLoadingState onBranchInNewChat?: (messageId: string) => void sessionKey?: string | null }> = ({ intro, loading, onBranchInNewChat, sessionKey }) => { return ( {/* * renders a wrapper
; * renders an inner scroll container (inline height/width 100%) plus * an inner content div. So: * - `className` on = outer wrapper sizing * - `scrollClassName` on <.Content> = scroll container * - `className` on <.Content> = content (flex column) * * `initial: 'instant'`: no animation on first mount. * `resize: 'instant'`: during streaming, snap to bottom each token. * Spring animation ('smooth') visibly lags behind fast token * streams; users read that as jank. 'instant' matches ChatGPT. * * The composer is rendered OUTSIDE the scroller as `position: * absolute; bottom: 0` (floating glass treatment) and overlays the * bottom of the scroll surface. We compensate by putting a tall * bottom spacer (>= composer height + margin) inside the scroll * content so "scroll to bottom" naturally parks the last line of * content above the composer, not hidden behind it. */} Boolean(intro) && s.thread.isEmpty}>{intro && } , SystemMessage, UserEditComposer, UserMessage }} /> {loading === 'response' && } {loading === 'session' && } ) } /** * Scroll glue for the chat thread. Replaces hand-rolled follow logic with * the exact pattern that assistant-ui's own `useThreadViewportAutoScroll` * uses internally: **raw DOM scroll + an armed behavior ref + a * ResizeObserver loop that re-pins to bottom until we actually reach it.** * * Why not use the library's `scrollToBottom` for sends? * - It wraps its work in `new Promise(requestAnimationFrame)` so even * `animation: 'instant'` is 1+ frame async. * - It does NOT clear `escapedFromLock` on call — if the user had * scrolled up before sending, the library's resize handler keeps * un-setting `isAtBottom` between our scroll and the next resize. * - `ignoreEscapes` only blocks NEW escapes during the animation; it * doesn't unstick an already-escaped state. * * The armed-ref pattern handles all of that: * 1. `thread.runStart` fires after the runtime has committed the user * message to state (so scrollHeight already reflects it). * 2. We arm a ref ('instant') and write `scrollTop = scrollHeight` * synchronously. * 3. A ResizeObserver on the content keeps re-pinning each time the * DOM grows (user message paints, assistant placeholder mounts, * assistant streams) until scrollTop is actually at bottom — then * we disarm. * 4. Any wheel-up or touch-scroll-up disarms immediately so the user * can always escape. * * This mirrors: * - assistant-ui's `useThreadViewportAutoScroll` (scrollToBottomBehaviorRef * + useOnResizeContent loop) * - Vercel ai-chatbot's `useScrollToBottom` (MutationObserver + RO on * container and children + isAtBottom/isUserScrolling flags) * * Must be rendered INSIDE a because useStickToBottomContext * reads from that component's context. */ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => { const { scrollRef, isAtBottom, state } = useStickToBottomContext() const sessionKeyRef = useRef(sessionKey ?? null) // "Armed" behavior ref. Non-null = "keep chasing bottom across resize // ticks until we get there." Null = "user owns the viewport." const armedRef = useRef(null) const messageCount = useAuiState(s => s.thread.messages.length) const prevMessageCountRef = useRef(messageCount) useEffect(() => { setThreadScrolledUp(!isAtBottom) }, [isAtBottom]) useEffect(() => { return () => { setThreadScrolledUp(false) } }, []) // Slam to bottom + arm the ref. Also forces library state flags off // so its internal resize handler doesn't fight our re-pins. const armAndPin = useCallback( (behavior: ScrollBehavior) => { const el = scrollRef.current if (!el) { return } armedRef.current = behavior // Clear the library's escape/at-bottom flags directly on the mutable // state object so its resize handler sees a clean follow state. state.escapedFromLock = false state.isAtBottom = true el.scrollTop = el.scrollHeight }, [scrollRef, state] ) // ResizeObserver loop — re-pins to bottom while armed, disarms when // actually at bottom. This is the assistant-ui pattern. useEffect(() => { const el = scrollRef.current if (!el) { return } const observer = new ResizeObserver(() => { const behavior = armedRef.current if (!behavior) { return } const distance = el.scrollHeight - (el.scrollTop + el.clientHeight) if (distance < 2) { armedRef.current = null return } el.scrollTop = el.scrollHeight }) observer.observe(el) const content = el.firstElementChild if (content) { observer.observe(content) } return () => observer.disconnect() }, [scrollRef]) // User-intent detection — any upward gesture disarms the chase. useEffect(() => { const el = scrollRef.current if (!el) { return } const onWheel = (e: WheelEvent) => { if (e.deltaY < 0) { armedRef.current = null } } const onTouch = () => { armedRef.current = null } el.addEventListener('wheel', onWheel, { passive: true }) el.addEventListener('touchmove', onTouch, { passive: true }) return () => { el.removeEventListener('wheel', onWheel) el.removeEventListener('touchmove', onTouch) } }, [scrollRef]) // (1) Session switch — strong intent to see the bottom of the new thread. useEffect(() => { const next = sessionKey ?? null if (sessionKeyRef.current === next) { return } sessionKeyRef.current = next prevMessageCountRef.current = 0 armAndPin('auto') }, [armAndPin, sessionKey]) // (2) Bulk message load (session history arriving from storage) — pin // to bottom and stay armed while the thread's markdown/code/images // settle over the next several frames. useEffect(() => { const prev = prevMessageCountRef.current prevMessageCountRef.current = messageCount if (prev === 0 && messageCount > 0) { armAndPin('auto') } }, [armAndPin, messageCount]) // (3) User send — the runtime event `thread.runStart` fires after the // user message has been committed to state (scrollHeight already reflects // it). This is the canonical signal per assistant-ui's own code. We // arm-and-pin synchronously in the callback, then the RO loop above // keeps us at bottom as the assistant message placeholder + reply stream. useAuiEvent('thread.runStart', () => { armAndPin('instant') }) return null } /** * Invisible bottom spacer whose height matches the currently-measured * composer height (plus a small gap). Because the composer is rendered * OUTSIDE the scroll container as `position: absolute; bottom: 0`, "scroll * to bottom" would otherwise park the last content line behind it. By * extending the scroll content down with real (blank) space equal to the * composer's footprint, the library's scroll-to-scrollHeight naturally * leaves the last message line sitting above the composer. * * A ResizeObserver on the composer keeps the spacer in sync when the * textarea grows (multi-line input), attachments expand, or the composer * enters a focused/expanded state. */ const COMPOSER_BREATHING_ROOM_PX = 36 const DEFAULT_COMPOSER_CLEARANCE_PX = 192 const ComposerClearance: FC = () => { const [height, setHeight] = useState(() => { // Keep enough space even while the floating composer is still mounting. if (typeof document === 'undefined') { return DEFAULT_COMPOSER_CLEARANCE_PX } const composer = document.querySelector('[data-slot="composer-root"]') return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : DEFAULT_COMPOSER_CLEARANCE_PX }) useEffect(() => { let composerObserver: ResizeObserver | null = null let observedComposer: HTMLElement | null = null const apply = (composer: HTMLElement) => { const h = composer.getBoundingClientRect().height setHeight(prev => { const next = Math.round(h + COMPOSER_BREATHING_ROOM_PX) return Math.abs(prev - next) < 1 ? prev : next }) } const bindComposer = () => { const composer = document.querySelector('[data-slot="composer-root"]') if (!composer || composer === observedComposer) { return false } observedComposer = composer apply(composer) composerObserver?.disconnect() composerObserver = new ResizeObserver(() => apply(composer)) composerObserver.observe(composer) return true } bindComposer() const mutationObserver = new MutationObserver(() => void bindComposer()) mutationObserver.observe(document.body, { childList: true, subtree: true }) return () => { composerObserver?.disconnect() mutationObserver.disconnect() } }, []) return