From 0e494525a103715f74a7a1cd0e23d7bff1a2a29e Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Thu, 5 Mar 2026 14:14:39 +0100 Subject: [PATCH 01/16] feat: lazy loading for Genie conversations Load only the most recent page of messages when opening a conversation, and fetch older messages on demand when the user scrolls to the top. - Add `history_info` SSE event with pagination token - Add REST endpoint GET /:alias/conversations/:conversationId/messages - Frontend auto-triggers loading via IntersectionObserver with scroll position preservation - Consolidate to single `listConversationMessages` method in connector Signed-off-by: Jorge Calvar --- .../appkit-ui/genie/GenieChatMessageList.mdx | 2 + .../react/genie/genie-chat-message-list.tsx | 86 ++++- .../appkit-ui/src/react/genie/genie-chat.tsx | 17 +- packages/appkit-ui/src/react/genie/types.ts | 6 + .../src/react/genie/use-genie-chat.ts | 80 +++- .../appkit/src/connectors/genie/client.ts | 83 ++-- .../appkit/src/connectors/genie/defaults.ts | 2 + packages/appkit/src/connectors/genie/index.ts | 1 + packages/appkit/src/connectors/genie/types.ts | 1 + packages/appkit/src/plugins/genie/genie.ts | 108 ++++++ .../src/plugins/genie/tests/genie.test.ts | 362 +++++++++++++++--- packages/appkit/src/plugins/genie/types.ts | 1 + packages/shared/src/genie.ts | 19 +- 13 files changed, 680 insertions(+), 88 deletions(-) diff --git a/docs/docs/api/appkit-ui/genie/GenieChatMessageList.mdx b/docs/docs/api/appkit-ui/genie/GenieChatMessageList.mdx index be14d87a..e1420c3e 100644 --- a/docs/docs/api/appkit-ui/genie/GenieChatMessageList.mdx +++ b/docs/docs/api/appkit-ui/genie/GenieChatMessageList.mdx @@ -18,6 +18,8 @@ Scrollable message list that renders Genie chat messages with auto-scroll, skele | `messages` | `GenieMessageItem[]` | ✓ | - | Array of messages to display | | `status` | `enum` | ✓ | - | Current chat status (controls loading indicators and skeleton placeholders) | | `className` | `string` | | - | Additional CSS class for the scroll area | +| `hasOlderMessages` | `boolean` | | `false` | Whether older messages are available to load | +| `onLoadOlder` | `(() => void)` | | - | Callback to load older messages | diff --git a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx index 15993356..b790f9ad 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; import { cn } from "../lib/utils"; import { ScrollArea } from "../ui/scroll-area"; import { Skeleton } from "../ui/skeleton"; @@ -13,6 +13,10 @@ export interface GenieChatMessageListProps { status: GenieChatStatus; /** Additional CSS class for the scroll area */ className?: string; + /** Whether older messages are available to load */ + hasOlderMessages?: boolean; + /** Callback to load older messages */ + onLoadOlder?: () => void; } const STATUS_LABELS: Record = { @@ -39,28 +43,94 @@ function StreamingIndicator({ messages }: { messages: GenieMessageItem[] }) { return null; } +function getViewport(scrollRef: React.RefObject) { + return scrollRef.current?.querySelector( + '[data-slot="scroll-area-viewport"]', + ); +} + /** Scrollable message list that renders Genie chat messages with auto-scroll, skeleton loaders, and a streaming indicator. */ export function GenieChatMessageList({ messages, status, className, + hasOlderMessages = false, + onLoadOlder, }: GenieChatMessageListProps) { const scrollRef = useRef(null); + const prevFirstMessageIdRef = useRef(null); + const prevScrollHeightRef = useRef(0); - // Scroll only the ScrollArea viewport, not the page - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional triggers for auto-scroll - useEffect(() => { - const viewport = scrollRef.current?.querySelector( - '[data-slot="scroll-area-viewport"]', - ); - if (viewport) { + // Handle scroll position after messages change. + // prevScrollHeightRef holds the scrollHeight from the *previous* render's + // effect, so on a prepend we can compute how much content was added above. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional triggers for scroll management + useLayoutEffect(() => { + const viewport = getViewport(scrollRef); + if (!viewport) return; + + const firstMessageId = messages[0]?.id ?? null; + const wasPrepend = + prevFirstMessageIdRef.current !== null && + firstMessageId !== prevFirstMessageIdRef.current; + + if (wasPrepend && prevScrollHeightRef.current > 0) { + // Older messages were prepended — preserve scroll position + const delta = viewport.scrollHeight - prevScrollHeightRef.current; + viewport.scrollTop += delta; + } else { + // New messages appended or initial load — scroll to bottom viewport.scrollTop = viewport.scrollHeight; } + + // Update refs *after* scroll adjustment so they're correct for the next render + prevFirstMessageIdRef.current = firstMessageId; + prevScrollHeightRef.current = viewport.scrollHeight; }, [messages.length, status]); + // Auto-trigger loading older messages when scrolling to the top + const sentinelRef = useRef(null); + const onLoadOlderRef = useRef(onLoadOlder); + onLoadOlderRef.current = onLoadOlder; + + const shouldObserve = hasOlderMessages && status !== "loading-older"; + + useEffect(() => { + const sentinel = sentinelRef.current; + const viewport = getViewport(scrollRef); + if (!sentinel || !viewport || !shouldObserve) return; + + const observer = new IntersectionObserver( + (entries) => { + // Only trigger when the user has actually scrolled near the top, + // not when content is too short to fill the viewport. + const isScrollable = viewport.scrollHeight > viewport.clientHeight; + if (entries[0]?.isIntersecting && isScrollable) { + onLoadOlderRef.current?.(); + } + }, + { root: viewport, threshold: 0 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [shouldObserve]); + return (
+ {/* Sentinel element for auto-triggering load when scrolled to top */} + {hasOlderMessages &&
} + + {status === "loading-older" && ( +
+ + + Loading older messages... + +
+ )} + {status === "loading-history" && messages.length === 0 && (
diff --git a/packages/appkit-ui/src/react/genie/genie-chat.tsx b/packages/appkit-ui/src/react/genie/genie-chat.tsx index d3d2b3d6..8b71c2ae 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat.tsx @@ -12,7 +12,15 @@ export function GenieChat({ placeholder, className, }: GenieChatProps) { - const { messages, status, error, sendMessage, reset } = useGenieChat({ + const { + messages, + status, + error, + sendMessage, + reset, + hasOlderMessages, + loadOlderMessages, + } = useGenieChat({ alias, basePath, }); @@ -32,7 +40,12 @@ export function GenieChat({
)} - + {error && (
diff --git a/packages/appkit-ui/src/react/genie/types.ts b/packages/appkit-ui/src/react/genie/types.ts index 917c1c95..ae14af1b 100644 --- a/packages/appkit-ui/src/react/genie/types.ts +++ b/packages/appkit-ui/src/react/genie/types.ts @@ -2,6 +2,7 @@ import type { GenieAttachmentResponse } from "shared"; export type { GenieAttachmentResponse, + GenieMessagePageResponse, GenieMessageResponse, GenieStreamEvent, } from "shared"; @@ -9,6 +10,7 @@ export type { export type GenieChatStatus = | "idle" | "loading-history" + | "loading-older" | "streaming" | "error"; @@ -40,6 +42,10 @@ export interface UseGenieChatReturn { error: string | null; sendMessage: (content: string) => void; reset: () => void; + /** Whether older messages exist that can be loaded */ + hasOlderMessages: boolean; + /** Fetch the next page of older messages */ + loadOlderMessages: () => void; } export interface GenieChatProps { diff --git a/packages/appkit-ui/src/react/genie/use-genie-chat.ts b/packages/appkit-ui/src/react/genie/use-genie-chat.ts index 3b2e62a6..3320691d 100644 --- a/packages/appkit-ui/src/react/genie/use-genie-chat.ts +++ b/packages/appkit-ui/src/react/genie/use-genie-chat.ts @@ -3,6 +3,7 @@ import { connectSSE } from "@/js"; import type { GenieChatStatus, GenieMessageItem, + GenieMessagePageResponse, GenieMessageResponse, GenieStreamEvent, UseGenieChatOptions, @@ -94,14 +95,23 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { const [status, setStatus] = useState("idle"); const [conversationId, setConversationId] = useState(null); const [error, setError] = useState(null); + const [nextPageToken, setNextPageToken] = useState(null); + + const hasOlderMessages = nextPageToken !== null; const abortControllerRef = useRef(null); const conversationIdRef = useRef(null); + const nextPageTokenRef = useRef(null); + const isLoadingOlderRef = useRef(false); useEffect(() => { conversationIdRef.current = conversationId; }, [conversationId]); + useEffect(() => { + nextPageTokenRef.current = nextPageToken; + }, [nextPageToken]); + const processEvent = useCallback( (event: GenieStreamEvent, isHistory: boolean) => { switch (event.type) { @@ -168,6 +178,11 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { break; } + case "history_info": { + setNextPageToken(event.nextPageToken); + break; + } + case "error": { setError(event.error); setStatus("error"); @@ -291,12 +306,66 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { [alias, basePath, processEvent], ); + const loadOlderMessages = useCallback(() => { + if ( + !nextPageTokenRef.current || + !conversationIdRef.current || + isLoadingOlderRef.current + ) + return; + + isLoadingOlderRef.current = true; + setStatus("loading-older"); + setError(null); + + const token = nextPageTokenRef.current; + + fetch( + `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(conversationIdRef.current)}/messages?pageToken=${encodeURIComponent(token)}`, + ) + .then(async (response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json() as Promise; + }) + .then((data) => { + const olderItems = data.messages.flatMap(messageResultToItems); + + // Distribute query results into the message items + for (const item of olderItems) { + for (const att of item.attachments) { + if (att.attachmentId && att.attachmentId in data.queryResults) { + item.queryResults.set( + att.attachmentId, + data.queryResults[att.attachmentId], + ); + } + } + } + + setMessages((prev) => [...olderItems, ...prev]); + setNextPageToken(data.nextPageToken); + setStatus("idle"); + }) + .catch((err) => { + setError( + err instanceof Error ? err.message : "Failed to load older messages.", + ); + setStatus("idle"); + }) + .finally(() => { + isLoadingOlderRef.current = false; + }); + }, [alias, basePath]); + const reset = useCallback(() => { abortControllerRef.current?.abort(); setMessages([]); setConversationId(null); setError(null); setStatus("idle"); + setNextPageToken(null); if (persistInUrl) { removeUrlParam(urlParamName); } @@ -313,5 +382,14 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { }; }, [persistInUrl, urlParamName, loadHistory]); - return { messages, status, conversationId, error, sendMessage, reset }; + return { + messages, + status, + conversationId, + error, + sendMessage, + reset, + hasOlderMessages, + loadOlderMessages, + }; } diff --git a/packages/appkit/src/connectors/genie/client.ts b/packages/appkit/src/connectors/genie/client.ts index 99636517..85a26995 100644 --- a/packages/appkit/src/connectors/genie/client.ts +++ b/packages/appkit/src/connectors/genie/client.ts @@ -111,28 +111,27 @@ export class GenieConnector { workspaceClient: WorkspaceClient, spaceId: string, conversationId: string, - options?: { maxMessages?: number }, - ): Promise { - const maxMessages = options?.maxMessages ?? this.config.maxMessages; - const allMessages: GenieMessage[] = []; - let pageToken: string | undefined; - - do { - const response = await workspaceClient.genie.listConversationMessages({ - space_id: spaceId, - conversation_id: conversationId, - page_size: genieConnectorDefaults.pageSize, - ...(pageToken ? { page_token: pageToken } : {}), - }); + options?: { pageSize?: number; pageToken?: string }, + ): Promise<{ + messages: GenieMessageResponse[]; + nextPageToken: string | null; + }> { + const pageSize = + options?.pageSize ?? genieConnectorDefaults.initialPageSize; - if (response.messages) { - allMessages.push(...response.messages); - } + const response = await workspaceClient.genie.listConversationMessages({ + space_id: spaceId, + conversation_id: conversationId, + page_size: pageSize, + ...(options?.pageToken ? { page_token: options.pageToken } : {}), + }); - pageToken = response.next_page_token; - } while (pageToken && allMessages.length < maxMessages); + const messages = (response.messages ?? []).reverse().map(toMessageResponse); - return allMessages.slice(0, maxMessages).reverse().map(toMessageResponse); + return { + messages, + nextPageToken: response.next_page_token ?? null, + }; } async getMessageAttachmentQueryResult( @@ -258,21 +257,31 @@ export class GenieConnector { workspaceClient: WorkspaceClient, spaceId: string, conversationId: string, - options?: { includeQueryResults?: boolean }, + options?: { includeQueryResults?: boolean; pageSize?: number }, ): AsyncGenerator { const includeQueryResults = options?.includeQueryResults !== false; try { - const messageResponses = await this.listConversationMessages( - workspaceClient, - spaceId, - conversationId, - ); + const { messages: messageResponses, nextPageToken } = + await this.listConversationMessages( + workspaceClient, + spaceId, + conversationId, + { pageSize: options?.pageSize }, + ); for (const messageResponse of messageResponses) { yield { type: "message_result", message: messageResponse }; } + yield { + type: "history_info", + conversationId, + spaceId, + nextPageToken, + loadedCount: messageResponses.length, + }; + if (includeQueryResults) { const queryAttachments: Array<{ messageId: string; @@ -367,15 +376,27 @@ export class GenieConnector { spaceId: string, conversationId: string, ): Promise { - const messages = await this.listConversationMessages( - workspaceClient, - spaceId, - conversationId, - ); + const allMessages: GenieMessageResponse[] = []; + let pageToken: string | undefined; + + do { + const { messages, nextPageToken } = await this.listConversationMessages( + workspaceClient, + spaceId, + conversationId, + { + pageSize: genieConnectorDefaults.pageSize, + pageToken, + }, + ); + allMessages.push(...messages); + pageToken = nextPageToken ?? undefined; + } while (pageToken && allMessages.length < this.config.maxMessages); + return { conversationId, spaceId, - messages, + messages: allMessages.slice(0, this.config.maxMessages), }; } } diff --git a/packages/appkit/src/connectors/genie/defaults.ts b/packages/appkit/src/connectors/genie/defaults.ts index a86172da..36092473 100644 --- a/packages/appkit/src/connectors/genie/defaults.ts +++ b/packages/appkit/src/connectors/genie/defaults.ts @@ -5,4 +5,6 @@ export const genieConnectorDefaults = { maxMessages: 200, /** Default page size for listConversationMessages. */ pageSize: 100, + /** Default page size for initial conversation load (lazy loading). */ + initialPageSize: 3, } as const; diff --git a/packages/appkit/src/connectors/genie/index.ts b/packages/appkit/src/connectors/genie/index.ts index 2db92d90..682ead28 100644 --- a/packages/appkit/src/connectors/genie/index.ts +++ b/packages/appkit/src/connectors/genie/index.ts @@ -4,6 +4,7 @@ export { type Pollable, type PollEvent, pollWaiter } from "./poll-waiter"; export type { GenieAttachmentResponse, GenieConversationHistoryResponse, + GenieMessagePageResponse, GenieMessageResponse, GenieStreamEvent, } from "./types"; diff --git a/packages/appkit/src/connectors/genie/types.ts b/packages/appkit/src/connectors/genie/types.ts index d8fe85aa..029a182f 100644 --- a/packages/appkit/src/connectors/genie/types.ts +++ b/packages/appkit/src/connectors/genie/types.ts @@ -4,6 +4,7 @@ import type { GenieMessageResponse } from "shared"; export type { GenieAttachmentResponse, + GenieMessagePageResponse, GenieMessageResponse, GenieStreamEvent, } from "shared"; diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts index 1656a8ea..29676243 100644 --- a/packages/appkit/src/plugins/genie/genie.ts +++ b/packages/appkit/src/plugins/genie/genie.ts @@ -9,6 +9,7 @@ import { genieStreamDefaults } from "./defaults"; import { genieManifest } from "./manifest"; import type { GenieConversationHistoryResponse, + GenieMessagePageResponse, GenieSendMessageRequest, GenieStreamEvent, IGenieConfig, @@ -66,6 +67,15 @@ export class GeniePlugin extends Plugin { await this.asUser(req)._handleGetConversation(req, res); }, }); + + this.route(router, { + name: "getConversationMessages", + method: "get", + path: "/:alias/conversations/:conversationId/messages", + handler: async (req: express.Request, res: express.Response) => { + await this.asUser(req)._handleGetConversationMessages(req, res); + }, + }); } async _handleSendMessage( @@ -171,6 +181,104 @@ export class GeniePlugin extends Plugin { ); } + async _handleGetConversationMessages( + req: express.Request, + res: express.Response, + ): Promise { + const { alias, conversationId } = req.params; + const spaceId = this.resolveSpaceId(alias); + + if (!spaceId) { + res.status(404).json({ error: `Unknown space alias: ${alias}` }); + return; + } + + const pageToken = req.query.pageToken as string | undefined; + const rawPageSize = req.query.pageSize + ? Number.parseInt(req.query.pageSize as string, 10) + : undefined; + const pageSize = + rawPageSize && Number.isFinite(rawPageSize) && rawPageSize > 0 + ? Math.min(rawPageSize, 100) + : undefined; + const includeQueryResults = req.query.includeQueryResults !== "false"; + + logger.debug( + "Fetching message page for conversation %s (alias=%s, pageToken=%s)", + conversationId, + alias, + pageToken ?? "none", + ); + + const workspaceClient = getWorkspaceClient(); + + try { + const { messages, nextPageToken } = + await this.genieConnector.listConversationMessages( + workspaceClient, + spaceId, + conversationId, + { pageSize, pageToken }, + ); + + const queryResults: Record = {}; + + if (includeQueryResults) { + const queryAttachments: Array<{ + messageId: string; + attachmentId: string; + }> = []; + + for (const msg of messages) { + for (const att of msg.attachments ?? []) { + if (att.query?.statementId && att.attachmentId) { + queryAttachments.push({ + messageId: msg.messageId, + attachmentId: att.attachmentId, + }); + } + } + } + + const results = await Promise.allSettled( + queryAttachments.map(async (att) => { + const data = + await this.genieConnector.getMessageAttachmentQueryResult( + workspaceClient, + spaceId, + conversationId, + att.messageId, + att.attachmentId, + ); + return { attachmentId: att.attachmentId, data }; + }), + ); + + for (const result of results) { + if (result.status === "fulfilled") { + queryResults[result.value.attachmentId] = result.value.data; + } else { + logger.error("Failed to fetch query result: %O", result.reason); + } + } + } + + const response: GenieMessagePageResponse = { + messages, + queryResults, + nextPageToken, + }; + + res.json(response); + } catch (error) { + logger.error("Failed to fetch message page: %O", error); + res.status(500).json({ + error: + error instanceof Error ? error.message : "Failed to fetch messages", + }); + } + } + async getConversation( alias: string, conversationId: string, diff --git a/packages/appkit/src/plugins/genie/tests/genie.test.ts b/packages/appkit/src/plugins/genie/tests/genie.test.ts index 58acfcfe..5a2cc3de 100644 --- a/packages/appkit/src/plugins/genie/tests/genie.test.ts +++ b/packages/appkit/src/plugins/genie/tests/genie.test.ts @@ -174,11 +174,15 @@ describe("Genie Plugin", () => { expect.any(Function), ); - expect(router.get).toHaveBeenCalledTimes(1); + expect(router.get).toHaveBeenCalledTimes(2); expect(router.get).toHaveBeenCalledWith( "/:alias/conversations/:conversationId", expect.any(Function), ); + expect(router.get).toHaveBeenCalledWith( + "/:alias/conversations/:conversationId/messages", + expect.any(Function), + ); }); }); @@ -584,7 +588,7 @@ describe("Genie Plugin", () => { expect.objectContaining({ space_id: "test-space-id", conversation_id: "conv-123", - page_size: 100, + page_size: 3, }), ); @@ -597,6 +601,9 @@ describe("Genie Plugin", () => { ).length; expect(messageResultCount).toBe(2); + // Should have history_info event + expect(allWritten).toContain("history_info"); + // Should contain message content expect(allWritten).toContain("What are the top customers?"); expect(allWritten).toContain("Here are the results"); @@ -707,34 +714,20 @@ describe("Genie Plugin", () => { expect(mockRes.end).toHaveBeenCalled(); }); - test("should paginate through all messages", async () => { - mockGenieService.listConversationMessages - .mockResolvedValueOnce({ - messages: [ - { - message_id: "msg-1", - conversation_id: "conv-123", - space_id: "test-space-id", - content: "Page 1 message", - status: "COMPLETED", - attachments: [], - }, - ], - next_page_token: "page-2-token", - }) - .mockResolvedValueOnce({ - messages: [ - { - message_id: "msg-2", - conversation_id: "conv-123", - space_id: "test-space-id", - content: "Page 2 message", - status: "COMPLETED", - attachments: [], - }, - ], - next_page_token: undefined, - }); + test("should fetch only one page and emit history_info with nextPageToken", async () => { + mockGenieService.listConversationMessages.mockResolvedValueOnce({ + messages: [ + { + message_id: "msg-1", + conversation_id: "conv-123", + space_id: "test-space-id", + content: "Most recent message", + status: "COMPLETED", + attachments: [], + }, + ], + next_page_token: "page-2-token", + }); const plugin = new GeniePlugin(config); const { router, getHandler } = createMockRouter(); @@ -752,36 +745,63 @@ describe("Genie Plugin", () => { await handler(mockReq, mockRes); + // Should only fetch one page (lazy loading) expect(mockGenieService.listConversationMessages).toHaveBeenCalledTimes( - 2, + 1, ); - // First call without page_token - expect(mockGenieService.listConversationMessages).toHaveBeenNthCalledWith( - 1, + expect(mockGenieService.listConversationMessages).toHaveBeenCalledWith( expect.objectContaining({ space_id: "test-space-id", conversation_id: "conv-123", - page_size: 100, + page_size: 3, }), ); - // Second call with page_token - expect(mockGenieService.listConversationMessages).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - space_id: "test-space-id", + const writeCalls = mockRes.write.mock.calls.map((call: any[]) => call[0]); + const allWritten = writeCalls.join(""); + + expect(allWritten).toContain("Most recent message"); + // history_info should contain the nextPageToken + expect(allWritten).toContain("history_info"); + expect(allWritten).toContain("page-2-token"); + expect(mockRes.end).toHaveBeenCalled(); + }); + + test("should emit history_info with null nextPageToken when no more pages", async () => { + mockMessages([ + { + message_id: "msg-1", conversation_id: "conv-123", - page_size: 100, - page_token: "page-2-token", - }), + space_id: "test-space-id", + content: "Only message", + status: "COMPLETED", + attachments: [], + }, + ]); + + const plugin = new GeniePlugin(config); + const { router, getHandler } = createMockRouter(); + + plugin.injectRoutes(router); + + const handler = getHandler( + "GET", + "/:alias/conversations/:conversationId", ); + const mockReq = createConversationRequest({ + query: { includeQueryResults: "false" }, + }); + const mockRes = createMockResponse(); + + await handler(mockReq, mockRes); const writeCalls = mockRes.write.mock.calls.map((call: any[]) => call[0]); const allWritten = writeCalls.join(""); - expect(allWritten).toContain("Page 1 message"); - expect(allWritten).toContain("Page 2 message"); + expect(allWritten).toContain("history_info"); + // nextPageToken should be null + expect(allWritten).toContain('"nextPageToken":null'); expect(mockRes.end).toHaveBeenCalled(); }); @@ -982,6 +1002,258 @@ describe("Genie Plugin", () => { }); }); + describe("getConversationMessages (REST pagination)", () => { + function createMessagesRequest(overrides: Record = {}) { + return createMockRequest({ + params: { alias: "myspace", conversationId: "conv-123" }, + query: { pageToken: "some-page-token" }, + headers: { + "x-forwarded-access-token": "user-token", + "x-forwarded-user": "user-1", + }, + ...overrides, + }); + } + + test("should return 404 for unknown alias", async () => { + const plugin = new GeniePlugin(config); + const { router, getHandler } = createMockRouter(); + + plugin.injectRoutes(router); + + const handler = getHandler( + "GET", + "/:alias/conversations/:conversationId/messages", + ); + const mockReq = createMessagesRequest({ + params: { alias: "unknown", conversationId: "conv-123" }, + }); + const mockRes = createMockResponse(); + + await handler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockRes.json).toHaveBeenCalledWith({ + error: "Unknown space alias: unknown", + }); + }); + + test("should return paginated messages with nextPageToken", async () => { + mockGenieService.listConversationMessages.mockResolvedValueOnce({ + messages: [ + { + message_id: "msg-old-1", + conversation_id: "conv-123", + space_id: "test-space-id", + content: "Older message", + status: "COMPLETED", + attachments: [], + }, + ], + next_page_token: "next-token-abc", + }); + + const plugin = new GeniePlugin(config); + const { router, getHandler } = createMockRouter(); + + plugin.injectRoutes(router); + + const handler = getHandler( + "GET", + "/:alias/conversations/:conversationId/messages", + ); + const mockReq = createMessagesRequest(); + const mockRes = createMockResponse(); + + await handler(mockReq, mockRes); + + expect(mockGenieService.listConversationMessages).toHaveBeenCalledWith( + expect.objectContaining({ + space_id: "test-space-id", + conversation_id: "conv-123", + page_size: 3, + page_token: "some-page-token", + }), + ); + + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + messageId: "msg-old-1", + content: "Older message", + }), + ]), + nextPageToken: "next-token-abc", + queryResults: {}, + }), + ); + }); + + test("should include query results when includeQueryResults is not false", async () => { + mockGenieService.listConversationMessages.mockResolvedValueOnce({ + messages: [ + { + message_id: "msg-1", + conversation_id: "conv-123", + space_id: "test-space-id", + content: "Query message", + status: "COMPLETED", + attachments: [ + { + attachment_id: "att-1", + query: { + title: "Test Query", + query: "SELECT 1", + statement_id: "stmt-1", + }, + }, + ], + }, + ], + next_page_token: undefined, + }); + + const plugin = new GeniePlugin(config); + const { router, getHandler } = createMockRouter(); + + plugin.injectRoutes(router); + + const handler = getHandler( + "GET", + "/:alias/conversations/:conversationId/messages", + ); + const mockReq = createMessagesRequest(); + const mockRes = createMockResponse(); + + await handler(mockReq, mockRes); + + expect( + mockGenieService.getMessageAttachmentQueryResult, + ).toHaveBeenCalledWith( + expect.objectContaining({ + message_id: "msg-1", + attachment_id: "att-1", + }), + ); + + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + queryResults: expect.objectContaining({ + "att-1": expect.any(Object), + }), + nextPageToken: null, + }), + ); + }); + + test("should skip query results when includeQueryResults is false", async () => { + mockGenieService.listConversationMessages.mockResolvedValueOnce({ + messages: [ + { + message_id: "msg-1", + conversation_id: "conv-123", + space_id: "test-space-id", + content: "Query message", + status: "COMPLETED", + attachments: [ + { + attachment_id: "att-1", + query: { + title: "Test Query", + query: "SELECT 1", + statement_id: "stmt-1", + }, + }, + ], + }, + ], + next_page_token: undefined, + }); + + const plugin = new GeniePlugin(config); + const { router, getHandler } = createMockRouter(); + + plugin.injectRoutes(router); + + const handler = getHandler( + "GET", + "/:alias/conversations/:conversationId/messages", + ); + const mockReq = createMessagesRequest({ + query: { pageToken: "some-token", includeQueryResults: "false" }, + }); + const mockRes = createMockResponse(); + + await handler(mockReq, mockRes); + + expect( + mockGenieService.getMessageAttachmentQueryResult, + ).not.toHaveBeenCalled(); + + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + queryResults: {}, + }), + ); + }); + + test("should return 500 on SDK failure", async () => { + mockGenieService.listConversationMessages.mockRejectedValue( + new Error("API error"), + ); + + const plugin = new GeniePlugin(config); + const { router, getHandler } = createMockRouter(); + + plugin.injectRoutes(router); + + const handler = getHandler( + "GET", + "/:alias/conversations/:conversationId/messages", + ); + const mockReq = createMessagesRequest(); + const mockRes = createMockResponse(); + + await handler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ + error: "API error", + }); + }); + + test("should respect custom pageSize query param", async () => { + mockGenieService.listConversationMessages.mockResolvedValueOnce({ + messages: [], + next_page_token: undefined, + }); + + const plugin = new GeniePlugin(config); + const { router, getHandler } = createMockRouter(); + + plugin.injectRoutes(router); + + const handler = getHandler( + "GET", + "/:alias/conversations/:conversationId/messages", + ); + const mockReq = createMessagesRequest({ + query: { pageToken: "token", pageSize: "50" }, + }); + const mockRes = createMockResponse(); + + await handler(mockReq, mockRes); + + expect(mockGenieService.listConversationMessages).toHaveBeenCalledWith( + expect.objectContaining({ + page_size: 50, + page_token: "token", + }), + ); + }); + }); + describe("SSE reconnection streamId", () => { let executeStreamSpy: ReturnType; diff --git a/packages/appkit/src/plugins/genie/types.ts b/packages/appkit/src/plugins/genie/types.ts index 70117ec1..0b4418ab 100644 --- a/packages/appkit/src/plugins/genie/types.ts +++ b/packages/appkit/src/plugins/genie/types.ts @@ -3,6 +3,7 @@ import type { BasePluginConfig } from "shared"; // Re-export connector types for backward compatibility export type { GenieAttachmentResponse, + GenieMessagePageResponse, GenieMessageResponse, GenieStreamEvent, } from "shared"; diff --git a/packages/shared/src/genie.ts b/packages/shared/src/genie.ts index b8f3970f..ab3cfe04 100644 --- a/packages/shared/src/genie.ts +++ b/packages/shared/src/genie.ts @@ -14,7 +14,16 @@ export type GenieStreamEvent = statementId: string; data: unknown; } - | { type: "error"; error: string }; + | { type: "error"; error: string } + | { + type: "history_info"; + conversationId: string; + spaceId: string; + /** Opaque token to fetch the next (older) page. Null means no more pages. */ + nextPageToken: string | null; + /** Total messages returned in this initial load */ + loadedCount: number; + }; /** Cleaned response — subset of SDK GenieMessage */ export interface GenieMessageResponse { @@ -27,6 +36,14 @@ export interface GenieMessageResponse { error?: string; } +/** Response for paginated message fetching (REST endpoint) */ +export interface GenieMessagePageResponse { + messages: GenieMessageResponse[]; + /** attachmentId → query result data */ + queryResults: Record; + nextPageToken: string | null; +} + export interface GenieAttachmentResponse { attachmentId?: string; query?: { From 3cd7b9babdb90f3365516e8edf1b5d1758d264de Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Thu, 5 Mar 2026 14:36:28 +0100 Subject: [PATCH 02/16] feat: lazy loading for Genie conversations Load only the most recent page of messages when opening a conversation, and fetch older messages on demand when the user scrolls to the top. - Add `history_info` SSE event with pagination token to existing getConversation endpoint (no new endpoints) - Frontend auto-triggers loading via IntersectionObserver with scroll position preservation using the same SSE endpoint with pageToken - Consolidate to single `listConversationMessages` method in connector Signed-off-by: Jorge Calvar --- packages/appkit-ui/src/react/genie/types.ts | 1 - .../src/react/genie/use-genie-chat.ts | 73 +++--- .../appkit/src/connectors/genie/client.ts | 8 +- .../appkit/src/connectors/genie/defaults.ts | 2 +- packages/appkit/src/connectors/genie/index.ts | 1 - packages/appkit/src/connectors/genie/types.ts | 1 - packages/appkit/src/plugins/genie/genie.ts | 114 +-------- .../src/plugins/genie/tests/genie.test.ts | 241 ++---------------- packages/appkit/src/plugins/genie/types.ts | 1 - packages/shared/src/genie.ts | 8 - 10 files changed, 75 insertions(+), 375 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/types.ts b/packages/appkit-ui/src/react/genie/types.ts index ae14af1b..f2c2681f 100644 --- a/packages/appkit-ui/src/react/genie/types.ts +++ b/packages/appkit-ui/src/react/genie/types.ts @@ -2,7 +2,6 @@ import type { GenieAttachmentResponse } from "shared"; export type { GenieAttachmentResponse, - GenieMessagePageResponse, GenieMessageResponse, GenieStreamEvent, } from "shared"; diff --git a/packages/appkit-ui/src/react/genie/use-genie-chat.ts b/packages/appkit-ui/src/react/genie/use-genie-chat.ts index 3320691d..84e4c921 100644 --- a/packages/appkit-ui/src/react/genie/use-genie-chat.ts +++ b/packages/appkit-ui/src/react/genie/use-genie-chat.ts @@ -3,7 +3,6 @@ import { connectSSE } from "@/js"; import type { GenieChatStatus, GenieMessageItem, - GenieMessagePageResponse, GenieMessageResponse, GenieStreamEvent, UseGenieChatOptions, @@ -319,39 +318,55 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { setError(null); const token = nextPageTokenRef.current; - - fetch( - `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(conversationIdRef.current)}/messages?pageToken=${encodeURIComponent(token)}`, - ) - .then(async (response) => { - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - return response.json() as Promise; - }) - .then((data) => { - const olderItems = data.messages.flatMap(messageResultToItems); - - // Distribute query results into the message items - for (const item of olderItems) { - for (const att of item.attachments) { - if (att.attachmentId && att.attachmentId in data.queryResults) { - item.queryResults.set( - att.attachmentId, - data.queryResults[att.attachmentId], - ); - } + const convId = conversationIdRef.current; + const requestId = crypto.randomUUID(); + + // Collect older messages from SSE, then prepend them all at once + const olderItems: GenieMessageItem[] = []; + + connectSSE({ + url: `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}?pageToken=${encodeURIComponent(token)}&requestId=${encodeURIComponent(requestId)}`, + onMessage: async (message) => { + try { + const event = JSON.parse(message.data) as GenieStreamEvent; + switch (event.type) { + case "message_result": + olderItems.push(...messageResultToItems(event.message)); + break; + case "query_result": + for (let i = olderItems.length - 1; i >= 0; i--) { + const item = olderItems[i]; + if ( + item.attachments.some( + (a) => a.attachmentId === event.attachmentId, + ) + ) { + item.queryResults.set(event.attachmentId, event.data); + break; + } + } + break; + case "history_info": + setNextPageToken(event.nextPageToken); + break; + case "error": + setError(event.error); + break; } + } catch { + // Malformed SSE data } - - setMessages((prev) => [...olderItems, ...prev]); - setNextPageToken(data.nextPageToken); - setStatus("idle"); - }) - .catch((err) => { + }, + onError: (err) => { setError( err instanceof Error ? err.message : "Failed to load older messages.", ); + }, + }) + .then(() => { + if (olderItems.length > 0) { + setMessages((prev) => [...olderItems, ...prev]); + } setStatus("idle"); }) .finally(() => { diff --git a/packages/appkit/src/connectors/genie/client.ts b/packages/appkit/src/connectors/genie/client.ts index 85a26995..3028791d 100644 --- a/packages/appkit/src/connectors/genie/client.ts +++ b/packages/appkit/src/connectors/genie/client.ts @@ -257,7 +257,11 @@ export class GenieConnector { workspaceClient: WorkspaceClient, spaceId: string, conversationId: string, - options?: { includeQueryResults?: boolean; pageSize?: number }, + options?: { + includeQueryResults?: boolean; + pageSize?: number; + pageToken?: string; + }, ): AsyncGenerator { const includeQueryResults = options?.includeQueryResults !== false; @@ -267,7 +271,7 @@ export class GenieConnector { workspaceClient, spaceId, conversationId, - { pageSize: options?.pageSize }, + { pageSize: options?.pageSize, pageToken: options?.pageToken }, ); for (const messageResponse of messageResponses) { diff --git a/packages/appkit/src/connectors/genie/defaults.ts b/packages/appkit/src/connectors/genie/defaults.ts index 36092473..c53c0479 100644 --- a/packages/appkit/src/connectors/genie/defaults.ts +++ b/packages/appkit/src/connectors/genie/defaults.ts @@ -6,5 +6,5 @@ export const genieConnectorDefaults = { /** Default page size for listConversationMessages. */ pageSize: 100, /** Default page size for initial conversation load (lazy loading). */ - initialPageSize: 3, + initialPageSize: 20, } as const; diff --git a/packages/appkit/src/connectors/genie/index.ts b/packages/appkit/src/connectors/genie/index.ts index 682ead28..2db92d90 100644 --- a/packages/appkit/src/connectors/genie/index.ts +++ b/packages/appkit/src/connectors/genie/index.ts @@ -4,7 +4,6 @@ export { type Pollable, type PollEvent, pollWaiter } from "./poll-waiter"; export type { GenieAttachmentResponse, GenieConversationHistoryResponse, - GenieMessagePageResponse, GenieMessageResponse, GenieStreamEvent, } from "./types"; diff --git a/packages/appkit/src/connectors/genie/types.ts b/packages/appkit/src/connectors/genie/types.ts index 029a182f..d8fe85aa 100644 --- a/packages/appkit/src/connectors/genie/types.ts +++ b/packages/appkit/src/connectors/genie/types.ts @@ -4,7 +4,6 @@ import type { GenieMessageResponse } from "shared"; export type { GenieAttachmentResponse, - GenieMessagePageResponse, GenieMessageResponse, GenieStreamEvent, } from "shared"; diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts index 29676243..1a61d0d1 100644 --- a/packages/appkit/src/plugins/genie/genie.ts +++ b/packages/appkit/src/plugins/genie/genie.ts @@ -9,7 +9,6 @@ import { genieStreamDefaults } from "./defaults"; import { genieManifest } from "./manifest"; import type { GenieConversationHistoryResponse, - GenieMessagePageResponse, GenieSendMessageRequest, GenieStreamEvent, IGenieConfig, @@ -67,15 +66,6 @@ export class GeniePlugin extends Plugin { await this.asUser(req)._handleGetConversation(req, res); }, }); - - this.route(router, { - name: "getConversationMessages", - method: "get", - path: "/:alias/conversations/:conversationId/messages", - handler: async (req: express.Request, res: express.Response) => { - await this.asUser(req)._handleGetConversationMessages(req, res); - }, - }); } async _handleSendMessage( @@ -148,14 +138,16 @@ export class GeniePlugin extends Plugin { } const includeQueryResults = req.query.includeQueryResults !== "false"; + const pageToken = req.query.pageToken as string | undefined; const requestId = (req.query.requestId as string) || randomUUID(); logger.debug( - "Fetching conversation %s from space %s (alias=%s, includeQueryResults=%s)", + "Fetching conversation %s from space %s (alias=%s, includeQueryResults=%s, pageToken=%s)", conversationId, spaceId, alias, includeQueryResults, + pageToken ?? "none", ); const streamSettings: StreamExecutionSettings = { @@ -175,110 +167,12 @@ export class GeniePlugin extends Plugin { workspaceClient, spaceId, conversationId, - { includeQueryResults }, + { includeQueryResults, pageToken }, ), streamSettings, ); } - async _handleGetConversationMessages( - req: express.Request, - res: express.Response, - ): Promise { - const { alias, conversationId } = req.params; - const spaceId = this.resolveSpaceId(alias); - - if (!spaceId) { - res.status(404).json({ error: `Unknown space alias: ${alias}` }); - return; - } - - const pageToken = req.query.pageToken as string | undefined; - const rawPageSize = req.query.pageSize - ? Number.parseInt(req.query.pageSize as string, 10) - : undefined; - const pageSize = - rawPageSize && Number.isFinite(rawPageSize) && rawPageSize > 0 - ? Math.min(rawPageSize, 100) - : undefined; - const includeQueryResults = req.query.includeQueryResults !== "false"; - - logger.debug( - "Fetching message page for conversation %s (alias=%s, pageToken=%s)", - conversationId, - alias, - pageToken ?? "none", - ); - - const workspaceClient = getWorkspaceClient(); - - try { - const { messages, nextPageToken } = - await this.genieConnector.listConversationMessages( - workspaceClient, - spaceId, - conversationId, - { pageSize, pageToken }, - ); - - const queryResults: Record = {}; - - if (includeQueryResults) { - const queryAttachments: Array<{ - messageId: string; - attachmentId: string; - }> = []; - - for (const msg of messages) { - for (const att of msg.attachments ?? []) { - if (att.query?.statementId && att.attachmentId) { - queryAttachments.push({ - messageId: msg.messageId, - attachmentId: att.attachmentId, - }); - } - } - } - - const results = await Promise.allSettled( - queryAttachments.map(async (att) => { - const data = - await this.genieConnector.getMessageAttachmentQueryResult( - workspaceClient, - spaceId, - conversationId, - att.messageId, - att.attachmentId, - ); - return { attachmentId: att.attachmentId, data }; - }), - ); - - for (const result of results) { - if (result.status === "fulfilled") { - queryResults[result.value.attachmentId] = result.value.data; - } else { - logger.error("Failed to fetch query result: %O", result.reason); - } - } - } - - const response: GenieMessagePageResponse = { - messages, - queryResults, - nextPageToken, - }; - - res.json(response); - } catch (error) { - logger.error("Failed to fetch message page: %O", error); - res.status(500).json({ - error: - error instanceof Error ? error.message : "Failed to fetch messages", - }); - } - } - async getConversation( alias: string, conversationId: string, diff --git a/packages/appkit/src/plugins/genie/tests/genie.test.ts b/packages/appkit/src/plugins/genie/tests/genie.test.ts index 5a2cc3de..320ceba4 100644 --- a/packages/appkit/src/plugins/genie/tests/genie.test.ts +++ b/packages/appkit/src/plugins/genie/tests/genie.test.ts @@ -174,15 +174,11 @@ describe("Genie Plugin", () => { expect.any(Function), ); - expect(router.get).toHaveBeenCalledTimes(2); + expect(router.get).toHaveBeenCalledTimes(1); expect(router.get).toHaveBeenCalledWith( "/:alias/conversations/:conversationId", expect.any(Function), ); - expect(router.get).toHaveBeenCalledWith( - "/:alias/conversations/:conversationId/messages", - expect.any(Function), - ); }); }); @@ -588,7 +584,7 @@ describe("Genie Plugin", () => { expect.objectContaining({ space_id: "test-space-id", conversation_id: "conv-123", - page_size: 3, + page_size: 20, }), ); @@ -754,7 +750,7 @@ describe("Genie Plugin", () => { expect.objectContaining({ space_id: "test-space-id", conversation_id: "conv-123", - page_size: 3, + page_size: 20, }), ); @@ -1002,43 +998,8 @@ describe("Genie Plugin", () => { }); }); - describe("getConversationMessages (REST pagination)", () => { - function createMessagesRequest(overrides: Record = {}) { - return createMockRequest({ - params: { alias: "myspace", conversationId: "conv-123" }, - query: { pageToken: "some-page-token" }, - headers: { - "x-forwarded-access-token": "user-token", - "x-forwarded-user": "user-1", - }, - ...overrides, - }); - } - - test("should return 404 for unknown alias", async () => { - const plugin = new GeniePlugin(config); - const { router, getHandler } = createMockRouter(); - - plugin.injectRoutes(router); - - const handler = getHandler( - "GET", - "/:alias/conversations/:conversationId/messages", - ); - const mockReq = createMessagesRequest({ - params: { alias: "unknown", conversationId: "conv-123" }, - }); - const mockRes = createMockResponse(); - - await handler(mockReq, mockRes); - - expect(mockRes.status).toHaveBeenCalledWith(404); - expect(mockRes.json).toHaveBeenCalledWith({ - error: "Unknown space alias: unknown", - }); - }); - - test("should return paginated messages with nextPageToken", async () => { + describe("getConversation with pageToken", () => { + test("should pass pageToken through to streamConversation", async () => { mockGenieService.listConversationMessages.mockResolvedValueOnce({ messages: [ { @@ -1060,9 +1021,16 @@ describe("Genie Plugin", () => { const handler = getHandler( "GET", - "/:alias/conversations/:conversationId/messages", + "/:alias/conversations/:conversationId", ); - const mockReq = createMessagesRequest(); + const mockReq = createMockRequest({ + params: { alias: "myspace", conversationId: "conv-123" }, + query: { pageToken: "some-page-token" }, + headers: { + "x-forwarded-access-token": "user-token", + "x-forwarded-user": "user-1", + }, + }); const mockRes = createMockResponse(); await handler(mockReq, mockRes); @@ -1071,186 +1039,17 @@ describe("Genie Plugin", () => { expect.objectContaining({ space_id: "test-space-id", conversation_id: "conv-123", - page_size: 3, page_token: "some-page-token", }), ); - expect(mockRes.json).toHaveBeenCalledWith( - expect.objectContaining({ - messages: expect.arrayContaining([ - expect.objectContaining({ - messageId: "msg-old-1", - content: "Older message", - }), - ]), - nextPageToken: "next-token-abc", - queryResults: {}, - }), - ); - }); - - test("should include query results when includeQueryResults is not false", async () => { - mockGenieService.listConversationMessages.mockResolvedValueOnce({ - messages: [ - { - message_id: "msg-1", - conversation_id: "conv-123", - space_id: "test-space-id", - content: "Query message", - status: "COMPLETED", - attachments: [ - { - attachment_id: "att-1", - query: { - title: "Test Query", - query: "SELECT 1", - statement_id: "stmt-1", - }, - }, - ], - }, - ], - next_page_token: undefined, - }); - - const plugin = new GeniePlugin(config); - const { router, getHandler } = createMockRouter(); - - plugin.injectRoutes(router); - - const handler = getHandler( - "GET", - "/:alias/conversations/:conversationId/messages", - ); - const mockReq = createMessagesRequest(); - const mockRes = createMockResponse(); - - await handler(mockReq, mockRes); - - expect( - mockGenieService.getMessageAttachmentQueryResult, - ).toHaveBeenCalledWith( - expect.objectContaining({ - message_id: "msg-1", - attachment_id: "att-1", - }), - ); - - expect(mockRes.json).toHaveBeenCalledWith( - expect.objectContaining({ - queryResults: expect.objectContaining({ - "att-1": expect.any(Object), - }), - nextPageToken: null, - }), - ); - }); - - test("should skip query results when includeQueryResults is false", async () => { - mockGenieService.listConversationMessages.mockResolvedValueOnce({ - messages: [ - { - message_id: "msg-1", - conversation_id: "conv-123", - space_id: "test-space-id", - content: "Query message", - status: "COMPLETED", - attachments: [ - { - attachment_id: "att-1", - query: { - title: "Test Query", - query: "SELECT 1", - statement_id: "stmt-1", - }, - }, - ], - }, - ], - next_page_token: undefined, - }); - - const plugin = new GeniePlugin(config); - const { router, getHandler } = createMockRouter(); - - plugin.injectRoutes(router); - - const handler = getHandler( - "GET", - "/:alias/conversations/:conversationId/messages", - ); - const mockReq = createMessagesRequest({ - query: { pageToken: "some-token", includeQueryResults: "false" }, - }); - const mockRes = createMockResponse(); - - await handler(mockReq, mockRes); - - expect( - mockGenieService.getMessageAttachmentQueryResult, - ).not.toHaveBeenCalled(); - - expect(mockRes.json).toHaveBeenCalledWith( - expect.objectContaining({ - queryResults: {}, - }), - ); - }); - - test("should return 500 on SDK failure", async () => { - mockGenieService.listConversationMessages.mockRejectedValue( - new Error("API error"), - ); - - const plugin = new GeniePlugin(config); - const { router, getHandler } = createMockRouter(); - - plugin.injectRoutes(router); - - const handler = getHandler( - "GET", - "/:alias/conversations/:conversationId/messages", - ); - const mockReq = createMessagesRequest(); - const mockRes = createMockResponse(); - - await handler(mockReq, mockRes); - - expect(mockRes.status).toHaveBeenCalledWith(500); - expect(mockRes.json).toHaveBeenCalledWith({ - error: "API error", - }); - }); - - test("should respect custom pageSize query param", async () => { - mockGenieService.listConversationMessages.mockResolvedValueOnce({ - messages: [], - next_page_token: undefined, - }); - - const plugin = new GeniePlugin(config); - const { router, getHandler } = createMockRouter(); - - plugin.injectRoutes(router); - - const handler = getHandler( - "GET", - "/:alias/conversations/:conversationId/messages", - ); - const mockReq = createMessagesRequest({ - query: { pageToken: "token", pageSize: "50" }, - }); - const mockRes = createMockResponse(); - - await handler(mockReq, mockRes); + const writeCalls = mockRes.write.mock.calls.map((call: any[]) => call[0]); + const allWritten = writeCalls.join(""); - expect(mockGenieService.listConversationMessages).toHaveBeenCalledWith( - expect.objectContaining({ - page_size: 50, - page_token: "token", - }), - ); + expect(allWritten).toContain("Older message"); + expect(allWritten).toContain("history_info"); + expect(allWritten).toContain("next-token-abc"); + expect(mockRes.end).toHaveBeenCalled(); }); }); diff --git a/packages/appkit/src/plugins/genie/types.ts b/packages/appkit/src/plugins/genie/types.ts index 0b4418ab..70117ec1 100644 --- a/packages/appkit/src/plugins/genie/types.ts +++ b/packages/appkit/src/plugins/genie/types.ts @@ -3,7 +3,6 @@ import type { BasePluginConfig } from "shared"; // Re-export connector types for backward compatibility export type { GenieAttachmentResponse, - GenieMessagePageResponse, GenieMessageResponse, GenieStreamEvent, } from "shared"; diff --git a/packages/shared/src/genie.ts b/packages/shared/src/genie.ts index ab3cfe04..b8021ab1 100644 --- a/packages/shared/src/genie.ts +++ b/packages/shared/src/genie.ts @@ -36,14 +36,6 @@ export interface GenieMessageResponse { error?: string; } -/** Response for paginated message fetching (REST endpoint) */ -export interface GenieMessagePageResponse { - messages: GenieMessageResponse[]; - /** attachmentId → query result data */ - queryResults: Record; - nextPageToken: string | null; -} - export interface GenieAttachmentResponse { attachmentId?: string; query?: { From be7272b5a001bf1b560c65831afe914e9775f38a Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Fri, 6 Mar 2026 12:00:57 +0100 Subject: [PATCH 03/16] refactor: extract fetchConversationPage and fix review issues - Extract shared fetchConversationPage for loadHistory and loadOlderMessages - Rename processEvent to processStreamEvent (only used for sendMessage) - Pass stable React setters directly instead of recreating paginationCallbacks - Add abort signal to loadOlderMessages to prevent leaks on unmount Signed-off-by: Jorge Calvar --- .../src/react/genie/use-genie-chat.ts | 161 ++++++++++-------- .../src/plugins/genie/tests/genie.test.ts | 4 +- 2 files changed, 92 insertions(+), 73 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/use-genie-chat.ts b/packages/appkit-ui/src/react/genie/use-genie-chat.ts index 84e4c921..9e2a1dde 100644 --- a/packages/appkit-ui/src/react/genie/use-genie-chat.ts +++ b/packages/appkit-ui/src/react/genie/use-genie-chat.ts @@ -73,6 +73,69 @@ function messageResultToItems(msg: GenieMessageResponse): GenieMessageItem[] { return [makeUserItem(msg, "-user"), makeAssistantItem(msg)]; } +/** + * Streams a conversation page via SSE. Collects message items and query + * results into a buffer and returns them when the stream completes. + */ +function fetchConversationPage( + basePath: string, + alias: string, + convId: string, + options: { + pageToken?: string; + signal?: AbortSignal; + onPaginationInfo?: (nextPageToken: string | null) => void; + onError?: (error: string) => void; + onConnectionError?: (err: unknown) => void; + }, +): Promise { + const params = new URLSearchParams({ + requestId: crypto.randomUUID(), + }); + if (options.pageToken) { + params.set("pageToken", options.pageToken); + } + + const items: GenieMessageItem[] = []; + + return connectSSE({ + url: `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}?${params}`, + signal: options.signal, + onMessage: async (message) => { + try { + const event = JSON.parse(message.data) as GenieStreamEvent; + switch (event.type) { + case "message_result": + items.push(...messageResultToItems(event.message)); + break; + case "query_result": + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if ( + item.attachments.some( + (a) => a.attachmentId === event.attachmentId, + ) + ) { + item.queryResults.set(event.attachmentId, event.data); + break; + } + } + break; + case "history_info": + options.onPaginationInfo?.(event.nextPageToken); + break; + case "error": + options.onError?.(event.error); + break; + } + } catch { + // Malformed SSE data + } + }, + onError: (err) => options.onConnectionError?.(err), + }).then(() => items); +} + /** * Manages the full Genie chat lifecycle: * SSE streaming, conversation persistence via URL, and history replay. @@ -111,8 +174,9 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { nextPageTokenRef.current = nextPageToken; }, [nextPageToken]); - const processEvent = useCallback( - (event: GenieStreamEvent, isHistory: boolean) => { + /** Process SSE events during live message streaming (sendMessage). */ + const processStreamEvent = useCallback( + (event: GenieStreamEvent) => { switch (event.type) { case "message_start": { setConversationId(event.conversationId); @@ -137,10 +201,7 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { const msg = event.message; const hasAttachments = (msg.attachments?.length ?? 0) > 0; - if (isHistory) { - const items = messageResultToItems(msg); - setMessages((prev) => [...prev, ...items]); - } else if (hasAttachments) { + if (hasAttachments) { // During streaming we already appended the user message locally, // so only handle assistant results. Messages without attachments // are the user-message echo from the API — skip those. @@ -177,11 +238,6 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { break; } - case "history_info": { - setNextPageToken(event.nextPageToken); - break; - } - case "error": { setError(event.error); setStatus("error"); @@ -235,7 +291,7 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { signal: abortController.signal, onMessage: async (message) => { try { - processEvent(JSON.parse(message.data) as GenieStreamEvent, false); + processStreamEvent(JSON.parse(message.data) as GenieStreamEvent); } catch { // Malformed SSE data } @@ -261,7 +317,7 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { } }); }, - [alias, basePath, processEvent], + [alias, basePath, processStreamEvent], ); const loadHistory = useCallback( @@ -275,19 +331,11 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { const abortController = new AbortController(); abortControllerRef.current = abortController; - const requestId = crypto.randomUUID(); - - connectSSE({ - url: `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}?requestId=${encodeURIComponent(requestId)}`, + fetchConversationPage(basePath, alias, convId, { signal: abortController.signal, - onMessage: async (message) => { - try { - processEvent(JSON.parse(message.data) as GenieStreamEvent, true); - } catch { - // Malformed SSE data - } - }, - onError: (err) => { + onPaginationInfo: setNextPageToken, + onError: setError, + onConnectionError: (err) => { if (abortController.signal.aborted) return; setError( err instanceof Error @@ -296,13 +344,14 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { ); setStatus("error"); }, - }).then(() => { + }).then((items) => { if (!abortController.signal.aborted) { + setMessages(items); setStatus((prev) => (prev === "error" ? "error" : "idle")); } }); }, - [alias, basePath, processEvent], + [alias, basePath], ); const loadOlderMessages = useCallback(() => { @@ -317,55 +366,25 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { setStatus("loading-older"); setError(null); - const token = nextPageTokenRef.current; - const convId = conversationIdRef.current; - const requestId = crypto.randomUUID(); - - // Collect older messages from SSE, then prepend them all at once - const olderItems: GenieMessageItem[] = []; - - connectSSE({ - url: `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}?pageToken=${encodeURIComponent(token)}&requestId=${encodeURIComponent(requestId)}`, - onMessage: async (message) => { - try { - const event = JSON.parse(message.data) as GenieStreamEvent; - switch (event.type) { - case "message_result": - olderItems.push(...messageResultToItems(event.message)); - break; - case "query_result": - for (let i = olderItems.length - 1; i >= 0; i--) { - const item = olderItems[i]; - if ( - item.attachments.some( - (a) => a.attachmentId === event.attachmentId, - ) - ) { - item.queryResults.set(event.attachmentId, event.data); - break; - } - } - break; - case "history_info": - setNextPageToken(event.nextPageToken); - break; - case "error": - setError(event.error); - break; - } - } catch { - // Malformed SSE data - } - }, - onError: (err) => { + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + fetchConversationPage(basePath, alias, conversationIdRef.current, { + pageToken: nextPageTokenRef.current, + signal: abortController.signal, + onPaginationInfo: setNextPageToken, + onError: setError, + onConnectionError: (err) => { + if (abortController.signal.aborted) return; setError( err instanceof Error ? err.message : "Failed to load older messages.", ); }, }) - .then(() => { - if (olderItems.length > 0) { - setMessages((prev) => [...olderItems, ...prev]); + .then((items) => { + if (abortController.signal.aborted) return; + if (items.length > 0) { + setMessages((prev) => [...items, ...prev]); } setStatus("idle"); }) diff --git a/packages/appkit/src/plugins/genie/tests/genie.test.ts b/packages/appkit/src/plugins/genie/tests/genie.test.ts index 320ceba4..5b76cd65 100644 --- a/packages/appkit/src/plugins/genie/tests/genie.test.ts +++ b/packages/appkit/src/plugins/genie/tests/genie.test.ts @@ -584,7 +584,7 @@ describe("Genie Plugin", () => { expect.objectContaining({ space_id: "test-space-id", conversation_id: "conv-123", - page_size: 20, + page_size: 3, }), ); @@ -750,7 +750,7 @@ describe("Genie Plugin", () => { expect.objectContaining({ space_id: "test-space-id", conversation_id: "conv-123", - page_size: 20, + page_size: 3, }), ); From d10ee13bf861cfe397e82dc0721ad60c7eb0dbc1 Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Fri, 6 Mar 2026 12:25:15 +0100 Subject: [PATCH 04/16] fix: use genieConnectorDefaults.initialPageSize in tests Reference the constant directly instead of hardcoding the value, preventing test failures when the default is changed. Signed-off-by: Jorge Calvar --- packages/appkit/src/plugins/genie/tests/genie.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/appkit/src/plugins/genie/tests/genie.test.ts b/packages/appkit/src/plugins/genie/tests/genie.test.ts index 5b76cd65..069868e4 100644 --- a/packages/appkit/src/plugins/genie/tests/genie.test.ts +++ b/packages/appkit/src/plugins/genie/tests/genie.test.ts @@ -6,6 +6,7 @@ import { setupDatabricksEnv, } from "@tools/test-helpers"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { genieConnectorDefaults } from "../../../connectors/genie/defaults"; import { ServiceContext } from "../../../context/service-context"; import { Plugin } from "../../../plugin"; import { GeniePlugin, genie } from "../genie"; @@ -584,7 +585,7 @@ describe("Genie Plugin", () => { expect.objectContaining({ space_id: "test-space-id", conversation_id: "conv-123", - page_size: 3, + page_size: genieConnectorDefaults.initialPageSize, }), ); @@ -750,7 +751,7 @@ describe("Genie Plugin", () => { expect.objectContaining({ space_id: "test-space-id", conversation_id: "conv-123", - page_size: 3, + page_size: genieConnectorDefaults.initialPageSize, }), ); From 746554d09d42994a91a2d88cb5e98e47666a8ae9 Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Fri, 6 Mar 2026 13:32:21 +0100 Subject: [PATCH 05/16] refactor: extract scroll hooks from GenieChatMessageList Extract useScrollManagement and useLoadOlderOnScroll into custom hooks, inline StreamingIndicator, leaving the component as clean JSX. Signed-off-by: Jorge Calvar --- .../react/genie/genie-chat-message-list.tsx | 90 +++++++++++-------- 1 file changed, 52 insertions(+), 38 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx index b790f9ad..a572a8e8 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx @@ -30,40 +30,24 @@ function formatStatus(status: string): string { return STATUS_LABELS[status] ?? status.replace(/_/g, " ").toLowerCase(); } -function StreamingIndicator({ messages }: { messages: GenieMessageItem[] }) { - const last = messages[messages.length - 1]; - if (last?.role === "assistant" && last.id === "") { - return ( -
- - {formatStatus(last.status)} -
- ); - } - return null; -} - function getViewport(scrollRef: React.RefObject) { return scrollRef.current?.querySelector( '[data-slot="scroll-area-viewport"]', ); } -/** Scrollable message list that renders Genie chat messages with auto-scroll, skeleton loaders, and a streaming indicator. */ -export function GenieChatMessageList({ - messages, - status, - className, - hasOlderMessages = false, - onLoadOlder, -}: GenieChatMessageListProps) { - const scrollRef = useRef(null); +/** + * Manages scroll position: scrolls to bottom on append/initial load, + * preserves position when older messages are prepended. + */ +function useScrollManagement( + scrollRef: React.RefObject, + messages: GenieMessageItem[], + status: GenieChatStatus, +) { const prevFirstMessageIdRef = useRef(null); const prevScrollHeightRef = useRef(0); - // Handle scroll position after messages change. - // prevScrollHeightRef holds the scrollHeight from the *previous* render's - // effect, so on a prepend we can compute how much content was added above. // biome-ignore lint/correctness/useExhaustiveDependencies: intentional triggers for scroll management useLayoutEffect(() => { const viewport = getViewport(scrollRef); @@ -75,26 +59,30 @@ export function GenieChatMessageList({ firstMessageId !== prevFirstMessageIdRef.current; if (wasPrepend && prevScrollHeightRef.current > 0) { - // Older messages were prepended — preserve scroll position const delta = viewport.scrollHeight - prevScrollHeightRef.current; viewport.scrollTop += delta; } else { - // New messages appended or initial load — scroll to bottom viewport.scrollTop = viewport.scrollHeight; } - // Update refs *after* scroll adjustment so they're correct for the next render prevFirstMessageIdRef.current = firstMessageId; prevScrollHeightRef.current = viewport.scrollHeight; }, [messages.length, status]); +} - // Auto-trigger loading older messages when scrolling to the top - const sentinelRef = useRef(null); +/** + * Observes a sentinel element at the top of the scroll area and triggers + * `onLoadOlder` when the user scrolls to the top (only if content overflows). + */ +function useLoadOlderOnScroll( + scrollRef: React.RefObject, + sentinelRef: React.RefObject, + shouldObserve: boolean, + onLoadOlder?: () => void, +) { const onLoadOlderRef = useRef(onLoadOlder); onLoadOlderRef.current = onLoadOlder; - const shouldObserve = hasOlderMessages && status !== "loading-older"; - useEffect(() => { const sentinel = sentinelRef.current; const viewport = getViewport(scrollRef); @@ -102,8 +90,6 @@ export function GenieChatMessageList({ const observer = new IntersectionObserver( (entries) => { - // Only trigger when the user has actually scrolled near the top, - // not when content is too short to fill the viewport. const isScrollable = viewport.scrollHeight > viewport.clientHeight; if (entries[0]?.isIntersecting && isScrollable) { onLoadOlderRef.current?.(); @@ -114,12 +100,37 @@ export function GenieChatMessageList({ observer.observe(sentinel); return () => observer.disconnect(); - }, [shouldObserve]); + }, [scrollRef, sentinelRef, shouldObserve]); +} + +/** Scrollable message list that renders Genie chat messages with auto-scroll, skeleton loaders, and a streaming indicator. */ +export function GenieChatMessageList({ + messages, + status, + className, + hasOlderMessages = false, + onLoadOlder, +}: GenieChatMessageListProps) { + const scrollRef = useRef(null); + const sentinelRef = useRef(null); + + useScrollManagement(scrollRef, messages, status); + useLoadOlderOnScroll( + scrollRef, + sentinelRef, + hasOlderMessages && status !== "loading-older", + onLoadOlder, + ); + + const lastMessage = messages[messages.length - 1]; + const showStreamingIndicator = + status === "streaming" && + lastMessage?.role === "assistant" && + lastMessage.id === ""; return (
- {/* Sentinel element for auto-triggering load when scrolled to top */} {hasOlderMessages &&
} {status === "loading-older" && ( @@ -146,8 +157,11 @@ export function GenieChatMessageList({ return ; })} - {status === "streaming" && messages.length > 0 && ( - + {showStreamingIndicator && ( +
+ + {formatStatus(lastMessage.status)} +
)} {messages.length === 0 && status === "idle" && ( From e1da9e31a4b3b2676feeefd6e9115e583ab75bfd Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Fri, 6 Mar 2026 13:36:53 +0100 Subject: [PATCH 06/16] refactor: minor cleanup in GenieChatMessageList - useLoadOlderOnScroll owns its sentinel ref internally - Simplify message rendering with filter+map Signed-off-by: Jorge Calvar --- .../react/genie/genie-chat-message-list.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx index a572a8e8..0e955acc 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx @@ -73,13 +73,14 @@ function useScrollManagement( /** * Observes a sentinel element at the top of the scroll area and triggers * `onLoadOlder` when the user scrolls to the top (only if content overflows). + * Returns a ref to attach to the sentinel element. */ function useLoadOlderOnScroll( scrollRef: React.RefObject, - sentinelRef: React.RefObject, shouldObserve: boolean, onLoadOlder?: () => void, ) { + const sentinelRef = useRef(null); const onLoadOlderRef = useRef(onLoadOlder); onLoadOlderRef.current = onLoadOlder; @@ -100,7 +101,9 @@ function useLoadOlderOnScroll( observer.observe(sentinel); return () => observer.disconnect(); - }, [scrollRef, sentinelRef, shouldObserve]); + }, [scrollRef, shouldObserve]); + + return sentinelRef; } /** Scrollable message list that renders Genie chat messages with auto-scroll, skeleton loaders, and a streaming indicator. */ @@ -112,12 +115,10 @@ export function GenieChatMessageList({ onLoadOlder, }: GenieChatMessageListProps) { const scrollRef = useRef(null); - const sentinelRef = useRef(null); useScrollManagement(scrollRef, messages, status); - useLoadOlderOnScroll( + const sentinelRef = useLoadOlderOnScroll( scrollRef, - sentinelRef, hasOlderMessages && status !== "loading-older", onLoadOlder, ); @@ -150,12 +151,13 @@ export function GenieChatMessageList({
)} - {messages.map((msg) => { - if (msg.role === "assistant" && msg.id === "" && !msg.content) { - return null; - } - return ; - })} + {messages + .filter( + (msg) => msg.role !== "assistant" || msg.id !== "" || msg.content, + ) + .map((msg) => ( + + ))} {showStreamingIndicator && (
From 8c8938a0fb9b9ec0227c2bdd061ca5aabb52ce1d Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Fri, 6 Mar 2026 13:43:56 +0100 Subject: [PATCH 07/16] refactor: simplify useGenieChat internals - Merge ref-sync effects into one - Extract fetchPage helper shared by loadHistory and loadOlderMessages - Simplify query_result handler with .map() instead of imperative loop Signed-off-by: Jorge Calvar --- .../src/react/genie/use-genie-chat.ts | 94 +++++++++---------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/use-genie-chat.ts b/packages/appkit-ui/src/react/genie/use-genie-chat.ts index 9e2a1dde..5f46a431 100644 --- a/packages/appkit-ui/src/react/genie/use-genie-chat.ts +++ b/packages/appkit-ui/src/react/genie/use-genie-chat.ts @@ -168,11 +168,8 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { useEffect(() => { conversationIdRef.current = conversationId; - }, [conversationId]); - - useEffect(() => { nextPageTokenRef.current = nextPageToken; - }, [nextPageToken]); + }, [conversationId, nextPageToken]); /** Process SSE events during live message streaming (sendMessage). */ const processStreamEvent = useCallback( @@ -218,23 +215,19 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { } case "query_result": { - setMessages((prev) => { - const updated = [...prev]; - for (let i = updated.length - 1; i >= 0; i--) { - const msg = updated[i]; - if ( - msg.attachments.some( - (a) => a.attachmentId === event.attachmentId, - ) - ) { - const queryResults = new Map(msg.queryResults); - queryResults.set(event.attachmentId, event.data); - updated[i] = { ...msg, queryResults }; - break; - } - } - return updated; - }); + setMessages((prev) => + prev.map((msg) => + msg.attachments.some((a) => a.attachmentId === event.attachmentId) + ? { + ...msg, + queryResults: new Map(msg.queryResults).set( + event.attachmentId, + event.data, + ), + } + : msg, + ), + ); break; } @@ -320,18 +313,18 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { [alias, basePath, processStreamEvent], ); - const loadHistory = useCallback( - (convId: string) => { + /** Creates an AbortController, stores it, and fetches a conversation page. */ + const fetchPage = useCallback( + ( + convId: string, + options?: { pageToken?: string; errorMessage?: string }, + ) => { abortControllerRef.current?.abort(); - setStatus("loading-history"); - setError(null); - setMessages([]); - setConversationId(convId); - const abortController = new AbortController(); abortControllerRef.current = abortController; - fetchConversationPage(basePath, alias, convId, { + const promise = fetchConversationPage(basePath, alias, convId, { + pageToken: options?.pageToken, signal: abortController.signal, onPaginationInfo: setNextPageToken, onError: setError, @@ -340,18 +333,35 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { setError( err instanceof Error ? err.message - : "Failed to load conversation history.", + : (options?.errorMessage ?? "Failed to load messages."), ); setStatus("error"); }, - }).then((items) => { + }); + + return { promise, abortController }; + }, + [alias, basePath], + ); + + const loadHistory = useCallback( + (convId: string) => { + setStatus("loading-history"); + setError(null); + setMessages([]); + setConversationId(convId); + + const { promise, abortController } = fetchPage(convId, { + errorMessage: "Failed to load conversation history.", + }); + promise.then((items) => { if (!abortController.signal.aborted) { setMessages(items); setStatus((prev) => (prev === "error" ? "error" : "idle")); } }); }, - [alias, basePath], + [fetchPage], ); const loadOlderMessages = useCallback(() => { @@ -366,21 +376,11 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { setStatus("loading-older"); setError(null); - const abortController = new AbortController(); - abortControllerRef.current = abortController; - - fetchConversationPage(basePath, alias, conversationIdRef.current, { + const { promise, abortController } = fetchPage(conversationIdRef.current, { pageToken: nextPageTokenRef.current, - signal: abortController.signal, - onPaginationInfo: setNextPageToken, - onError: setError, - onConnectionError: (err) => { - if (abortController.signal.aborted) return; - setError( - err instanceof Error ? err.message : "Failed to load older messages.", - ); - }, - }) + errorMessage: "Failed to load older messages.", + }); + promise .then((items) => { if (abortController.signal.aborted) return; if (items.length > 0) { @@ -391,7 +391,7 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { .finally(() => { isLoadingOlderRef.current = false; }); - }, [alias, basePath]); + }, [fetchPage]); const reset = useCallback(() => { abortControllerRef.current?.abort(); From d6f9d13ea43bd6f0c3309abfe1364a35e0de16ee Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Mon, 9 Mar 2026 08:40:42 +0100 Subject: [PATCH 08/16] fix: address PR review feedback - Rename hasOlderMessages/loadOlderMessages to hasPreviousPage/ fetchPreviousPage following TanStack Query conventions, add isFetchingPreviousPage - Use separate AbortController for pagination to avoid aborting in-progress streams - Fix Express query param type safety (typeof check vs as cast) - Add test for paginated request failure Signed-off-by: Jorge Calvar --- .../react/genie/genie-chat-message-list.tsx | 28 ++++++------- .../appkit-ui/src/react/genie/genie-chat.tsx | 8 ++-- packages/appkit-ui/src/react/genie/types.ts | 10 +++-- .../src/react/genie/use-genie-chat.ts | 42 ++++++++++++------- packages/appkit/src/plugins/genie/genie.ts | 7 +++- .../src/plugins/genie/tests/genie.test.ts | 34 +++++++++++++++ 6 files changed, 91 insertions(+), 38 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx index 0e955acc..f028b91a 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx @@ -13,10 +13,10 @@ export interface GenieChatMessageListProps { status: GenieChatStatus; /** Additional CSS class for the scroll area */ className?: string; - /** Whether older messages are available to load */ - hasOlderMessages?: boolean; - /** Callback to load older messages */ - onLoadOlder?: () => void; + /** Whether a previous page of older messages exists */ + hasPreviousPage?: boolean; + /** Callback to fetch the previous page of messages */ + onFetchPreviousPage?: () => void; } const STATUS_LABELS: Record = { @@ -72,17 +72,17 @@ function useScrollManagement( /** * Observes a sentinel element at the top of the scroll area and triggers - * `onLoadOlder` when the user scrolls to the top (only if content overflows). + * `onFetchPreviousPage` when the user scrolls to the top (only if content overflows). * Returns a ref to attach to the sentinel element. */ function useLoadOlderOnScroll( scrollRef: React.RefObject, shouldObserve: boolean, - onLoadOlder?: () => void, + onFetchPreviousPage?: () => void, ) { const sentinelRef = useRef(null); - const onLoadOlderRef = useRef(onLoadOlder); - onLoadOlderRef.current = onLoadOlder; + const onFetchPreviousPageRef = useRef(onFetchPreviousPage); + onFetchPreviousPageRef.current = onFetchPreviousPage; useEffect(() => { const sentinel = sentinelRef.current; @@ -93,7 +93,7 @@ function useLoadOlderOnScroll( (entries) => { const isScrollable = viewport.scrollHeight > viewport.clientHeight; if (entries[0]?.isIntersecting && isScrollable) { - onLoadOlderRef.current?.(); + onFetchPreviousPageRef.current?.(); } }, { root: viewport, threshold: 0 }, @@ -111,16 +111,16 @@ export function GenieChatMessageList({ messages, status, className, - hasOlderMessages = false, - onLoadOlder, + hasPreviousPage = false, + onFetchPreviousPage, }: GenieChatMessageListProps) { const scrollRef = useRef(null); useScrollManagement(scrollRef, messages, status); const sentinelRef = useLoadOlderOnScroll( scrollRef, - hasOlderMessages && status !== "loading-older", - onLoadOlder, + hasPreviousPage && status !== "loading-older", + onFetchPreviousPage, ); const lastMessage = messages[messages.length - 1]; @@ -132,7 +132,7 @@ export function GenieChatMessageList({ return (
- {hasOlderMessages &&
} + {hasPreviousPage &&
} {status === "loading-older" && (
diff --git a/packages/appkit-ui/src/react/genie/genie-chat.tsx b/packages/appkit-ui/src/react/genie/genie-chat.tsx index 8b71c2ae..32bb1139 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat.tsx @@ -18,8 +18,8 @@ export function GenieChat({ error, sendMessage, reset, - hasOlderMessages, - loadOlderMessages, + hasPreviousPage, + fetchPreviousPage, } = useGenieChat({ alias, basePath, @@ -43,8 +43,8 @@ export function GenieChat({ {error && ( diff --git a/packages/appkit-ui/src/react/genie/types.ts b/packages/appkit-ui/src/react/genie/types.ts index f2c2681f..74a2e0e9 100644 --- a/packages/appkit-ui/src/react/genie/types.ts +++ b/packages/appkit-ui/src/react/genie/types.ts @@ -41,10 +41,12 @@ export interface UseGenieChatReturn { error: string | null; sendMessage: (content: string) => void; reset: () => void; - /** Whether older messages exist that can be loaded */ - hasOlderMessages: boolean; - /** Fetch the next page of older messages */ - loadOlderMessages: () => void; + /** Whether a previous page of older messages exists */ + hasPreviousPage: boolean; + /** Whether a previous page is currently being fetched */ + isFetchingPreviousPage: boolean; + /** Fetch the previous page of older messages */ + fetchPreviousPage: () => void; } export interface GenieChatProps { diff --git a/packages/appkit-ui/src/react/genie/use-genie-chat.ts b/packages/appkit-ui/src/react/genie/use-genie-chat.ts index 5f46a431..7a749a75 100644 --- a/packages/appkit-ui/src/react/genie/use-genie-chat.ts +++ b/packages/appkit-ui/src/react/genie/use-genie-chat.ts @@ -159,9 +159,11 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { const [error, setError] = useState(null); const [nextPageToken, setNextPageToken] = useState(null); - const hasOlderMessages = nextPageToken !== null; + const hasPreviousPage = nextPageToken !== null; + const isFetchingPreviousPage = status === "loading-older"; const abortControllerRef = useRef(null); + const paginationAbortRef = useRef(null); const conversationIdRef = useRef(null); const nextPageTokenRef = useRef(null); const isLoadingOlderRef = useRef(false); @@ -313,15 +315,18 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { [alias, basePath, processStreamEvent], ); - /** Creates an AbortController, stores it, and fetches a conversation page. */ + /** Creates an AbortController, stores it in the given ref, and fetches a conversation page. */ const fetchPage = useCallback( ( + controllerRef: React.RefObject, convId: string, options?: { pageToken?: string; errorMessage?: string }, ) => { - abortControllerRef.current?.abort(); + controllerRef.current?.abort(); const abortController = new AbortController(); - abortControllerRef.current = abortController; + ( + controllerRef as React.MutableRefObject + ).current = abortController; const promise = fetchConversationPage(basePath, alias, convId, { pageToken: options?.pageToken, @@ -351,9 +356,11 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { setMessages([]); setConversationId(convId); - const { promise, abortController } = fetchPage(convId, { - errorMessage: "Failed to load conversation history.", - }); + const { promise, abortController } = fetchPage( + abortControllerRef, + convId, + { errorMessage: "Failed to load conversation history." }, + ); promise.then((items) => { if (!abortController.signal.aborted) { setMessages(items); @@ -364,7 +371,7 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { [fetchPage], ); - const loadOlderMessages = useCallback(() => { + const fetchPreviousPage = useCallback(() => { if ( !nextPageTokenRef.current || !conversationIdRef.current || @@ -376,10 +383,14 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { setStatus("loading-older"); setError(null); - const { promise, abortController } = fetchPage(conversationIdRef.current, { - pageToken: nextPageTokenRef.current, - errorMessage: "Failed to load older messages.", - }); + const { promise, abortController } = fetchPage( + paginationAbortRef, + conversationIdRef.current, + { + pageToken: nextPageTokenRef.current, + errorMessage: "Failed to load older messages.", + }, + ); promise .then((items) => { if (abortController.signal.aborted) return; @@ -395,6 +406,7 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { const reset = useCallback(() => { abortControllerRef.current?.abort(); + paginationAbortRef.current?.abort(); setMessages([]); setConversationId(null); setError(null); @@ -413,6 +425,7 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { } return () => { abortControllerRef.current?.abort(); + paginationAbortRef.current?.abort(); }; }, [persistInUrl, urlParamName, loadHistory]); @@ -423,7 +436,8 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { error, sendMessage, reset, - hasOlderMessages, - loadOlderMessages, + hasPreviousPage, + isFetchingPreviousPage, + fetchPreviousPage, }; } diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts index 1a61d0d1..51c0b6b5 100644 --- a/packages/appkit/src/plugins/genie/genie.ts +++ b/packages/appkit/src/plugins/genie/genie.ts @@ -138,8 +138,11 @@ export class GeniePlugin extends Plugin { } const includeQueryResults = req.query.includeQueryResults !== "false"; - const pageToken = req.query.pageToken as string | undefined; - const requestId = (req.query.requestId as string) || randomUUID(); + const pageToken = + typeof req.query.pageToken === "string" ? req.query.pageToken : undefined; + const requestId = + (typeof req.query.requestId === "string" && req.query.requestId) || + randomUUID(); logger.debug( "Fetching conversation %s from space %s (alias=%s, includeQueryResults=%s, pageToken=%s)", diff --git a/packages/appkit/src/plugins/genie/tests/genie.test.ts b/packages/appkit/src/plugins/genie/tests/genie.test.ts index 069868e4..36d0d921 100644 --- a/packages/appkit/src/plugins/genie/tests/genie.test.ts +++ b/packages/appkit/src/plugins/genie/tests/genie.test.ts @@ -1052,6 +1052,40 @@ describe("Genie Plugin", () => { expect(allWritten).toContain("next-token-abc"); expect(mockRes.end).toHaveBeenCalled(); }); + + test("should yield error event when paginated request fails", async () => { + mockGenieService.listConversationMessages.mockRejectedValue( + new Error("Page token expired"), + ); + + const plugin = new GeniePlugin(config); + const { router, getHandler } = createMockRouter(); + + plugin.injectRoutes(router); + + const handler = getHandler( + "GET", + "/:alias/conversations/:conversationId", + ); + const mockReq = createMockRequest({ + params: { alias: "myspace", conversationId: "conv-123" }, + query: { pageToken: "expired-token" }, + headers: { + "x-forwarded-access-token": "user-token", + "x-forwarded-user": "user-1", + }, + }); + const mockRes = createMockResponse(); + + await handler(mockReq, mockRes); + + const writeCalls = mockRes.write.mock.calls.map((call: any[]) => call[0]); + const allWritten = writeCalls.join(""); + + expect(allWritten).toContain("error"); + expect(allWritten).toContain("Page token expired"); + expect(mockRes.end).toHaveBeenCalled(); + }); }); describe("SSE reconnection streamId", () => { From e3d512cbe0d9515404e53703b3dccc0b522e3a6b Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Mon, 9 Mar 2026 09:00:39 +0100 Subject: [PATCH 09/16] docs: re-generate docs after change --- docs/docs/api/appkit-ui/genie/GenieChatMessageList.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/api/appkit-ui/genie/GenieChatMessageList.mdx b/docs/docs/api/appkit-ui/genie/GenieChatMessageList.mdx index e1420c3e..f81df359 100644 --- a/docs/docs/api/appkit-ui/genie/GenieChatMessageList.mdx +++ b/docs/docs/api/appkit-ui/genie/GenieChatMessageList.mdx @@ -18,8 +18,8 @@ Scrollable message list that renders Genie chat messages with auto-scroll, skele | `messages` | `GenieMessageItem[]` | ✓ | - | Array of messages to display | | `status` | `enum` | ✓ | - | Current chat status (controls loading indicators and skeleton placeholders) | | `className` | `string` | | - | Additional CSS class for the scroll area | -| `hasOlderMessages` | `boolean` | | `false` | Whether older messages are available to load | -| `onLoadOlder` | `(() => void)` | | - | Callback to load older messages | +| `hasPreviousPage` | `boolean` | | `false` | Whether a previous page of older messages exists | +| `onFetchPreviousPage` | `(() => void)` | | - | Callback to fetch the previous page of messages | From 4681194844b555ee4179c2083ee7d356c95b2c3c Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Mon, 9 Mar 2026 11:06:48 +0100 Subject: [PATCH 10/16] fix: use typeof check for requestId in sendMessage handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistent with getConversation handler — avoids unsafe as string cast for Express query params. Signed-off-by: Jorge Calvar --- packages/appkit/src/plugins/genie/genie.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/appkit/src/plugins/genie/genie.ts b/packages/appkit/src/plugins/genie/genie.ts index 51c0b6b5..a3c301b4 100644 --- a/packages/appkit/src/plugins/genie/genie.ts +++ b/packages/appkit/src/plugins/genie/genie.ts @@ -95,7 +95,9 @@ export class GeniePlugin extends Plugin { ); const timeout = this.config.timeout ?? 120_000; - const requestId = (req.query.requestId as string) || randomUUID(); + const requestId = + (typeof req.query.requestId === "string" && req.query.requestId) || + randomUUID(); const streamSettings: StreamExecutionSettings = { ...genieStreamDefaults, From 0d11b595763cea587e4707ae144c538789c78894 Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Mon, 9 Mar 2026 11:15:09 +0100 Subject: [PATCH 11/16] fix: scroll management races and observer rapid-fire loop - useScrollManagement no longer fires on status-only changes, preventing scroll-to-bottom yank when "loading-older" spinner appears - sendMessage aborts in-flight pagination to prevent status stomping - fetchPreviousPage guards setStatus to not overwrite concurrent streams - IntersectionObserver skips initial callback after re-subscription to prevent rapid-fire pagination loop Signed-off-by: Jorge Calvar --- .../react/genie/genie-chat-message-list.tsx | 29 ++++++++++++++++--- .../src/react/genie/use-genie-chat.ts | 7 ++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx index f028b91a..86e27b2e 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx @@ -43,31 +43,43 @@ function getViewport(scrollRef: React.RefObject) { function useScrollManagement( scrollRef: React.RefObject, messages: GenieMessageItem[], - status: GenieChatStatus, ) { const prevFirstMessageIdRef = useRef(null); const prevScrollHeightRef = useRef(0); + const prevMessageCountRef = useRef(0); - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional triggers for scroll management + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only react to message count changes useLayoutEffect(() => { const viewport = getViewport(scrollRef); if (!viewport) return; + const count = messages.length; + const countChanged = count !== prevMessageCountRef.current; + prevMessageCountRef.current = count; + + // Nothing to do if message count didn't change (e.g. status-only transition) + if (!countChanged) { + prevScrollHeightRef.current = viewport.scrollHeight; + return; + } + const firstMessageId = messages[0]?.id ?? null; const wasPrepend = prevFirstMessageIdRef.current !== null && firstMessageId !== prevFirstMessageIdRef.current; if (wasPrepend && prevScrollHeightRef.current > 0) { + // Older messages prepended — preserve scroll position const delta = viewport.scrollHeight - prevScrollHeightRef.current; viewport.scrollTop += delta; } else { + // Messages appended or initial load — scroll to bottom viewport.scrollTop = viewport.scrollHeight; } prevFirstMessageIdRef.current = firstMessageId; prevScrollHeightRef.current = viewport.scrollHeight; - }, [messages.length, status]); + }, [messages.length]); } /** @@ -89,8 +101,17 @@ function useLoadOlderOnScroll( const viewport = getViewport(scrollRef); if (!sentinel || !viewport || !shouldObserve) return; + // Skip the first callback after (re-)subscribing — the observer fires + // immediately if the sentinel is visible, which happens right after a + // page load before the scroll adjustment has pushed it off-screen. + let initialFire = true; + const observer = new IntersectionObserver( (entries) => { + if (initialFire) { + initialFire = false; + return; + } const isScrollable = viewport.scrollHeight > viewport.clientHeight; if (entries[0]?.isIntersecting && isScrollable) { onFetchPreviousPageRef.current?.(); @@ -116,7 +137,7 @@ export function GenieChatMessageList({ }: GenieChatMessageListProps) { const scrollRef = useRef(null); - useScrollManagement(scrollRef, messages, status); + useScrollManagement(scrollRef, messages); const sentinelRef = useLoadOlderOnScroll( scrollRef, hasPreviousPage && status !== "loading-older", diff --git a/packages/appkit-ui/src/react/genie/use-genie-chat.ts b/packages/appkit-ui/src/react/genie/use-genie-chat.ts index 7a749a75..bf85c26a 100644 --- a/packages/appkit-ui/src/react/genie/use-genie-chat.ts +++ b/packages/appkit-ui/src/react/genie/use-genie-chat.ts @@ -249,6 +249,7 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { if (!trimmed) return; abortControllerRef.current?.abort(); + paginationAbortRef.current?.abort(); setError(null); setStatus("streaming"); @@ -397,7 +398,11 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { if (items.length > 0) { setMessages((prev) => [...items, ...prev]); } - setStatus("idle"); + // Only transition to idle if we're still in loading-older state — + // another operation (e.g. sendMessage) may have taken over. + setStatus((current) => + current === "loading-older" ? "idle" : current, + ); }) .finally(() => { isLoadingOlderRef.current = false; From 2d100d2fe7b4f82d02253aba452a3ecf4130a5c3 Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Mon, 9 Mar 2026 11:55:07 +0100 Subject: [PATCH 12/16] fix: pagination error handling, type safety, and performance - Server-sent error events during pagination now set error status (hadError flag prevents status flashing to idle) - Replace unsafe MutableRefObject cast with plain object type in fetchPage - Restore reverse-scan-with-break for query_result in processStreamEvent - Add ResizeObserver to keep scroll height ref fresh for async content Signed-off-by: Jorge Calvar --- .../react/genie/genie-chat-message-list.tsx | 13 ++++ .../src/react/genie/use-genie-chat.ts | 65 +++++++++++-------- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx index 86e27b2e..5597ec76 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx @@ -48,6 +48,19 @@ function useScrollManagement( const prevScrollHeightRef = useRef(0); const prevMessageCountRef = useRef(0); + // Keep prevScrollHeightRef fresh when async content (images, embeds) + // changes the viewport height between renders. + useEffect(() => { + const viewport = getViewport(scrollRef); + if (!viewport) return; + + const observer = new ResizeObserver(() => { + prevScrollHeightRef.current = viewport.scrollHeight; + }); + observer.observe(viewport); + return () => observer.disconnect(); + }, [scrollRef]); + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only react to message count changes useLayoutEffect(() => { const viewport = getViewport(scrollRef); diff --git a/packages/appkit-ui/src/react/genie/use-genie-chat.ts b/packages/appkit-ui/src/react/genie/use-genie-chat.ts index bf85c26a..6927bb39 100644 --- a/packages/appkit-ui/src/react/genie/use-genie-chat.ts +++ b/packages/appkit-ui/src/react/genie/use-genie-chat.ts @@ -97,6 +97,7 @@ function fetchConversationPage( } const items: GenieMessageItem[] = []; + let hadError = false; return connectSSE({ url: `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}?${params}`, @@ -125,6 +126,7 @@ function fetchConversationPage( options.onPaginationInfo?.(event.nextPageToken); break; case "error": + hadError = true; options.onError?.(event.error); break; } @@ -133,7 +135,7 @@ function fetchConversationPage( } }, onError: (err) => options.onConnectionError?.(err), - }).then(() => items); + }).then(() => ({ items, hadError })); } /** @@ -217,19 +219,28 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { } case "query_result": { - setMessages((prev) => - prev.map((msg) => - msg.attachments.some((a) => a.attachmentId === event.attachmentId) - ? { - ...msg, - queryResults: new Map(msg.queryResults).set( - event.attachmentId, - event.data, - ), - } - : msg, - ), - ); + setMessages((prev) => { + // Reverse scan — query results typically match recent messages + for (let i = prev.length - 1; i >= 0; i--) { + const msg = prev[i]; + if ( + msg.attachments.some( + (a) => a.attachmentId === event.attachmentId, + ) + ) { + const updated = prev.slice(); + updated[i] = { + ...msg, + queryResults: new Map(msg.queryResults).set( + event.attachmentId, + event.data, + ), + }; + return updated; + } + } + return prev; + }); break; } @@ -319,15 +330,13 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { /** Creates an AbortController, stores it in the given ref, and fetches a conversation page. */ const fetchPage = useCallback( ( - controllerRef: React.RefObject, + controllerRef: { current: AbortController | null }, convId: string, options?: { pageToken?: string; errorMessage?: string }, ) => { controllerRef.current?.abort(); const abortController = new AbortController(); - ( - controllerRef as React.MutableRefObject - ).current = abortController; + controllerRef.current = abortController; const promise = fetchConversationPage(basePath, alias, convId, { pageToken: options?.pageToken, @@ -362,10 +371,10 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { convId, { errorMessage: "Failed to load conversation history." }, ); - promise.then((items) => { + promise.then(({ items, hadError }) => { if (!abortController.signal.aborted) { setMessages(items); - setStatus((prev) => (prev === "error" ? "error" : "idle")); + setStatus(hadError ? "error" : "idle"); } }); }, @@ -393,16 +402,20 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { }, ); promise - .then((items) => { + .then(({ items, hadError }) => { if (abortController.signal.aborted) return; if (items.length > 0) { setMessages((prev) => [...items, ...prev]); } - // Only transition to idle if we're still in loading-older state — - // another operation (e.g. sendMessage) may have taken over. - setStatus((current) => - current === "loading-older" ? "idle" : current, - ); + if (hadError) { + setStatus("error"); + } else { + // Only transition to idle if we're still in loading-older state — + // another operation (e.g. sendMessage) may have taken over. + setStatus((current) => + current === "loading-older" ? "idle" : current, + ); + } }) .finally(() => { isLoadingOlderRef.current = false; From 0f1e473765b17f8cd91b5979b0477cae9c398680 Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Mon, 9 Mar 2026 14:04:46 +0100 Subject: [PATCH 13/16] fix: simplify error handling, remove hadError flag - Set error status directly in onError callback instead of threading hadError flag through return value - Remove freeze/unfreeze scroll momentum plumbing (not effective enough) - Clean up unused code Signed-off-by: Jorge Calvar --- .../react/genie/genie-chat-message-list.tsx | 2 +- .../src/react/genie/use-genie-chat.ts | 31 +++++++++---------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx index 5597ec76..9c7caa5b 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx @@ -150,12 +150,12 @@ export function GenieChatMessageList({ }: GenieChatMessageListProps) { const scrollRef = useRef(null); - useScrollManagement(scrollRef, messages); const sentinelRef = useLoadOlderOnScroll( scrollRef, hasPreviousPage && status !== "loading-older", onFetchPreviousPage, ); + useScrollManagement(scrollRef, messages); const lastMessage = messages[messages.length - 1]; const showStreamingIndicator = diff --git a/packages/appkit-ui/src/react/genie/use-genie-chat.ts b/packages/appkit-ui/src/react/genie/use-genie-chat.ts index 6927bb39..cdbf6a64 100644 --- a/packages/appkit-ui/src/react/genie/use-genie-chat.ts +++ b/packages/appkit-ui/src/react/genie/use-genie-chat.ts @@ -97,8 +97,6 @@ function fetchConversationPage( } const items: GenieMessageItem[] = []; - let hadError = false; - return connectSSE({ url: `${basePath}/${encodeURIComponent(alias)}/conversations/${encodeURIComponent(convId)}?${params}`, signal: options.signal, @@ -126,7 +124,6 @@ function fetchConversationPage( options.onPaginationInfo?.(event.nextPageToken); break; case "error": - hadError = true; options.onError?.(event.error); break; } @@ -135,7 +132,7 @@ function fetchConversationPage( } }, onError: (err) => options.onConnectionError?.(err), - }).then(() => ({ items, hadError })); + }).then(() => items); } /** @@ -342,7 +339,10 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { pageToken: options?.pageToken, signal: abortController.signal, onPaginationInfo: setNextPageToken, - onError: setError, + onError: (msg) => { + setError(msg); + setStatus("error"); + }, onConnectionError: (err) => { if (abortController.signal.aborted) return; setError( @@ -371,10 +371,10 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { convId, { errorMessage: "Failed to load conversation history." }, ); - promise.then(({ items, hadError }) => { + promise.then((items) => { if (!abortController.signal.aborted) { setMessages(items); - setStatus(hadError ? "error" : "idle"); + setStatus((prev) => (prev === "error" ? "error" : "idle")); } }); }, @@ -402,20 +402,17 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { }, ); promise - .then(({ items, hadError }) => { + .then((items) => { if (abortController.signal.aborted) return; if (items.length > 0) { setMessages((prev) => [...items, ...prev]); } - if (hadError) { - setStatus("error"); - } else { - // Only transition to idle if we're still in loading-older state — - // another operation (e.g. sendMessage) may have taken over. - setStatus((current) => - current === "loading-older" ? "idle" : current, - ); - } + // Only transition to idle if we're still in loading-older state — + // another operation (e.g. sendMessage) may have taken over. + // If onError already set "error", this preserves it. + setStatus((current) => + current === "loading-older" ? "idle" : current, + ); }) .finally(() => { isLoadingOlderRef.current = false; From b0c596bad94fd7e5ccfbf8f40d7da884b61ecd60 Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Mon, 9 Mar 2026 15:26:03 +0100 Subject: [PATCH 14/16] fix: abort pagination on conversation switch, arm observer via rAF - loadHistory aborts in-flight pagination to prevent stale messages from old conversation being prepended - Replace initialFire boolean with requestAnimationFrame arming so short conversations where sentinel is genuinely visible still trigger loading automatically Signed-off-by: Jorge Calvar --- .../react/genie/genie-chat-message-list.tsx | 22 +++++++++++-------- .../src/react/genie/use-genie-chat.ts | 1 + 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx index 9c7caa5b..00770c23 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx @@ -114,17 +114,18 @@ function useLoadOlderOnScroll( const viewport = getViewport(scrollRef); if (!sentinel || !viewport || !shouldObserve) return; - // Skip the first callback after (re-)subscribing — the observer fires - // immediately if the sentinel is visible, which happens right after a - // page load before the scroll adjustment has pushed it off-screen. - let initialFire = true; + // The observer fires synchronously on observe() if the sentinel is + // already visible. We arm it on the next frame so that synchronous + // initial fire is ignored, but a real intersection (user genuinely + // at the top on a short conversation) triggers on subsequent frames. + let armed = false; + const frameId = requestAnimationFrame(() => { + armed = true; + }); const observer = new IntersectionObserver( (entries) => { - if (initialFire) { - initialFire = false; - return; - } + if (!armed) return; const isScrollable = viewport.scrollHeight > viewport.clientHeight; if (entries[0]?.isIntersecting && isScrollable) { onFetchPreviousPageRef.current?.(); @@ -134,7 +135,10 @@ function useLoadOlderOnScroll( ); observer.observe(sentinel); - return () => observer.disconnect(); + return () => { + cancelAnimationFrame(frameId); + observer.disconnect(); + }; }, [scrollRef, shouldObserve]); return sentinelRef; diff --git a/packages/appkit-ui/src/react/genie/use-genie-chat.ts b/packages/appkit-ui/src/react/genie/use-genie-chat.ts index cdbf6a64..5c6876c5 100644 --- a/packages/appkit-ui/src/react/genie/use-genie-chat.ts +++ b/packages/appkit-ui/src/react/genie/use-genie-chat.ts @@ -361,6 +361,7 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { const loadHistory = useCallback( (convId: string) => { + paginationAbortRef.current?.abort(); setStatus("loading-history"); setError(null); setMessages([]); From e97a07ae4f8871e34347f86570dcd3417fefbced Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Mon, 9 Mar 2026 19:48:30 +0100 Subject: [PATCH 15/16] fix: enforce minimum delay before prepending older messages Add MIN_PREVIOUS_PAGE_LOAD_MS to ensure scroll inertia fully settles before fetchPreviousPage prepends older messages to the chat. Signed-off-by: Jorge Calvar --- .../appkit-ui/src/react/genie/use-genie-chat.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/use-genie-chat.ts b/packages/appkit-ui/src/react/genie/use-genie-chat.ts index 5c6876c5..a54082ae 100644 --- a/packages/appkit-ui/src/react/genie/use-genie-chat.ts +++ b/packages/appkit-ui/src/react/genie/use-genie-chat.ts @@ -135,6 +135,9 @@ function fetchConversationPage( }).then(() => items); } +/** Minimum time (ms) to hold the loading-older state so scroll inertia settles before prepending messages. */ +const MIN_PREVIOUS_PAGE_LOAD_MS = 800; + /** * Manages the full Genie chat lifecycle: * SSE streaming, conversation persistence via URL, and history replay. @@ -394,6 +397,7 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { setStatus("loading-older"); setError(null); + const startTime = Date.now(); const { promise, abortController } = fetchPage( paginationAbortRef, conversationIdRef.current, @@ -403,14 +407,18 @@ export function useGenieChat(options: UseGenieChatOptions): UseGenieChatReturn { }, ); promise - .then((items) => { + .then(async (items) => { + if (abortController.signal.aborted) return; + const elapsed = Date.now() - startTime; + if (elapsed < MIN_PREVIOUS_PAGE_LOAD_MS) { + await new Promise((r) => + setTimeout(r, MIN_PREVIOUS_PAGE_LOAD_MS - elapsed), + ); + } if (abortController.signal.aborted) return; if (items.length > 0) { setMessages((prev) => [...items, ...prev]); } - // Only transition to idle if we're still in loading-older state — - // another operation (e.g. sendMessage) may have taken over. - // If onError already set "error", this preserves it. setStatus((current) => current === "loading-older" ? "idle" : current, ); From 3c7d46442654239dce886a6acad4e56afc35428f Mon Sep 17 00:00:00 2001 From: Jorge Calvar Date: Tue, 10 Mar 2026 11:06:54 +0100 Subject: [PATCH 16/16] fix: prevent scroll jump when loading-older indicator disappears The useScrollManagement hook's useLayoutEffect only depended on messages.length, so prevScrollHeightRef was never updated when the loading indicator appeared. Adding status to the dependency array lets the effect capture the correct scrollHeight before messages are prepended, producing an accurate delta. Signed-off-by: Jorge Calvar --- .../appkit-ui/src/react/genie/genie-chat-message-list.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx index 00770c23..3261ff99 100644 --- a/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx +++ b/packages/appkit-ui/src/react/genie/genie-chat-message-list.tsx @@ -43,6 +43,7 @@ function getViewport(scrollRef: React.RefObject) { function useScrollManagement( scrollRef: React.RefObject, messages: GenieMessageItem[], + status: GenieChatStatus, ) { const prevFirstMessageIdRef = useRef(null); const prevScrollHeightRef = useRef(0); @@ -61,7 +62,7 @@ function useScrollManagement( return () => observer.disconnect(); }, [scrollRef]); - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only react to message count changes + // biome-ignore lint/correctness/useExhaustiveDependencies: react to message count AND status so prevScrollHeightRef stays accurate when the loading indicator appears/disappears useLayoutEffect(() => { const viewport = getViewport(scrollRef); if (!viewport) return; @@ -92,7 +93,7 @@ function useScrollManagement( prevFirstMessageIdRef.current = firstMessageId; prevScrollHeightRef.current = viewport.scrollHeight; - }, [messages.length]); + }, [messages.length, status]); } /** @@ -159,7 +160,7 @@ export function GenieChatMessageList({ hasPreviousPage && status !== "loading-older", onFetchPreviousPage, ); - useScrollManagement(scrollRef, messages); + useScrollManagement(scrollRef, messages, status); const lastMessage = messages[messages.length - 1]; const showStreamingIndicator =