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

@ -193,7 +193,7 @@ interface ElectronAPI {
localChat: {
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>
getHistory: (agentId: string) => Promise<{ messages: unknown[] }>
getHistory: (agentId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number }>
send: (agentId: string, content: string) => Promise<{ ok?: boolean; error?: string }>
resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }>
onEvent: (callback: (event: LocalChatEvent) => void) => void

View file

@ -340,23 +340,28 @@ export function registerHubIpcHandlers(): void {
})
/**
* Get message history for local chat.
* Get message history for local chat with pagination.
* Returns raw AgentMessageItem[] so the renderer can render content blocks,
* tool results, thinking blocks, etc. same format as the Gateway RPC.
*/
ipcMain.handle('localChat:getHistory', async (_event, agentId: string) => {
ipcMain.handle('localChat:getHistory', async (_event, agentId: string, options?: { offset?: number; limit?: number }) => {
const h = getHub()
const agent = h.getAgent(agentId)
if (!agent) {
return { messages: [] }
return { messages: [], total: 0, offset: 0, limit: 0 }
}
try {
await agent.ensureInitialized()
const messages = agent.getMessages()
return { messages }
const allMessages = agent.getMessages()
const total = allMessages.length
// Must match DEFAULT_MESSAGES_LIMIT from @multica/sdk/actions/rpc
const limit = options?.limit ?? 10
const offset = options?.offset ?? Math.max(0, total - limit)
const sliced = allMessages.slice(offset, offset + limit)
return { messages: sliced, total, offset, limit }
} catch {
return { messages: [] }
return { messages: [], total: 0, offset: 0, limit: 0 }
}
})

View file

@ -207,8 +207,9 @@ const electronAPI = {
subscribe: (agentId: string) => ipcRenderer.invoke('localChat:subscribe', agentId),
/** Unsubscribe from agent events */
unsubscribe: (agentId: string) => ipcRenderer.invoke('localChat:unsubscribe', agentId),
/** Get message history for local chat (returns raw AgentMessageItem[]) */
getHistory: (agentId: string) => ipcRenderer.invoke('localChat:getHistory', agentId),
/** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */
getHistory: (agentId: string, options?: { offset?: number; limit?: number }) =>
ipcRenderer.invoke('localChat:getHistory', agentId, options),
/** Send message to agent via direct IPC (no Gateway) */
send: (agentId: string, content: string) => ipcRenderer.invoke('localChat:send', agentId, content),
/** Resolve an exec approval request */

View file

@ -10,9 +10,12 @@ export function LocalChat() {
streamingIds,
isLoading,
isLoadingHistory,
isLoadingMore,
hasMore,
error,
pendingApprovals,
sendMessage,
loadMore,
resolveApproval,
} = useLocalChat()
@ -39,9 +42,12 @@ export function LocalChat() {
streamingIds={streamingIds}
isLoading={isLoading}
isLoadingHistory={isLoadingHistory}
isLoadingMore={isLoadingMore}
hasMore={hasMore}
error={error}
pendingApprovals={pendingApprovals}
sendMessage={sendMessage}
loadMore={loadMore}
resolveApproval={resolveApproval}
/>
)

View file

@ -4,7 +4,9 @@ import type {
StreamPayload,
ExecApprovalRequestPayload,
ApprovalDecision,
AgentMessageItem,
} from '@multica/sdk'
import { DEFAULT_MESSAGES_LIMIT } from '@multica/sdk'
export function useLocalChat() {
const chat = useChat()
@ -13,8 +15,11 @@ export function useLocalChat() {
const [agentId, setAgentId] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingHistory, setIsLoadingHistory] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const isLoadingMoreRef = useRef(false)
const [initError, setInitError] = useState<string | null>(null)
const initRef = useRef(false)
const offsetRef = useRef<number | null>(null)
// Initialize hub and get default agent ID
useEffect(() => {
@ -61,12 +66,16 @@ export function useLocalChat() {
chatRef.current.addApproval(approval as ExecApprovalRequestPayload)
})
// Fetch history
window.electronAPI.localChat.getHistory(agentId)
// Fetch history with pagination
window.electronAPI.localChat.getHistory(agentId, { limit: DEFAULT_MESSAGES_LIMIT })
.then((result) => {
console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, sample:', result.messages?.[0])
console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, total:', result.total)
if (result.messages?.length) {
chatRef.current.setHistory(result.messages as never[], agentId)
chatRef.current.setHistory(result.messages as AgentMessageItem[], agentId, {
total: result.total,
offset: result.offset,
})
offsetRef.current = result.offset
}
})
.catch(() => {})
@ -91,6 +100,31 @@ export function useLocalChat() {
[agentId],
)
const loadMore = useCallback(async () => {
const currentOffset = offsetRef.current
if (!agentId || 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 window.electronAPI.localChat.getHistory(agentId, { offset: newOffset, limit })
if (result.messages?.length) {
chatRef.current.prependHistory(result.messages as AgentMessageItem[], 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)
}
}, [agentId])
const resolveApproval = useCallback(
(approvalId: string, decision: ApprovalDecision) => {
chatRef.current.removeApproval(approvalId)
@ -106,9 +140,12 @@ export function useLocalChat() {
streamingIds: chat.streamingIds,
isLoading,
isLoadingHistory,
isLoadingMore,
hasMore: chat.hasMore,
error: chat.error,
pendingApprovals: chat.pendingApprovals,
sendMessage,
loadMore,
resolveApproval,
}
}

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,
};
}

View file

@ -15,6 +15,7 @@ export {
isResponseSuccess,
isResponseError,
type AgentMessageItem,
DEFAULT_MESSAGES_LIMIT,
type GetAgentMessagesParams,
type GetAgentMessagesResult,
type GetHubInfoResult,

View file

@ -60,6 +60,9 @@ export function isResponseError(
// ============ RPC Method Types ============
/** Default number of messages returned per page */
export const DEFAULT_MESSAGES_LIMIT = 10;
/** getAgentMessages - request params */
export interface GetAgentMessagesParams {
agentId: string;

View file

@ -1,6 +1,6 @@
"use client";
import { useRef } from "react";
import { useRef, useEffect, useCallback } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { ChatInput } from "@multica/ui/components/chat-input";
@ -30,9 +30,12 @@ export interface ChatViewProps {
streamingIds: Set<string>;
isLoading: boolean;
isLoadingHistory: boolean;
isLoadingMore?: boolean;
hasMore?: boolean;
error: ChatViewError | null;
pendingApprovals: ChatViewApproval[];
sendMessage: (text: string) => void;
loadMore?: () => void;
resolveApproval: (approvalId: string, decision: "allow-once" | "allow-always" | "deny") => void;
onDisconnect?: () => void;
}
@ -42,15 +45,76 @@ export function ChatView({
streamingIds,
isLoading,
isLoadingHistory,
isLoadingMore = false,
hasMore = false,
error,
pendingApprovals,
sendMessage,
loadMore,
resolveApproval,
onDisconnect,
}: ChatViewProps) {
const mainRef = useRef<HTMLElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const fadeStyle = useScrollFade(mainRef);
useAutoScroll(mainRef);
const { suppressAutoScroll } = useAutoScroll(mainRef);
// scrollHeight compensation for prepended messages
const prevScrollHeightRef = useRef(0);
const isPrependingRef = useRef(false);
const unlockRef = useRef<(() => void) | null>(null);
// Snapshot scrollHeight before prepend render
const onLoadMore = useCallback(() => {
if (!loadMore || !mainRef.current) return;
const el = mainRef.current;
prevScrollHeightRef.current = el.scrollHeight;
isPrependingRef.current = true;
// Lock auto-scroll during prepend
unlockRef.current = suppressAutoScroll();
loadMore();
}, [loadMore, suppressAutoScroll]);
// After messages change, compensate scroll position if we just prepended
useEffect(() => {
const el = mainRef.current;
if (!el || !isPrependingRef.current) return;
isPrependingRef.current = false;
// Double-rAF ensures DOM layout is complete before compensating
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const newScrollHeight = el.scrollHeight;
const heightDiff = newScrollHeight - prevScrollHeightRef.current;
if (heightDiff > 0) {
el.scrollTop = el.scrollTop + heightDiff;
}
// Release auto-scroll lock after position is restored
unlockRef.current?.();
unlockRef.current = null;
});
});
}, [messages]);
// IntersectionObserver to trigger loadMore when sentinel is visible
// Skip during initial history load to avoid premature triggering
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || isLoadingHistory) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasMore && !isLoadingMore) {
onLoadMore();
}
},
{ rootMargin: "100px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, isLoadingMore, isLoadingHistory, onLoadMore]);
return (
<div className="flex-1 flex flex-col min-h-0">
@ -122,6 +186,15 @@ export function ChatView({
</div>
) : (
<>
{/* Sentinel element for IntersectionObserver load-more trigger */}
<div ref={sentinelRef} className="h-px shrink-0" />
{isLoadingMore && (
<div className="flex justify-center py-3">
<div className="text-xs text-muted-foreground animate-pulse">
Loading older messages...
</div>
</div>
)}
<MessageList messages={messages} streamingIds={streamingIds} />
{pendingApprovals.length > 0 && (
<div className="relative px-4 max-w-4xl mx-auto">

View file

@ -1,15 +1,15 @@
import { type RefObject, useEffect, useRef } from "react"
import { type RefObject, useEffect, useRef, useCallback } from "react"
/**
* Auto-scrolls a scroll container to the bottom when its inner content grows,
* as long as the user hasn't scrolled up to read older content.
*
* Observes child element size changes via ResizeObserver on all children,
* plus MutationObserver for added/removed nodes. Works for new messages,
* history loads, streaming updates, and image loads.
* Returns a `lockRef` that can be set to `true` to temporarily suppress
* auto-scroll (e.g. during history prepend operations).
*/
export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
const stickRef = useRef(true)
const lockRef = useRef(false)
useEffect(() => {
const el = ref.current
@ -25,6 +25,7 @@ export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
}
const onContentChange = () => {
if (lockRef.current) return
if (stickRef.current) {
scrollToBottom()
}
@ -61,4 +62,12 @@ export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
mo.disconnect()
}
}, [ref])
/** Temporarily suppress auto-scroll during prepend operations */
const suppressAutoScroll = useCallback(() => {
lockRef.current = true
return () => { lockRef.current = false }
}, [])
return { suppressAutoScroll }
}

View file

@ -3,6 +3,9 @@ import { SessionManager } from "../../../agent/session/session-manager.js";
import { resolveSessionPath } from "../../../agent/session/storage.js";
import { RpcError, type RpcHandler } from "../dispatcher.js";
// Must match DEFAULT_MESSAGES_LIMIT from @multica/sdk/actions/rpc
const DEFAULT_LIMIT = 10;
interface GetAgentMessagesParams {
agentId: string;
offset?: number;
@ -14,7 +17,8 @@ export function createGetAgentMessagesHandler(): RpcHandler {
if (!params || typeof params !== "object") {
throw new RpcError("INVALID_PARAMS", "params must be an object");
}
const { agentId, offset = 0, limit = 50 } = params as GetAgentMessagesParams;
const { agentId, limit = DEFAULT_LIMIT } = params as GetAgentMessagesParams;
let { offset } = params as GetAgentMessagesParams;
if (!agentId) {
throw new RpcError("INVALID_PARAMS", "Missing required param: agentId");
}
@ -27,6 +31,12 @@ export function createGetAgentMessagesHandler(): RpcHandler {
const session = new SessionManager({ sessionId: agentId });
const allMessages = session.loadMessages();
const total = allMessages.length;
// When offset is not provided, return the latest messages
if (offset == null) {
offset = Math.max(0, total - limit);
}
const sliced = allMessages.slice(offset, offset + limit);
return { messages: sliced, total, offset, limit };