Merge pull request #1099 from titosemi/fix/chat-load-latest-messages

fix(dashboard): load latest messages first; fix scroll-to-bottom and pagination
This commit is contained in:
gsxdsm
2026-05-27 14:35:59 -07:00
committed by GitHub
12 changed files with 192 additions and 41 deletions

View File

@@ -671,6 +671,40 @@ describe("ChatStore", () => {
const messages = store.getMessages("chat-nonexistent");
expect(messages).toHaveLength(0);
});
it("returns messages newest-first when order=desc", () => {
startFakeClock();
const session = createTestSession(store);
const m1 = store.addMessage(session.id, { role: "user", content: "First" });
advanceClock(5);
const m2 = store.addMessage(session.id, { role: "assistant", content: "Second" });
advanceClock(5);
const m3 = store.addMessage(session.id, { role: "user", content: "Third" });
const messages = store.getMessages(session.id, { order: "desc" });
expect(messages).toHaveLength(3);
expect(messages[0].id).toBe(m3.id);
expect(messages[1].id).toBe(m2.id);
expect(messages[2].id).toBe(m1.id);
});
it("combines before cursor with order=desc", () => {
startFakeClock();
const session = createTestSession(store);
const m1 = store.addMessage(session.id, { role: "user", content: "First" });
advanceClock(5);
const m2 = store.addMessage(session.id, { role: "assistant", content: "Second" });
advanceClock(5);
const m3 = store.addMessage(session.id, { role: "user", content: "Third" });
// before=m3.createdAt with desc → returns messages before m3, newest first
const messages = store.getMessages(session.id, { before: m3.createdAt, order: "desc" });
expect(messages).toHaveLength(2);
expect(messages[0].id).toBe(m2.id);
expect(messages[1].id).toBe(m1.id);
});
});
describe("getMessage", () => {

View File

@@ -580,7 +580,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
*
* @param sessionId - Session ID
* @param filter - Optional filter (limit, offset, before cursor)
* @returns Array of messages ordered by createdAt ASC
* @returns Array of messages ordered by createdAt ASC (default) or DESC
*/
getMessages(sessionId: string, filter?: ChatMessagesFilter): ChatMessage[] {
const whereClauses: string[] = ["sessionId = ?"];
@@ -595,11 +595,12 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
const whereSql = whereClauses.join(" AND ");
const limit = filter?.limit ?? 100;
const offset = filter?.offset ?? 0;
const order = filter?.order === "desc" ? "DESC" : "ASC";
const rows = this.db.prepare(`
SELECT * FROM chat_messages
WHERE ${whereSql}
ORDER BY createdAt ASC
ORDER BY createdAt ${order}
LIMIT ? OFFSET ?
`).all(...params, limit, offset);

View File

@@ -191,6 +191,8 @@ export interface ChatMessagesFilter {
* Used for loading older messages in a conversation.
*/
before?: string;
/** Sort order: 'asc' (oldest first, default) or 'desc' (newest first) */
order?: "asc" | "desc";
}
// ── Room Chat Types ──────────────────────────────────────────────────

View File

@@ -8735,13 +8735,14 @@ export function deleteChatSession(id: string, projectId?: string): Promise<{ suc
/** Fetch messages for a chat session */
export function fetchChatMessages(
sessionId: string,
opts?: { limit?: number; offset?: number; before?: string },
opts?: { limit?: number; offset?: number; before?: string; order?: "asc" | "desc" },
projectId?: string,
): Promise<ChatMessageListResponse> {
const search = new URLSearchParams();
if (opts?.limit !== undefined) search.set("limit", String(opts.limit));
if (opts?.offset !== undefined) search.set("offset", String(opts.offset));
if (opts?.before) search.set("before", opts.before);
if (opts?.order) search.set("order", opts.order);
const qs = search.toString();
return api<ChatMessageListResponse>(
withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ""}`, projectId),

View File

@@ -1238,7 +1238,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
useEffect(() => {
const sentinel = loadMoreSentinelRef.current;
if (!sentinel || !hasMoreMessages) return;
if (!sentinel || !hasMoreMessages || messagesLoading) return;
const observer = new IntersectionObserver(
(entries) => {
@@ -1250,7 +1250,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMoreMessages, loadMoreMessages]);
}, [hasMoreMessages, messagesLoading, loadMoreMessages]);
const getActiveThreadId = useCallback(() => {
return roomThreadActive ? (rooms.activeRoom?.id ?? null) : (activeSession?.id ?? null);
@@ -1379,6 +1379,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
logScrollDebug(cause);
const messagesContainer = messagesContainerRef.current;
if (!messagesContainer) return;
// Cancel any pending scroll restoration so it doesn't override the explicit jump-to-bottom.
scrollRestoreSnapshotRef.current = null;
isUserScrollingRef.current = false;
anchorToBottom(messagesContainer);
}, [anchorToBottom, logScrollDebug]);

View File

@@ -286,7 +286,7 @@ describe("QuickChatFAB session-first UX", () => {
fireEvent.click(trigger);
fireEvent.click(screen.getByTestId("quick-chat-session-option-session-model"));
await waitFor(() => {
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-model", { limit: 50 }, "proj-1");
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-model", { limit: 50, order: "desc" }, "proj-1");
});
});
@@ -897,7 +897,7 @@ describe("QuickChatFAB session-first UX", () => {
fireEvent.click(screen.getByTestId("quick-chat-session-option-session-agent"));
await waitFor(() => {
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-agent", { limit: 50 }, "proj-1");
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-agent", { limit: 50, order: "desc" }, "proj-1");
});
expect(mockCreateChatSession).not.toHaveBeenCalled();
});

View File

@@ -99,6 +99,7 @@ describe("useChat", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockGetScopedItem.mockReturnValue(undefined);
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
mockFetchChatSession.mockResolvedValue({
session: makeSession({ id: "session-001", agentId: "agent-001" }),
@@ -559,7 +560,7 @@ describe("useChat", () => {
const parsed = JSON.parse(localStorage.getItem(chatMessagesCacheKey(projectId, session.id)) ?? "null") as { data: ChatMessage[] };
expect(parsed.data).toHaveLength(50);
expect(parsed.data[0]?.id).toBe("msg-1");
expect(parsed.data[0]?.id).toBe("msg-50");
});
it("selects a session and loads its messages", async () => {
@@ -583,7 +584,7 @@ describe("useChat", () => {
});
await waitFor(() => {
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50 }, undefined);
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50, order: "desc" }, undefined);
});
await waitFor(() => {
@@ -600,13 +601,13 @@ describe("useChat", () => {
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
// Simulate a conversation with multiple user and assistant messages
// in backend chronological order (oldest first)
// API returns messages newest-first (order=desc); simulate that in the mock
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "First question" }),
makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "First answer" }),
makeMessage({ id: "msg-003", sessionId: "session-001", role: "user", content: "Second question" }),
makeMessage({ id: "msg-004", sessionId: "session-001", role: "assistant", content: "Second answer" }),
makeMessage({ id: "msg-003", sessionId: "session-001", role: "user", content: "Second question" }),
makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "First answer" }),
makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "First question" }),
],
});
@@ -1083,7 +1084,7 @@ describe("useChat", () => {
await waitFor(() => {
expect(result.current.isStreaming).toBe(false);
expect(addToast).not.toHaveBeenCalledWith("Load failed", "error");
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50 }, undefined);
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50, order: "desc" }, undefined);
});
});
@@ -2112,7 +2113,7 @@ describe("useChat", () => {
const secondCall = mockFetchChatMessages.mock.calls[1];
expect(secondCall[0]).toBe("session-001");
expect(secondCall[1]).toHaveProperty("limit");
expect(secondCall[1]).toHaveProperty("offset");
expect(secondCall[1]).toHaveProperty("before");
await waitFor(() => {
expect(result.current.messages).toHaveLength(51);
@@ -2140,6 +2141,52 @@ describe("useChat", () => {
});
});
it("loadMoreMessages callback is stable when messages array changes (no re-create on streaming)", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
// 50 messages so hasMoreMessages=true
const make50 = () =>
Array.from({ length: 50 }, (_, i) =>
makeMessage({ id: `msg-${i}`, sessionId: "session-001", role: "user", content: `m${i}`, createdAt: `2026-04-08T00:00:${String(i).padStart(2, "0")}.000Z` })
);
mockFetchChatMessages.mockResolvedValueOnce({ messages: make50() });
// Minimal streaming mock — returns immediately so sendMessage won't hang
mockStreamChatResponse.mockImplementation(() => ({ close: vi.fn(), isConnected: () => false }));
const { result } = renderHook(() => useChat());
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(50);
expect(result.current.hasMoreMessages).toBe(true);
});
// Capture callback identity before messages change
const loadMoreBefore = result.current.loadMoreMessages;
// sendMessage adds an optimistic user message → new messages array reference
act(() => {
void result.current.sendMessage("hello");
});
await waitFor(() => {
// Optimistic user message was appended
expect(result.current.messages.length).toBeGreaterThan(50);
});
// loadMoreMessages must NOT have been recreated despite messages array changing
expect(result.current.loadMoreMessages).toBe(loadMoreBefore);
});
it("filters sessions by search query", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [
@@ -2665,7 +2712,7 @@ describe("useChat", () => {
// Verify messages were loaded
await waitFor(() => {
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50 }, undefined);
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50, order: "desc" }, undefined);
});
});

View File

@@ -347,7 +347,7 @@ describe("useQuickChat", () => {
await waitFor(() => {
expect(mockCreateChatSession).not.toHaveBeenCalled();
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-existing", { limit: 50 }, "proj-123");
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-existing", { limit: 50, order: "desc" }, "proj-123");
});
});
@@ -378,7 +378,7 @@ describe("useQuickChat", () => {
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-existing", { limit: 50 }, "proj-123");
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-existing", { limit: 50, order: "desc" }, "proj-123");
});
});
@@ -492,7 +492,7 @@ describe("useQuickChat", () => {
"proj-123",
);
expect(result.current.activeSession?.id).toBe("session-fresh");
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-fresh", { limit: 50 }, "proj-123");
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-fresh", { limit: 50, order: "desc" }, "proj-123");
});
});
@@ -923,7 +923,7 @@ describe("useQuickChat", () => {
await waitFor(() => {
expect(mockFetchChatMessages.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-existing", { limit: 50 }, "proj-123");
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-existing", { limit: 50, order: "desc" }, "proj-123");
});
});
@@ -1597,4 +1597,25 @@ describe("useQuickChat", () => {
});
});
});
it("initial load uses order=desc to fetch latest messages first", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchResumeChatSession.mockResolvedValue({ session });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
await waitFor(() => {
expect(mockFetchChatMessages).toHaveBeenCalledWith(
"session-001",
expect.objectContaining({ order: "desc" }),
"proj-123",
);
});
});
});

View File

@@ -283,7 +283,7 @@ export function useChat(
const [searchQuery, setSearchQuery] = useState("");
// Pagination
const [hasMoreMessages, setHasMoreMessages] = useState(true);
const [hasMoreMessages, setHasMoreMessages] = useState(false);
// Agent name resolution map
const { agentsMap } = useAgentsMapCache(projectId);
@@ -437,8 +437,8 @@ export function useChat(
// Load messages when active session changes
const loadMessages = useCallback(
async (sessionId: string, opts?: { offset?: number }) => {
const isPaginationRequest = typeof opts?.offset === "number" && opts.offset > 0;
async (sessionId: string, opts?: { offset?: number; before?: string }) => {
const isPaginationRequest = (typeof opts?.offset === "number" && opts.offset > 0) || typeof opts?.before === "string";
const cacheKey = getChatMessagesCacheKey(projectId, sessionId);
const cachedMessages = !isPaginationRequest ? readCachedMessages(projectId, sessionId) : [];
const hasCachedMessages = cachedMessages.length > 0;
@@ -451,8 +451,9 @@ export function useChat(
}
try {
const data = await fetchChatMessages(sessionId, { limit: 50, ...opts }, projectId);
const mappedMessages = data.messages.map(mapChatMessageToInfo);
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc", ...opts }, projectId);
// API returns newest-first (order=desc); reverse so display is oldest-first.
const mappedMessages = data.messages.slice().reverse().map(mapChatMessageToInfo);
if (isPaginationRequest) {
if (activeSessionRef.current?.id === sessionId) {
setMessages((prev) => [...mappedMessages, ...prev]);
@@ -630,7 +631,7 @@ export function useChat(
// Reset transient state
resetTransientComposerState();
setHasMoreMessages(true);
setHasMoreMessages(false);
// Load messages for this session
if (id) {
@@ -740,11 +741,17 @@ export function useChat(
[activeSession, getChatMessagesCacheKey, projectId],
);
// Load more messages (pagination)
// Load more messages (pagination — use before cursor for oldest displayed message)
// messagesRef is assigned on every render; reading from the ref here avoids
// closing over `messages` and prevents this callback from being recreated on
// every streamed token (which would cause the IntersectionObserver to churn).
const loadMoreMessages = useCallback(async () => {
if (!activeSession || !hasMoreMessages) return;
await loadMessages(activeSession.id, { offset: messages.length });
}, [activeSession, hasMoreMessages, loadMessages, messages.length]);
// messagesRef.current[0] is the oldest visible message; fetch older ones using its createdAt
const cursor = messagesRef.current[0]?.createdAt;
if (!cursor) return;
await loadMessages(activeSession.id, { before: cursor });
}, [activeSession, hasMoreMessages, loadMessages]);
const stopStreaming = useCallback(() => {
if (!activeSession) return;

View File

@@ -330,8 +330,8 @@ export function useQuickChat(
isStreamingRef.current = false;
streamRef.current = null;
lastAttachedGenerationRef.current = null;
void fetchChatMessages(sessionId, { limit: 50 }, projectId).then((data) => {
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.map(mapChatMessageToInfo));
void fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId).then((data) => {
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
}).catch(() => {});
flushPendingMessage();
},
@@ -347,8 +347,8 @@ export function useQuickChat(
if (!options?.silent) {
addToast?.(errorMessage, "error");
}
void fetchChatMessages(sessionId, { limit: 50 }, projectId).then((data) => {
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.map(mapChatMessageToInfo));
void fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId).then((data) => {
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
}).catch(() => {});
flushPendingMessage();
},
@@ -428,8 +428,8 @@ export function useQuickChat(
setMessagesLoading(true);
try {
const sessionId = activeSession.id;
const data = await fetchChatMessages(sessionId, { limit: 50 }, projectId);
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.map(mapChatMessageToInfo));
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId);
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
} catch (err) {
console.error("[useQuickChat] Failed to load messages:", err);
} finally {
@@ -473,8 +473,8 @@ export function useQuickChat(
clearInterval(interval);
// Reload messages to pick up the completed assistant message
const sessionId = activeSession.id;
const data = await fetchChatMessages(sessionId, { limit: 50 }, projectId);
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.map(mapChatMessageToInfo));
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId);
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
@@ -496,8 +496,8 @@ export function useQuickChat(
setMessagesLoading(true);
try {
const sessionId = activeSession.id;
const data = await fetchChatMessages(sessionId, { limit: 50 }, projectId);
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.map(mapChatMessageToInfo));
const data = await fetchChatMessages(sessionId, { limit: 50, order: "desc" }, projectId);
if (activeSessionRef.current?.id === sessionId) setMessages(data.messages.slice().reverse().map(mapChatMessageToInfo));
} catch (err) {
console.error("[useQuickChat] Failed to reload messages:", err);
} finally {

View File

@@ -920,6 +920,35 @@ describe("Chat API Routes", () => {
expect(response.status).toBe(404);
});
it("passes order=desc to getMessages when query param is provided", async () => {
mockGetSession.mockReturnValue(sampleSession);
mockGetMessages.mockReturnValue([sampleMessage]);
const response = await request(
app,
"GET",
"/api/chat/sessions/chat-abc123/messages?order=desc",
);
expect(response.status).toBe(200);
expect(mockGetMessages).toHaveBeenCalledWith("chat-abc123", expect.objectContaining({
order: "desc",
}));
});
it("returns 400 for invalid order value", async () => {
mockGetSession.mockReturnValue(sampleSession);
const response = await request(
app,
"GET",
"/api/chat/sessions/chat-abc123/messages?order=invalid",
);
expect(response.status).toBe(400);
expect((response.body as any).error).toMatch(/order/i);
});
});
describe("POST /api/chat/sessions/:id/cancel", () => {

View File

@@ -320,7 +320,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
/**
* GET /api/chat/sessions/:id/messages
* Get messages for a chat session with pagination.
* Query params: limit? (default 50, max 200), offset? (default 0), before? (ISO timestamp)
* Query params: limit? (default 50, max 200), offset? (default 0), before? (ISO timestamp), order? ('asc'|'desc')
*/
router.get("/chat/sessions/:id/messages", async (req, res) => {
try {
@@ -334,10 +334,11 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
throw notFound(`Chat session ${sessionId} not found`);
}
const { limit: limitStr, offset: offsetStr, before } = req.query as {
const { limit: limitStr, offset: offsetStr, before, order } = req.query as {
limit?: string;
offset?: string;
before?: string;
order?: string;
};
// Validate pagination params
@@ -351,12 +352,17 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
throw badRequest("offset must be a non-negative integer");
}
if (order !== undefined && order !== "asc" && order !== "desc") {
throw badRequest('order must be "asc" or "desc"');
}
const effectiveLimit = Math.min(limit, 200);
const messages = chatStore.getMessages(sessionId, {
limit: effectiveLimit,
offset,
...(before && { before }),
...(order === "desc" || order === "asc" ? { order } : {}),
});
res.json({ messages });