import { ActionBarPrimitive, AuiIf, BranchPickerPrimitive, ErrorPrimitive, MessagePrimitive, ThreadPrimitive, type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, GitBranchIcon, Loader2Icon, MoreHorizontalIcon, RefreshCwIcon, Volume2Icon, VolumeXIcon } from 'lucide-react' import { type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer' import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text' 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 { 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 { 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 THINKING_FACES = [ '(。•́︿•̀。)', '(◔_◔)', '(¬‿¬)', '( •_•)>⌐■-■', '(⌐■_■)', '(´・_・`)', '◉_◉', '(°ロ°)', '( ˘⌣˘)♡', 'ヽ(>∀<☆)☆', '٩(๑❛ᴗ❛๑)۶', '(⊙_⊙)', '(¬_¬)', '( ͡° ͜ʖ ͡°)', 'ಠ_ಠ' ] const THINKING_VERBS = [ 'pondering', 'contemplating', 'musing', 'cogitating', 'ruminating', 'deliberating', 'mulling', 'reflecting', 'processing', 'reasoning', 'analyzing', 'computing', 'synthesizing', 'formulating', 'brainstorming' ] type ThreadLoadingState = 'response' | 'session' | 'working' interface MessageActionProps { messageId: string messageText: string onBranchInNewChat?: (messageId: string) => void } const BOTTOM_DISTANCE_PX = 24 let readAloudAudio: HTMLAudioElement | null = null function isNearBottom(el: HTMLElement): boolean { return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX } function partText(part: unknown): string { if (typeof part === 'string') { return part } if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') { return part.text } return '' } 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 }) => { const viewportRef = useRef(null) const contentRef = useRef(null) const messageCount = useAuiState(s => s.thread.messages.length) const isRunning = useAuiState(s => s.thread.isRunning) const lastMessageId = useAuiState(s => s.thread.messages.at(-1)?.id ?? '') const shouldStickToBottomRef = useRef(true) const scrollFrameRef = useRef(null) const sessionKeyRef = useRef(sessionKey ?? null) const handleScroll = useCallback((event: React.UIEvent) => { const nearBottom = isNearBottom(event.currentTarget) shouldStickToBottomRef.current = nearBottom setThreadScrolledUp(!nearBottom) }, []) const handleWheel = useCallback((event: React.WheelEvent) => { if (event.deltaY < 0) { shouldStickToBottomRef.current = false setThreadScrolledUp(true) } }, []) const scrollToBottom = useCallback(() => { const viewport = viewportRef.current if (!viewport) { return } viewport.scrollTop = viewport.scrollHeight shouldStickToBottomRef.current = true setThreadScrolledUp(false) }, []) const scheduleScrollToBottom = useCallback(() => { if (scrollFrameRef.current !== null) { window.cancelAnimationFrame(scrollFrameRef.current) } scrollFrameRef.current = window.requestAnimationFrame(() => { scrollFrameRef.current = null scrollToBottom() }) }, [scrollToBottom]) useEffect(() => { return () => { if (scrollFrameRef.current !== null) { window.cancelAnimationFrame(scrollFrameRef.current) } setThreadScrolledUp(false) } }, []) useLayoutEffect(() => { const viewport = viewportRef.current if (!viewport) { return } const nextSessionKey = sessionKey ?? null const sessionChanged = sessionKeyRef.current !== nextSessionKey sessionKeyRef.current = nextSessionKey const force = loading === 'session' || sessionChanged if (!force && !shouldStickToBottomRef.current) { return } scheduleScrollToBottom() }, [isRunning, lastMessageId, loading, messageCount, scheduleScrollToBottom, sessionKey]) useLayoutEffect(() => { const content = contentRef.current const viewport = viewportRef.current if (!content || !viewport) { return } let previousHeight = content.getBoundingClientRect().height const observer = new ResizeObserver(entries => { const height = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height if (height === previousHeight) { return } previousHeight = height if (!shouldStickToBottomRef.current && !isNearBottom(viewport)) { return } scheduleScrollToBottom() }) observer.observe(content) return () => observer.disconnect() }, [scheduleScrollToBottom]) return ( Boolean(intro) && s.thread.isEmpty}>{intro && }
{() => } {loading === 'response' && } {loading === 'working' && }
{loading === 'session' && }
) } const CenteredThreadSpinner: FC = () => (
) const ThreadMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => { const role = useAuiState(s => s.message.role) const isEditing = useAuiState(s => s.message.composer.isEditing) // The runtime synthesizes an empty assistant placeholder while isRunning is true // (last message is user). Rendering the full `MessagePrimitive.Root` for it adds // ~36px of invisible chrome (gap-2 + min-h-7 footer) which can push the // loading affordance too far below the user message. Skip it — // `ResponseLoadingIndicator` in the viewport handles the loading affordance directly. const isPlaceholder = useAuiState( s => s.message.role === 'assistant' && s.message.status?.type === 'running' && s.message.content.length === 0 ) if (isEditing) { return } if (role === 'user') { return } if (isPlaceholder) { return null } return } const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => { const messageId = useAuiState(s => s.message.id) const content = useAuiState(s => s.message.content) const messageText = messageContentText(content) return (
) } const STATUS_ROW_CLASS = 'flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70' const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label }) => (
{children}
) const ResponseLoadingIndicator: FC = () => { const [tick, setTick] = useState(0) const elapsed = useElapsedSeconds() useEffect(() => { const id = window.setInterval(() => setTick(t => t + 1), 900) return () => window.clearInterval(id) }, []) const face = THINKING_FACES[tick % THINKING_FACES.length] const verb = THINKING_VERBS[tick % THINKING_VERBS.length] return ( {face} {verb}… ) } const WorkingIndicator: FC = () => { const elapsed = useElapsedSeconds() return ( Still working… ) } const ImageGenerateTool: FC = ({ result }) => { const generatedImage = useGeneratedImageContext() const running = result === undefined useEffect(() => { generatedImage?.setPending(running) }, [generatedImage, running]) if (!running) { return null } return (
) } const ChainToolFallback: FC = props => { if (props.toolName === 'image_generate') { return } return } const ThinkingDisclosure: FC<{ children: ReactNode pending?: boolean }> = ({ children, pending = false }) => { const [open, setOpen] = useState(false) const elapsed = useElapsedSeconds(pending) return (
{open &&
{children}
}
) } const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => (
{text}
) const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }) const SHORT_FMT = new Intl.DateTimeFormat(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' }) function startOfDay(d: Date): number { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() } function formatMessageTimestamp(value: Date | string | number | undefined): string { if (!value) { return '' } const date = value instanceof Date ? value : new Date(value) if (Number.isNaN(date.getTime())) { return '' } const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000) if (dayDelta === 0) { return `Today, ${TIME_FMT.format(date)}` } if (dayDelta === 1) { return `Yesterday, ${TIME_FMT.format(date)}` } return SHORT_FMT.format(date) } const ACTION_BAR_CLASS = cn( 'absolute inset-0 flex gap-1 text-muted-foreground opacity-0 transition-opacity duration-100', 'pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100', 'focus-within:pointer-events-auto focus-within:opacity-100' ) const AssistantActionBar: FC = ({ messageId, messageText, onBranchInNewChat }) => { const [menuOpen, setMenuOpen] = useState(false) return (
triggerHaptic('submit')} tooltip="Refresh"> e.preventDefault()} sideOffset={6}> onBranchInNewChat?.(messageId)}> Branch in new chat
) } const CopyMessageButton: FC<{ text: string }> = ({ text }) => { const [copied, setCopied] = useState(false) const copy = useCallback(async () => { if (!text) { return } try { await navigator.clipboard.writeText(text) triggerHaptic('selection') setCopied(true) window.setTimeout(() => setCopied(false), 2000) } catch (error) { notifyError(error, 'Copy failed') } }, [text]) return ( void copy()} tooltip={copied ? 'Copied' : 'Copy'}> {copied ? : } ) } const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => { const voicePlayback = useStore($voicePlayback) const readAloudStatus = voicePlayback.source === 'read-aloud' && voicePlayback.messageId === messageId ? voicePlayback.status : 'idle' const isPreparing = readAloudStatus === 'preparing' const isSpeaking = readAloudStatus === 'speaking' const anyPlaybackActive = voicePlayback.status !== 'idle' const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon const read = useCallback(async () => { if (!text || $voicePlayback.get().status !== 'idle') { return } try { await playSpeechText(text, { messageId, source: 'read-aloud' }) } catch (error) { notifyError(error, 'Read aloud failed') } }, [messageId, text]) return ( { e.preventDefault() void (isSpeaking ? stopVoicePlayback() : read()) }} > {isPreparing ? 'Preparing audio...' : isSpeaking ? 'Stop reading' : 'Read aloud'} ) } const MessageTimestamp: FC = () => { const createdAt = useAuiState(s => s.message.createdAt) const label = formatMessageTimestamp(createdAt) if (!label) { return null } return {label} } const AssistantFooter: FC = props => (
/
) const branchButtonClass = 'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35' const UserMessage: FC = () => { return (
) } const EditComposer: FC = () => { // Editing requires a real onEdit implementation against Hermes history. // Hide the edit composer until that contract is implemented. return null }