import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ThreadPrimitive } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import type { ConversationOutlineItem } from '@/chat/outline'
import { getConversationMessageAnchorId } from '@/chat/outline'
import { HappyChatProvider } from '@/components/AssistantChat/context'
import { HappyAssistantMessage } from '@/components/AssistantChat/messages/AssistantMessage'
import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage'
import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMessage'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/Spinner'
import { useTranslation } from '@/lib/use-translation'
import { CloseIcon } from '@/components/icons'
function NewMessagesIndicator(props: { count: number; onClick: () => void }) {
const { t } = useTranslation()
if (props.count === 0) {
return null
}
return (
)
}
function MessageSkeleton() {
const { t } = useTranslation()
const rows = [
{ align: 'end', width: 'w-2/3', height: 'h-10' },
{ align: 'start', width: 'w-3/4', height: 'h-12' },
{ align: 'end', width: 'w-1/2', height: 'h-9' },
{ align: 'start', width: 'w-5/6', height: 'h-14' }
]
return (
{t('misc.loadingMessages')}
{rows.map((row, index) => (
))}
)
}
const THREAD_MESSAGE_COMPONENTS = {
UserMessage: HappyUserMessage,
AssistantMessage: HappyAssistantMessage,
SystemMessage: HappySystemMessage
} as const
export function ConversationOutlinePanel(props: {
title: string
items: readonly ConversationOutlineItem[]
hasMoreMessages: boolean
isLoadingMoreMessages: boolean
onLoadMore: () => void
onSelect: (item: ConversationOutlineItem) => void
onClose: () => void
}) {
const { t } = useTranslation()
return (
)
}
export function HappyThread(props: {
api: ApiClient
sessionId: string
metadata: SessionMetadataSummary | null
disabled: boolean
onRefresh: () => void
onRetryMessage?: (localId: string) => void
onFlushPending: () => void
onAtBottomChange: (atBottom: boolean) => void
isLoadingMessages: boolean
messagesWarning: string | null
hasMoreMessages: boolean
isLoadingMoreMessages: boolean
onLoadMore: () => Promise
pendingCount: number
rawMessagesCount: number
normalizedMessagesCount: number
messagesVersion: number
forceScrollToken: number
outlineOpen: boolean
outlineTitle: string
outlineItems: readonly ConversationOutlineItem[]
onOutlineOpenChange: (open: boolean) => void
onOutlineItemClick?: (item: ConversationOutlineItem) => void
}) {
const { t } = useTranslation()
const viewportRef = useRef(null)
const topSentinelRef = useRef(null)
const loadLockRef = useRef(false)
const pendingScrollRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null)
const prevLoadingMoreRef = useRef(false)
const loadStartedRef = useRef(false)
const isLoadingMoreRef = useRef(props.isLoadingMoreMessages)
const hasMoreMessagesRef = useRef(props.hasMoreMessages)
const isLoadingMessagesRef = useRef(props.isLoadingMessages)
const onLoadMoreRef = useRef(props.onLoadMore)
const handleLoadMoreRef = useRef<() => void>(() => {})
const atBottomRef = useRef(true)
const onAtBottomChangeRef = useRef(props.onAtBottomChange)
const onFlushPendingRef = useRef(props.onFlushPending)
const forceScrollTokenRef = useRef(props.forceScrollToken)
// Smart scroll state: autoScroll enabled when user is near bottom
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true)
const autoScrollEnabledRef = useRef(autoScrollEnabled)
// Keep refs in sync with state
useEffect(() => {
autoScrollEnabledRef.current = autoScrollEnabled
}, [autoScrollEnabled])
useEffect(() => {
onAtBottomChangeRef.current = props.onAtBottomChange
}, [props.onAtBottomChange])
useEffect(() => {
onFlushPendingRef.current = props.onFlushPending
}, [props.onFlushPending])
useEffect(() => {
hasMoreMessagesRef.current = props.hasMoreMessages
}, [props.hasMoreMessages])
useEffect(() => {
isLoadingMessagesRef.current = props.isLoadingMessages
}, [props.isLoadingMessages])
useEffect(() => {
onLoadMoreRef.current = props.onLoadMore
}, [props.onLoadMore])
// Track scroll position to toggle autoScroll (stable listener using refs)
useEffect(() => {
const viewport = viewportRef.current
if (!viewport) return
const THRESHOLD_PX = 120
const handleScroll = () => {
const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight
const isNearBottom = distanceFromBottom < THRESHOLD_PX
if (isNearBottom) {
if (!autoScrollEnabledRef.current) setAutoScrollEnabled(true)
} else if (autoScrollEnabledRef.current) {
setAutoScrollEnabled(false)
}
if (isNearBottom !== atBottomRef.current) {
atBottomRef.current = isNearBottom
onAtBottomChangeRef.current(isNearBottom)
if (isNearBottom) {
onFlushPendingRef.current()
}
}
}
viewport.addEventListener('scroll', handleScroll, { passive: true })
return () => viewport.removeEventListener('scroll', handleScroll)
}, []) // Stable: no dependencies, reads from refs
// Scroll to bottom handler for the indicator button
const scrollToBottom = useCallback(() => {
const viewport = viewportRef.current
if (viewport) {
viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' })
}
setAutoScrollEnabled(true)
if (!atBottomRef.current) {
atBottomRef.current = true
onAtBottomChangeRef.current(true)
}
onFlushPendingRef.current()
}, [])
// Reset state when session changes
useEffect(() => {
setAutoScrollEnabled(true)
atBottomRef.current = true
onAtBottomChangeRef.current(true)
forceScrollTokenRef.current = props.forceScrollToken
}, [props.sessionId])
useEffect(() => {
if (forceScrollTokenRef.current === props.forceScrollToken) {
return
}
forceScrollTokenRef.current = props.forceScrollToken
scrollToBottom()
}, [props.forceScrollToken, scrollToBottom])
const handleLoadMore = useCallback(() => {
if (isLoadingMessagesRef.current || !hasMoreMessagesRef.current || isLoadingMoreRef.current || loadLockRef.current) {
return
}
const viewport = viewportRef.current
if (!viewport) {
return
}
pendingScrollRef.current = {
scrollTop: viewport.scrollTop,
scrollHeight: viewport.scrollHeight
}
loadLockRef.current = true
loadStartedRef.current = false
let loadPromise: Promise
try {
loadPromise = onLoadMoreRef.current()
} catch (error) {
pendingScrollRef.current = null
loadLockRef.current = false
throw error
}
void loadPromise.catch((error) => {
pendingScrollRef.current = null
loadLockRef.current = false
console.error('Failed to load older messages:', error)
}).finally(() => {
if (!loadStartedRef.current && !isLoadingMoreRef.current && pendingScrollRef.current) {
pendingScrollRef.current = null
loadLockRef.current = false
}
})
}, [])
const handleOutlineSelect = useCallback((item: ConversationOutlineItem) => {
const target = document.getElementById(getConversationMessageAnchorId(item.targetMessageId))
if (target) {
target.scrollIntoView({ block: 'start', behavior: 'smooth' })
setAutoScrollEnabled(false)
}
props.onOutlineItemClick?.(item)
props.onOutlineOpenChange(false)
}, [props.onOutlineItemClick, props.onOutlineOpenChange])
useEffect(() => {
handleLoadMoreRef.current = handleLoadMore
}, [handleLoadMore])
useEffect(() => {
const sentinel = topSentinelRef.current
const viewport = viewportRef.current
if (!sentinel || !viewport || !props.hasMoreMessages || props.isLoadingMessages) {
return
}
if (typeof IntersectionObserver === 'undefined') {
return
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
handleLoadMoreRef.current()
}
}
},
{
root: viewport,
rootMargin: '200px 0px 0px 0px'
}
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [props.hasMoreMessages, props.isLoadingMessages])
useLayoutEffect(() => {
const pending = pendingScrollRef.current
const viewport = viewportRef.current
if (!pending || !viewport) {
return
}
const delta = viewport.scrollHeight - pending.scrollHeight
viewport.scrollTop = pending.scrollTop + delta
pendingScrollRef.current = null
loadLockRef.current = false
}, [props.messagesVersion])
useEffect(() => {
isLoadingMoreRef.current = props.isLoadingMoreMessages
if (props.isLoadingMoreMessages) {
loadStartedRef.current = true
}
if (prevLoadingMoreRef.current && !props.isLoadingMoreMessages && pendingScrollRef.current) {
pendingScrollRef.current = null
loadLockRef.current = false
}
prevLoadingMoreRef.current = props.isLoadingMoreMessages
}, [props.isLoadingMoreMessages])
const showSkeleton = props.isLoadingMessages && props.rawMessagesCount === 0 && props.pendingCount === 0
return (
{showSkeleton ? (
) : (
<>
{props.messagesWarning ? (
{props.messagesWarning}
) : null}
{props.hasMoreMessages && !props.isLoadingMessages ? (
) : null}
{import.meta.env.DEV && props.normalizedMessagesCount === 0 && props.rawMessagesCount > 0 ? (
Message normalization returned 0 items for {props.rawMessagesCount} messages (see `web/src/chat/normalize.ts`).
) : null}
>
)}
{props.outlineOpen ? (
<>
)
}