multica/packages/hooks/src/use-gateway-chat.ts
Naiyuan Qing 65c2fea1b6 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>
2026-02-05 18:40:15 +08:00

128 lines
3.8 KiB
TypeScript

"use client";
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";
import { useChat } from "./use-chat";
interface UseGatewayChatOptions {
client: GatewayClient;
hubId: string;
agentId: string;
}
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 latest messages on mount
useEffect(() => {
client
.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]);
// Subscribe to events
useEffect(() => {
client.onMessage((msg) => {
if (msg.action === StreamAction) {
const payload = msg.payload as StreamPayload;
chat.handleStream(payload);
if (payload.event.type === "message_start") setIsLoading(true);
if (payload.event.type === "message_end") setIsLoading(false);
return;
}
if (msg.action === ExecApprovalRequestAction) {
chat.addApproval(msg.payload as ExecApprovalRequestPayload);
return;
}
if (msg.action === "error") {
chat.setError(msg.payload as { code: string; message: string });
return;
}
});
return () => { client.onMessage(() => {}); };
}, [client]);
const sendMessage = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
chat.addUserMessage(trimmed, agentId);
chat.setError(null);
client.send(hubId, "message", { agentId, content: trimmed });
setIsLoading(true);
},
[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);
client.request(hubId, "resolveExecApproval", { approvalId, decision }).catch(() => {});
},
[client, hubId],
);
return {
messages: chat.messages,
streamingIds: chat.streamingIds,
isLoading,
isLoadingHistory,
isLoadingMore,
hasMore: chat.hasMore,
error: chat.error,
pendingApprovals: chat.pendingApprovals,
sendMessage,
loadMore,
resolveApproval,
};
}