From 1594f98cc1674e606f009c3edbd219603fca4652 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 7 Jan 2026 23:44:07 +1000 Subject: [PATCH] feat: add chat history with scroll-up loading to public agent chat - Update conversation API to support cursor-based pagination (before/after params) - Default behavior now returns most recent messages first - Add react-virtuoso for efficient virtualized message rendering - Implement scroll-up loading of older messages - Show loading indicator when fetching history --- apps/web/src/app/agents/[id]/chat/page.tsx | 275 +++++++++++------- .../conversation/[conversationId]/route.ts | 44 ++- 2 files changed, 211 insertions(+), 108 deletions(-) diff --git a/apps/web/src/app/agents/[id]/chat/page.tsx b/apps/web/src/app/agents/[id]/chat/page.tsx index fa1916a..12095ca 100644 --- a/apps/web/src/app/agents/[id]/chat/page.tsx +++ b/apps/web/src/app/agents/[id]/chat/page.tsx @@ -6,6 +6,7 @@ import { Icon } from '@tpmjs/ui/Icon/Icon'; import Link from 'next/link'; import { useParams, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { AppHeader } from '~/components/AppHeader'; interface Agent { @@ -181,8 +182,13 @@ export default function PublicAgentChatPage(): React.ReactElement { const [error, setError] = useState(null); const [toolCalls, setToolCalls] = useState([]); const [expandedToolCalls, setExpandedToolCalls] = useState>(new Set()); + const [hasMoreMessages, setHasMoreMessages] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); - const messagesEndRef = useRef(null); + // Track first item index for prepending (Virtuoso pattern) + const [firstItemIndex, setFirstItemIndex] = useState(10000); + + const virtuosoRef = useRef(null); const inputRef = useRef(null); const toggleToolCall = (toolCallId: string) => { @@ -214,26 +220,69 @@ export default function PublicAgentChatPage(): React.ReactElement { } }, [agentId]); - // Fetch messages for conversation + // Fetch messages for conversation (initial load - gets most recent 50) const fetchMessages = useCallback(async () => { if (!agent) return; try { - const response = await fetch(`/api/agents/${agent.uid}/conversation/${conversationId}`); + const response = await fetch( + `/api/agents/${agent.uid}/conversation/${conversationId}?limit=50` + ); if (response.status === 404) { // Conversation doesn't exist yet, that's fine + setHasMoreMessages(false); return; } const data = await response.json(); if (data.success) { - setMessages(data.data.messages || []); + const msgs = data.data.messages || []; + setMessages(msgs); + setHasMoreMessages(data.pagination?.hasMore ?? false); + // Reset first item index when loading fresh + setFirstItemIndex(10000); } } catch (err) { console.error('Failed to fetch messages:', err); } }, [agent, conversationId]); + // Load older messages when scrolling up + const loadMoreMessages = useCallback(async () => { + if (!agent || isLoadingMore || !hasMoreMessages || messages.length === 0) return; + + setIsLoadingMore(true); + try { + // Get the timestamp of the oldest message we have + const oldestMessage = messages[0]; + const beforeTimestamp = oldestMessage?.createdAt; + + if (!beforeTimestamp) return; + + const response = await fetch( + `/api/agents/${agent.uid}/conversation/${conversationId}?limit=50&before=${encodeURIComponent(beforeTimestamp)}` + ); + + if (!response.ok) return; + + const data = await response.json(); + + if (data.success) { + const olderMessages = data.data.messages || []; + if (olderMessages.length > 0) { + // Prepend older messages and adjust firstItemIndex + setFirstItemIndex((prev) => prev - olderMessages.length); + setMessages((prev) => [...olderMessages, ...prev]); + } + setHasMoreMessages(data.pagination?.hasMore ?? false); + } + } catch (err) { + console.error('Failed to load more messages:', err); + } finally { + setIsLoadingMore(false); + } + }, [agent, conversationId, isLoadingMore, hasMoreMessages, messages]); + useEffect(() => { const init = async () => { await fetchAgent(); @@ -248,10 +297,6 @@ export default function PublicAgentChatPage(): React.ReactElement { } }, [agent, fetchMessages]); - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }); - const handleSend = async () => { if (!input.trim() || !agent || isSending) return; @@ -441,106 +486,136 @@ export default function PublicAgentChatPage(): React.ReactElement { {/* Chat Area */} -
+
{/* Messages */} -
- {messages.length === 0 && !streamingContent && ( -
-
-
- -
-

Start a conversation

-

- Send a message to start chatting with {agent.name}. -

+ {messages.length === 0 && !streamingContent ? ( +
+
+
+
+

Start a conversation

+

+ Send a message to start chatting with {agent.name}. +

- )} - - {messages.map((message) => { - // Parse tool output safely - const getToolOutput = () => { - if (message.toolResult) return message.toolResult; - try { - return JSON.parse(message.content || '{}'); - } catch { - return { result: message.content }; - } - }; - - return ( -
- {message.role === 'TOOL' ? ( -
- toggleToolCall(message.toolCallId || message.id)} - /> +
+ ) : ( + + hasMoreMessages ? ( +
+ {isLoadingMore ? ( +
+ + Loading older messages... +
+ ) : ( + + )}
- ) : ( + ) : null, + Footer: () => ( +
+ {/* Live tool calls during streaming */} + {toolCalls.length > 0 && ( +
+ {toolCalls.map((tc) => ( +
+
+ toggleToolCall(tc.toolCallId)} + /> +
+
+ ))} +
+ )} + + {streamingContent && ( +
+
+

{streamingContent}

+ +
+
+ )} + + {isSending && !streamingContent && toolCalls.length === 0 && ( +
+
+
+ + Thinking... +
+
+
+ )} +
+ ), + }} + itemContent={(_index, message) => { + // Parse tool output safely + const getToolOutput = () => { + if (message.toolResult) return message.toolResult; + try { + return JSON.parse(message.content || '{}'); + } catch { + return { result: message.content }; + } + }; + + return ( +
-

{message.content}

-
- )} -
- ); - })} - - {/* Live tool calls during streaming */} - {toolCalls.length > 0 && ( -
- {toolCalls.map((tc) => ( -
-
- toggleToolCall(tc.toolCallId)} - /> + {message.role === 'TOOL' ? ( +
+ toggleToolCall(message.toolCallId || message.id)} + /> +
+ ) : ( +
+

{message.content}

+
+ )}
- ))} -
- )} - - {streamingContent && ( -
-
-

{streamingContent}

- -
-
- )} - - {isSending && !streamingContent && toolCalls.length === 0 && ( -
-
-
- - Thinking... -
-
-
- )} - -
-
+ ); + }} + /> + )} {/* Error Message */} {error && ( diff --git a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts index 5b6dbd0..8208607 100644 --- a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts +++ b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts @@ -396,14 +396,20 @@ export async function POST(request: NextRequest, context: RouteContext): Promise * * Query params: * - limit: Max messages to return (default: 50, max: 100) - * - offset: Number of messages to skip (default: 0) + * - before: Fetch messages created before this ISO timestamp (for loading older messages) + * - after: Fetch messages created after this ISO timestamp (for loading newer messages) + * + * Default behavior (no before/after): Returns the most recent messages + * With before: Returns messages older than the timestamp (for scrolling up) + * With after: Returns messages newer than the timestamp (for refreshing) */ export async function GET(request: NextRequest, context: RouteContext): Promise { const { id: idOrUid, conversationId } = await context.params; const { searchParams } = new URL(request.url); const limit = Math.min(Number.parseInt(searchParams.get('limit') || '50', 10), 100); - const offset = Number.parseInt(searchParams.get('offset') || '0', 10); + const before = searchParams.get('before'); + const after = searchParams.get('after'); try { // Fetch agent by id or uid @@ -435,16 +441,37 @@ export async function GET(request: NextRequest, context: RouteContext): Promise< ); } - // Fetch messages with pagination + // Build the where clause based on cursor + const whereClause: { + conversationId: string; + createdAt?: { lt?: Date; gt?: Date }; + } = { conversationId: conversation.id }; + + if (before) { + whereClause.createdAt = { lt: new Date(before) }; + } else if (after) { + whereClause.createdAt = { gt: new Date(after) }; + } + + // Determine fetch order: + // - Default (no cursor) or "before": Fetch desc (newest first), then reverse for chronological order + // - "after": Fetch asc (oldest first) to get messages after the cursor + const shouldFetchDesc = !after; + + // Fetch messages const messages = await prisma.message.findMany({ - where: { conversationId: conversation.id }, - orderBy: { createdAt: 'asc' }, + where: whereClause, + orderBy: { createdAt: shouldFetchDesc ? 'desc' : 'asc' }, take: limit + 1, - skip: offset, }); const hasMore = messages.length > limit; - const paginatedMessages = hasMore ? messages.slice(0, limit) : messages; + let paginatedMessages = hasMore ? messages.slice(0, limit) : messages; + + // Reverse if we fetched in desc order to maintain chronological order + if (shouldFetchDesc) { + paginatedMessages = paginatedMessages.reverse(); + } const mappedMessages = paginatedMessages.map((m) => ({ id: m.id, @@ -471,8 +498,9 @@ export async function GET(request: NextRequest, context: RouteContext): Promise< }, pagination: { limit, - offset, hasMore, + ...(before && { before }), + ...(after && { after }), }, }); } catch (error) {