Files
hapi/web/src/components/AssistantChat/HappyThread.tsx
T

468 lines
20 KiB
TypeScript

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 (
<button
onClick={props.onClick}
className="absolute bottom-20 left-1/2 -translate-x-1/2 bg-[var(--app-button)] text-[var(--app-button-text)] px-3 py-1.5 rounded-full text-sm font-medium shadow-lg animate-bounce-in z-10"
>
{t('misc.newMessage', { n: props.count })} &#8595;
</button>
)
}
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 (
<div role="status" aria-live="polite">
<span className="sr-only">{t('misc.loadingMessages')}</span>
<div className="space-y-3 animate-pulse">
{rows.map((row, index) => (
<div key={`skeleton-${index}`} className={row.align === 'end' ? 'flex justify-end' : 'flex justify-start'}>
<div className={`${row.height} ${row.width} rounded-xl bg-[var(--app-subtle-bg)]`} />
</div>
))}
</div>
</div>
)
}
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 (
<aside
className="absolute inset-y-0 right-0 z-30 flex w-full max-w-[24rem] flex-col border-l border-[var(--app-border)] bg-[var(--app-bg)] shadow-2xl sm:w-[24rem]"
aria-label={t('session.outline.title')}
>
<div className="flex items-start gap-3 border-b border-[var(--app-border)] p-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">{t('session.outline.title')}</div>
<div className="mt-0.5 truncate text-xs text-[var(--app-hint)]">{props.title}</div>
</div>
<button
type="button"
onClick={props.onClose}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
aria-label={t('button.close')}
title={t('button.close')}
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
{props.hasMoreMessages ? (
<div className="border-b border-[var(--app-border)] p-3">
<Button
variant="outline"
size="sm"
onClick={props.onLoadMore}
disabled={props.isLoadingMoreMessages}
aria-busy={props.isLoadingMoreMessages}
className="w-full gap-1.5 text-xs"
>
{props.isLoadingMoreMessages ? (
<>
<Spinner size="sm" label={null} className="text-current" />
{t('misc.loading')}
</>
) : (
<>
<span aria-hidden="true"></span>
{t('session.outline.loadOlder')}
</>
)}
</Button>
</div>
) : null}
<div className="app-scroll-y min-h-0 flex-1 p-2">
{props.items.length === 0 ? (
<div className="px-2 py-8 text-center text-sm text-[var(--app-hint)]">
{t('session.outline.empty')}
</div>
) : (
<div className="space-y-1">
{props.items.map((item) => {
return (
<button
key={item.id}
type="button"
onClick={() => props.onSelect(item)}
className="group flex w-full min-w-0 items-start gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-[var(--app-subtle-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
>
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-[var(--app-button)]" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium uppercase text-[var(--app-hint)]">
{t('session.outline.kind.user')}
</span>
<span className="line-clamp-2 text-sm leading-snug text-[var(--app-fg)]">
{item.label}
</span>
</span>
</button>
)
})}
</div>
)}
</div>
</aside>
)
}
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<unknown>
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<HTMLDivElement | null>(null)
const topSentinelRef = useRef<HTMLDivElement | null>(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<unknown>
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 (
<HappyChatProvider value={{
api: props.api,
sessionId: props.sessionId,
metadata: props.metadata,
disabled: props.disabled,
onRefresh: props.onRefresh,
onRetryMessage: props.onRetryMessage
}}>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col relative">
<ThreadPrimitive.Viewport asChild autoScroll={autoScrollEnabled}>
<div ref={viewportRef} className="app-scroll-y min-h-0 flex-1 overflow-x-hidden">
<div className="mx-auto w-full max-w-content min-w-0 p-3">
<div ref={topSentinelRef} className="h-px w-full" aria-hidden="true" />
{showSkeleton ? (
<MessageSkeleton />
) : (
<>
{props.messagesWarning ? (
<div className="mb-3 rounded-md bg-amber-500/10 p-2 text-xs">
{props.messagesWarning}
</div>
) : null}
{props.hasMoreMessages && !props.isLoadingMessages ? (
<div className="py-1 mb-2">
<div className="mx-auto w-fit">
<Button
variant="outline"
size="sm"
onClick={handleLoadMore}
disabled={props.isLoadingMoreMessages || props.isLoadingMessages}
aria-busy={props.isLoadingMoreMessages}
className="gap-1.5 text-xs opacity-80 hover:opacity-100"
>
{props.isLoadingMoreMessages ? (
<>
<Spinner size="sm" label={null} className="text-current" />
{t('misc.loading')}
</>
) : (
<>
<span aria-hidden="true"></span>
{t('misc.loadOlder')}
</>
)}
</Button>
</div>
</div>
) : null}
{import.meta.env.DEV && props.normalizedMessagesCount === 0 && props.rawMessagesCount > 0 ? (
<div className="mb-2 rounded-md bg-amber-500/10 p-2 text-xs">
Message normalization returned 0 items for {props.rawMessagesCount} messages (see `web/src/chat/normalize.ts`).
</div>
) : null}
</>
)}
<div className="happy-thread-messages flex flex-col gap-3">
<ThreadPrimitive.Messages components={THREAD_MESSAGE_COMPONENTS} />
</div>
</div>
</div>
</ThreadPrimitive.Viewport>
<NewMessagesIndicator count={props.pendingCount} onClick={scrollToBottom} />
{props.outlineOpen ? (
<>
<button
type="button"
className="absolute inset-0 z-20 bg-black/20"
aria-label={t('session.outline.close')}
onClick={() => props.onOutlineOpenChange(false)}
/>
<ConversationOutlinePanel
title={props.outlineTitle}
items={props.outlineItems}
hasMoreMessages={props.hasMoreMessages}
isLoadingMoreMessages={props.isLoadingMoreMessages}
onLoadMore={handleLoadMore}
onSelect={handleOutlineSelect}
onClose={() => props.onOutlineOpenChange(false)}
/>
</>
) : null}
</ThreadPrimitive.Root>
</HappyChatProvider>
)
}