mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
Add web conversation outline (#534)
This commit is contained in:
@@ -2,6 +2,8 @@ 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'
|
||||
@@ -9,6 +11,7 @@ import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMe
|
||||
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()
|
||||
@@ -55,6 +58,97 @@ const THREAD_MESSAGE_COMPONENTS = {
|
||||
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
|
||||
@@ -74,6 +168,11 @@ export function HappyThread(props: {
|
||||
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)
|
||||
@@ -210,6 +309,16 @@ export function HappyThread(props: {
|
||||
})
|
||||
}, [])
|
||||
|
||||
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])
|
||||
@@ -333,6 +442,25 @@ export function HappyThread(props: {
|
||||
</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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user