feat(chat): add message history pagination with scroll-up loading

Return latest messages by default instead of oldest. Support paginated
loading of older messages when scrolling up via IntersectionObserver,
with scrollHeight compensation to preserve scroll position.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 18:40:15 +08:00
parent c2b5ada2ef
commit 65c2fea1b6
12 changed files with 232 additions and 28 deletions

View file

@ -64,11 +64,12 @@ export function useChat() {
const [streamingIds, setStreamingIds] = useState<Set<string>>(new Set());
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);
const [error, setError] = useState<ChatError | null>(null);
const [hasMore, setHasMore] = useState(false);
const isStreaming = streamingIds.size > 0;
/** Load history: convert raw AgentMessageItem[] → Message[] */
const setHistory = useCallback((raw: AgentMessageItem[], agentId: string) => {
/** Convert raw AgentMessageItem[] → Message[] */
const convertMessages = useCallback((raw: AgentMessageItem[], agentId: string): Message[] => {
const toolCallArgsMap = new Map<string, { name: string; args: Record<string, unknown> }>();
for (const m of raw) {
if (m.role === "assistant") {
@ -101,10 +102,25 @@ export function useChat() {
});
}
}
setMessages(loaded);
return loaded;
}, []);
/** Load initial history (replaces all messages) */
const setHistory = useCallback((raw: AgentMessageItem[], agentId: string, meta?: { total: number; offset: number }) => {
const loaded = convertMessages(raw, agentId);
setMessages(loaded);
if (meta) {
setHasMore(meta.offset > 0);
}
}, [convertMessages]);
/** Prepend older messages (for "load more" pagination) */
const prependHistory = useCallback((raw: AgentMessageItem[], agentId: string, meta: { total: number; offset: number }) => {
const older = convertMessages(raw, agentId);
setMessages((prev) => [...older, ...prev]);
setHasMore(meta.offset > 0);
}, [convertMessages]);
/** Add a user message */
const addUserMessage = useCallback((text: string, agentId: string) => {
setMessages((prev) => [
@ -217,11 +233,13 @@ export function useChat() {
messages,
streamingIds,
isStreaming,
hasMore,
pendingApprovals,
error,
// State control (for transport layer to call)
setError,
setHistory,
prependHistory,
addUserMessage,
handleStream,
addApproval,

View file

@ -1,12 +1,13 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import {
type GatewayClient,
type StreamPayload,
type GetAgentMessagesResult,
type ExecApprovalRequestPayload,
type ApprovalDecision,
DEFAULT_MESSAGES_LIMIT,
StreamAction,
ExecApprovalRequestAction,
} from "@multica/sdk";
@ -22,12 +23,24 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions
const chat = useChat();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const isLoadingMoreRef = useRef(false);
const offsetRef = useRef<number | null>(null);
// Fetch history
// Fetch latest messages on mount
useEffect(() => {
client
.request<GetAgentMessagesResult>(hubId, "getAgentMessages", { agentId, limit: 200 })
.then((result) => chat.setHistory(result.messages, agentId))
.request<GetAgentMessagesResult>(hubId, "getAgentMessages", {
agentId,
limit: DEFAULT_MESSAGES_LIMIT,
})
.then((result) => {
chat.setHistory(result.messages, agentId, {
total: result.total,
offset: result.offset,
});
offsetRef.current = result.offset;
})
.catch(() => {})
.finally(() => setIsLoadingHistory(false));
}, [client, hubId, agentId]);
@ -66,6 +79,31 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions
[client, hubId, agentId],
);
const loadMore = useCallback(async () => {
const currentOffset = offsetRef.current;
if (currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return;
isLoadingMoreRef.current = true;
setIsLoadingMore(true);
try {
const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT);
const limit = currentOffset - newOffset;
const result = await client.request<GetAgentMessagesResult>(
hubId, "getAgentMessages", { agentId, offset: newOffset, limit },
);
chat.prependHistory(result.messages, agentId, {
total: result.total,
offset: result.offset,
});
offsetRef.current = result.offset;
} catch {
// Best-effort — pagination failure does not block chat
} finally {
isLoadingMoreRef.current = false;
setIsLoadingMore(false);
}
}, [client, hubId, agentId]);
const resolveApproval = useCallback(
(approvalId: string, decision: ApprovalDecision) => {
chat.removeApproval(approvalId);
@ -79,9 +117,12 @@ export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions
streamingIds: chat.streamingIds,
isLoading,
isLoadingHistory,
isLoadingMore,
hasMore: chat.hasMore,
error: chat.error,
pendingApprovals: chat.pendingApprovals,
sendMessage,
loadMore,
resolveApproval,
};
}