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, useMemo, useRef, useState } from 'react' import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom' import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text' import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool' import { ToolFallback } from '@/components/assistant-ui/tool-fallback' import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { DisclosureRow } from '@/components/chat/disclosure-row' import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context' import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder' import { Intro, type IntroProps } from '@/components/chat/intro' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { CopyButton } from '@/components/ui/copy-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, 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' type ThreadLoadingState = 'response' | 'session' interface StickyStateFlags { escapedFromLock: boolean isAtBottom: boolean } 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() : '' } function resetStickyState(state: StickyStateFlags) { state.escapedFromLock = false state.isAtBottom = true } function pinElementToBottom(el: HTMLElement) { el.scrollTop = el.scrollHeight return el.scrollTop } export const Thread: FC<{ clampToComposer?: boolean intro?: IntroProps loading?: ThreadLoadingState onBranchInNewChat?: (messageId: string) => void sessionKey?: string | null }> = ({ clampToComposer = false, intro, loading, onBranchInNewChat, sessionKey }) => { const introHero = useAuiState(s => Boolean(intro) && s.thread.isEmpty) return ( Boolean(intro) && s.thread.isEmpty}> {intro ? (
) : null}
, SystemMessage, UserEditComposer, UserMessage }} /> {loading === 'response' && }
{loading === 'session' && }
) } const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => { const { scrollRef, isAtBottom, state } = useStickToBottomContext() const sessionKeyRef = useRef(sessionKey ?? null) const armedRef = useRef(null) const pinRafRef = useRef(null) const previousScrollTopRef = useRef(0) const suppressNextScrollEventRef = useRef(false) const messageCount = useAuiState(s => s.thread.messages.length) const prevMessageCountRef = useRef(messageCount) useEffect(() => { setThreadScrolledUp(!isAtBottom) }, [isAtBottom]) useEffect(() => { return () => { setThreadScrolledUp(false) } }, []) const armAndPin = useCallback( (behavior: ScrollBehavior) => { const el = scrollRef.current if (!el) { return } armedRef.current = behavior resetStickyState(state) suppressNextScrollEventRef.current = true previousScrollTopRef.current = pinElementToBottom(el) }, [scrollRef, state] ) useEffect(() => { const el = scrollRef.current if (!el) { return } const observer = new ResizeObserver(() => { if (pinRafRef.current !== null) { return } pinRafRef.current = window.requestAnimationFrame(() => { pinRafRef.current = null if (!armedRef.current) { return } const distance = el.scrollHeight - (el.scrollTop + el.clientHeight) if (distance < 2) { armedRef.current = null return } suppressNextScrollEventRef.current = true previousScrollTopRef.current = pinElementToBottom(el) }) }) observer.observe(el) const content = el.firstElementChild if (content) { observer.observe(content) } return () => { observer.disconnect() if (pinRafRef.current !== null) { window.cancelAnimationFrame(pinRafRef.current) pinRafRef.current = null } } }, [scrollRef]) useEffect(() => { const el = scrollRef.current if (!el) { return } const onWheel = (e: WheelEvent) => { if (e.deltaY < 0) { armedRef.current = null } } const onTouch = () => { armedRef.current = null } const onScroll = () => { const currentTop = el.scrollTop if (suppressNextScrollEventRef.current) { suppressNextScrollEventRef.current = false previousScrollTopRef.current = currentTop return } if (currentTop + 1 < previousScrollTopRef.current) { armedRef.current = null } previousScrollTopRef.current = currentTop } el.addEventListener('wheel', onWheel, { passive: true }) el.addEventListener('touchmove', onTouch, { passive: true }) el.addEventListener('scroll', onScroll, { passive: true }) return () => { el.removeEventListener('wheel', onWheel) el.removeEventListener('touchmove', onTouch) el.removeEventListener('scroll', onScroll) } }, [scrollRef]) useEffect(() => { const next = sessionKey ?? null if (sessionKeyRef.current === next) { return } sessionKeyRef.current = next prevMessageCountRef.current = 0 armAndPin('auto') }, [armAndPin, sessionKey]) useEffect(() => { const prev = prevMessageCountRef.current prevMessageCountRef.current = messageCount if (prev === 0 && messageCount > 0) { armAndPin('auto') } }, [armAndPin, messageCount]) useAuiEvent('thread.runStart', () => { armAndPin('instant') }) return null } function pickPrimaryPreviewTarget(targets: string[]): string[] { if (targets.length <= 1) { return targets } const localUrl = targets.find(value => /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(value)) return [localUrl || targets[targets.length - 1]] } const CenteredThreadSpinner: FC = () => (
) 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) const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content]) const previewTargets = useMemo(() => { if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) { return [] } return pickPrimaryPreviewTarget(extractPreviewTargets(messageText)) }, [messageText]) const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0) if (isPlaceholder) { return null } return (
{hoistedTodos.length > 0 && } {previewTargets.length > 0 && (
{previewTargets.map(target => ( ))}
)}
{messageText.trim().length > 0 && ( )}
) } const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ children, label, className, ...rest }) => (
{children}
) const ResponseLoadingIndicator: FC = () => { const elapsed = useElapsedSeconds() return ( ) } 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 => { // todo parts are hoisted to a dedicated panel above the message content. if (props.toolName === 'todo') {return null} if (props.toolName === 'image_generate') {return } if (props.toolName === 'clarify') {return } return } const ThinkingDisclosure: FC<{ children: ReactNode pending?: boolean timerKey?: string }> = ({ children, pending = false, timerKey }) => { const [open, setOpen] = useState(false) const elapsed = useElapsedSeconds(pending, timerKey) return (
setOpen(v => !v)} open={open}> Thinking {pending && ( )} {open && (
{children}
)}
) } const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ children }) => { const pending = useAuiState(s => s.thread.isRunning && s.message.status?.type === 'running') const messageId = useAuiState(s => s.message.id) return {children} } const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => { const displayText = text.trimStart() return (
{displayText}
) } 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 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 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 EMPTY_ATTACHMENT_REFS: string[] = [] function messageAttachmentRefs(value: unknown): string[] { if (!Array.isArray(value)) { return EMPTY_ATTACHMENT_REFS } return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS } const UserMessage: FC = () => { const content = useAuiState(s => s.message.content) const messageText = messageContentText(content) const attachmentRefs = useAuiState(s => { const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown } return messageAttachmentRefs(custom.attachmentRefs) }) const hasBody = messageText.trim().length > 0 return (
{attachmentRefs.length > 0 && (
)} {hasBody && (
)}
) } const UserActionBar: FC<{ messageText: string }> = ({ messageText }) => (
triggerHaptic('selection')} tooltip="Edit">
) const SLASH_STATUS_RE = /^slash:(?\/[^\n]+)\n(?[\s\S]*)$/ const SystemMessage: FC = () => { const text = useAuiState(s => messageContentText(s.message.content)) if (!text) { return null } const slashStatus = text.match(SLASH_STATUS_RE) if (slashStatus?.groups) { return ( {slashStatus.groups.command} ยท {slashStatus.groups.output.trim()} ) } return ( {text} ) } const UserEditComposer: FC = () => (
triggerHaptic('submit')} tooltip="Send edit">
)