mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
468 lines
20 KiB
TypeScript
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 })} ↓
|
|
</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>
|
|
)
|
|
}
|