Files
hapi/web/src/components/SessionChat.tsx
T
weishu 0f29ee182c refactor: replace React Query message cache with windowed message store
Introduces a new message-window-store module to manage message state with
automatic windowing of visible and pending messages. This replaces manual
React Query cache operations with a centralized, observable state system.
close #39

Key changes:
- New MessageWindowState tracks visible/pending messages with size limits
- Automatic trimming of message windows (400 visible, 200 pending messages)
- Pending message buffering when user scrolls away from bottom
- Centralized status updates for optimistic messages
- Thread component simplified with forwarded scroll and pending callbacks
- Removes message count tracking from components

This improves performance for chats with many messages and provides a
cleaner separation of concerns between UI and state management.
2026-01-07 16:59:12 +08:00

218 lines
8.6 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { AssistantRuntimeProvider } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client'
import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api'
import type { ChatBlock, NormalizedMessage } from '@/chat/types'
import type { Suggestion } from '@/hooks/useActiveSuggestions'
import { normalizeDecryptedMessage } from '@/chat/normalize'
import { reduceChatBlocks } from '@/chat/reducer'
import { reconcileChatBlocks } from '@/chat/reconcile'
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
import { HappyThread } from '@/components/AssistantChat/HappyThread'
import { useHappyRuntime } from '@/lib/assistant-runtime'
import { SessionHeader } from '@/components/SessionHeader'
import { usePlatform } from '@/hooks/usePlatform'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
export function SessionChat(props: {
api: ApiClient
session: Session
messages: DecryptedMessage[]
messagesWarning: string | null
hasMoreMessages: boolean
isLoadingMessages: boolean
isLoadingMoreMessages: boolean
isSending: boolean
pendingCount: number
messagesVersion: number
onBack: () => void
onRefresh: () => void
onLoadMore: () => Promise<unknown>
onSend: (text: string) => void
onFlushPending: () => void
onAtBottomChange: (atBottom: boolean) => void
onRetryMessage?: (localId: string) => void
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
}) {
const { haptic } = usePlatform()
const navigate = useNavigate()
const controlsDisabled = !props.session.active
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
const [forceScrollToken, setForceScrollToken] = useState(0)
const agentFlavor = props.session.metadata?.flavor ?? null
const { abortSession, switchSession, setPermissionMode, setModelMode } = useSessionActions(
props.api,
props.session.id,
agentFlavor
)
useEffect(() => {
normalizedCacheRef.current.clear()
blocksByIdRef.current.clear()
}, [props.session.id])
const normalizedMessages: NormalizedMessage[] = useMemo(() => {
const cache = normalizedCacheRef.current
const normalized: NormalizedMessage[] = []
const seen = new Set<string>()
for (const message of props.messages) {
seen.add(message.id)
const cached = cache.get(message.id)
if (cached && cached.source === message) {
if (cached.normalized) normalized.push(cached.normalized)
continue
}
const next = normalizeDecryptedMessage(message)
cache.set(message.id, { source: message, normalized: next })
if (next) normalized.push(next)
}
for (const id of cache.keys()) {
if (!seen.has(id)) {
cache.delete(id)
}
}
return normalized
}, [props.messages])
const reduced = useMemo(
() => reduceChatBlocks(normalizedMessages, props.session.agentState),
[normalizedMessages, props.session.agentState]
)
const reconciled = useMemo(
() => reconcileChatBlocks(reduced.blocks, blocksByIdRef.current),
[reduced.blocks]
)
useEffect(() => {
blocksByIdRef.current = reconciled.byId
}, [reconciled.byId])
// Permission mode change handler
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
try {
await setPermissionMode(mode)
haptic.notification('success')
props.onRefresh()
} catch (e) {
haptic.notification('error')
console.error('Failed to set permission mode:', e)
}
}, [setPermissionMode, props.onRefresh, haptic])
// Model mode change handler
const handleModelModeChange = useCallback(async (mode: ModelMode) => {
try {
await setModelMode(mode)
haptic.notification('success')
props.onRefresh()
} catch (e) {
haptic.notification('error')
console.error('Failed to set model mode:', e)
}
}, [setModelMode, props.onRefresh, haptic])
// Abort handler
const handleAbort = useCallback(async () => {
await abortSession()
props.onRefresh()
}, [abortSession, props.onRefresh])
// Switch to remote handler
const handleSwitchToRemote = useCallback(async () => {
await switchSession()
props.onRefresh()
}, [switchSession, props.onRefresh])
const handleViewFiles = useCallback(() => {
navigate({
to: '/sessions/$sessionId/files',
params: { sessionId: props.session.id }
})
}, [navigate, props.session.id])
const handleViewTerminal = useCallback(() => {
navigate({
to: '/sessions/$sessionId/terminal',
params: { sessionId: props.session.id }
})
}, [navigate, props.session.id])
const handleSend = useCallback((text: string) => {
props.onSend(text)
setForceScrollToken((token) => token + 1)
}, [props.onSend])
const runtime = useHappyRuntime({
session: props.session,
blocks: reconciled.blocks,
isSending: props.isSending,
onSendMessage: handleSend,
onAbort: handleAbort
})
return (
<div className="flex h-full flex-col">
<SessionHeader
session={props.session}
onBack={props.onBack}
onViewFiles={props.session.metadata?.path ? handleViewFiles : undefined}
api={props.api}
onSessionDeleted={props.onBack}
/>
{controlsDisabled ? (
<div className="px-3 pt-3">
<div className="mx-auto w-full max-w-content rounded-md bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-hint)]">
Session is inactive. Controls are disabled.
</div>
</div>
) : null}
<AssistantRuntimeProvider runtime={runtime}>
<div className="relative flex min-h-0 flex-1 flex-col">
<HappyThread
key={props.session.id}
api={props.api}
sessionId={props.session.id}
metadata={props.session.metadata}
disabled={controlsDisabled}
onRefresh={props.onRefresh}
onRetryMessage={props.onRetryMessage}
onFlushPending={props.onFlushPending}
onAtBottomChange={props.onAtBottomChange}
isLoadingMessages={props.isLoadingMessages}
messagesWarning={props.messagesWarning}
hasMoreMessages={props.hasMoreMessages}
isLoadingMoreMessages={props.isLoadingMoreMessages}
onLoadMore={props.onLoadMore}
pendingCount={props.pendingCount}
rawMessagesCount={props.messages.length}
normalizedMessagesCount={normalizedMessages.length}
messagesVersion={props.messagesVersion}
forceScrollToken={forceScrollToken}
/>
<HappyComposer
disabled={props.isSending || controlsDisabled}
permissionMode={props.session.permissionMode}
modelMode={props.session.modelMode}
agentFlavor={agentFlavor}
active={props.session.active}
thinking={props.session.thinking}
agentState={props.session.agentState}
contextSize={reduced.latestUsage?.contextSize}
controlledByUser={props.session.agentState?.controlledByUser === true}
onPermissionModeChange={handlePermissionModeChange}
onModelModeChange={handleModelModeChange}
onSwitchToRemote={handleSwitchToRemote}
onTerminal={props.session.active ? handleViewTerminal : undefined}
autocompleteSuggestions={props.autocompleteSuggestions}
/>
</div>
</AssistantRuntimeProvider>
</div>
)
}