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:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user