diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index fb78d243..d9a5846e 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -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 diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index 09ee0319..1b47b01c 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -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 } } }) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index c6081c7c..a0d3d00e 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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 */ diff --git a/apps/desktop/src/components/local-chat.tsx b/apps/desktop/src/components/local-chat.tsx index 95f1f81b..5384c063 100644 --- a/apps/desktop/src/components/local-chat.tsx +++ b/apps/desktop/src/components/local-chat.tsx @@ -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} /> ) diff --git a/apps/desktop/src/hooks/use-local-chat.ts b/apps/desktop/src/hooks/use-local-chat.ts index 5c2c5408..cf637f25 100644 --- a/apps/desktop/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/hooks/use-local-chat.ts @@ -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(null) const [isLoading, setIsLoading] = useState(false) const [isLoadingHistory, setIsLoadingHistory] = useState(true) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const isLoadingMoreRef = useRef(false) const [initError, setInitError] = useState(null) const initRef = useRef(false) + const offsetRef = useRef(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, } } diff --git a/packages/hooks/src/use-chat.ts b/packages/hooks/src/use-chat.ts index acb6691b..5711cde5 100644 --- a/packages/hooks/src/use-chat.ts +++ b/packages/hooks/src/use-chat.ts @@ -64,11 +64,12 @@ export function useChat() { const [streamingIds, setStreamingIds] = useState>(new Set()); const [pendingApprovals, setPendingApprovals] = useState([]); const [error, setError] = useState(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 }>(); 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, diff --git a/packages/hooks/src/use-gateway-chat.ts b/packages/hooks/src/use-gateway-chat.ts index ec085a10..4613bbfb 100644 --- a/packages/hooks/src/use-gateway-chat.ts +++ b/packages/hooks/src/use-gateway-chat.ts @@ -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(null); - // Fetch history + // Fetch latest messages on mount useEffect(() => { client - .request(hubId, "getAgentMessages", { agentId, limit: 200 }) - .then((result) => chat.setHistory(result.messages, agentId)) + .request(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( + 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, }; } diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index a1fb3e9f..c378a6d6 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -15,6 +15,7 @@ export { isResponseSuccess, isResponseError, type AgentMessageItem, + DEFAULT_MESSAGES_LIMIT, type GetAgentMessagesParams, type GetAgentMessagesResult, type GetHubInfoResult, diff --git a/packages/sdk/src/actions/rpc.ts b/packages/sdk/src/actions/rpc.ts index 32c605de..78962bba 100644 --- a/packages/sdk/src/actions/rpc.ts +++ b/packages/sdk/src/actions/rpc.ts @@ -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; diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index a5e17df9..3c210b4a 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -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; 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(null); + const sentinelRef = useRef(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 (
@@ -122,6 +186,15 @@ export function ChatView({
) : ( <> + {/* Sentinel element for IntersectionObserver load-more trigger */} +
+ {isLoadingMore && ( +
+
+ Loading older messages... +
+
+ )} {pendingApprovals.length > 0 && (
diff --git a/packages/ui/src/hooks/use-auto-scroll.ts b/packages/ui/src/hooks/use-auto-scroll.ts index 8090631b..8455722c 100644 --- a/packages/ui/src/hooks/use-auto-scroll.ts +++ b/packages/ui/src/hooks/use-auto-scroll.ts @@ -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) { const stickRef = useRef(true) + const lockRef = useRef(false) useEffect(() => { const el = ref.current @@ -25,6 +25,7 @@ export function useAutoScroll(ref: RefObject) { } const onContentChange = () => { + if (lockRef.current) return if (stickRef.current) { scrollToBottom() } @@ -61,4 +62,12 @@ export function useAutoScroll(ref: RefObject) { mo.disconnect() } }, [ref]) + + /** Temporarily suppress auto-scroll during prepend operations */ + const suppressAutoScroll = useCallback(() => { + lockRef.current = true + return () => { lockRef.current = false } + }, []) + + return { suppressAutoScroll } } diff --git a/src/hub/rpc/handlers/get-agent-messages.ts b/src/hub/rpc/handlers/get-agent-messages.ts index 15060fff..39c8c38f 100644 --- a/src/hub/rpc/handlers/get-agent-messages.ts +++ b/src/hub/rpc/handlers/get-agent-messages.ts @@ -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 };