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:
parent
c2b5ada2ef
commit
65c2fea1b6
12 changed files with 232 additions and 28 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue