This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 04974c803 fix(ai): clamp the conversation page after the list shrinks
(#5207)
04974c803 is described below
commit 04974c8031aeaa44f5fce6c433bff2cee7032ee5
Author: 健康宝 <[email protected]>
AuthorDate: Fri Oct 9 11:24:28 2026 +0800
fix(ai): clamp the conversation page after the list shrinks (#5207)
`useConversationList` was the only server-paginated list hook without page
clamping: after
archiving or deleting the last row of the final page, `page` stayed past
the new last page and the
UI showed an empty table with a pager pointing at a page that no longer
exists. Clamp to
`ceil(total / pageSize)` with a floor of 1 and let the page effect
re-query; `page` only decreases
here, so the clamp cannot loop. Same convention as the existing clamp in
`alerts.tsx`.
---
web/src/pages/ai/hooks/useConversationList.test.ts | 66 ++++++++++++++++++++--
web/src/pages/ai/hooks/useConversationList.ts | 10 ++++
2 files changed, 71 insertions(+), 5 deletions(-)
diff --git a/web/src/pages/ai/hooks/useConversationList.test.ts
b/web/src/pages/ai/hooks/useConversationList.test.ts
index 40a6c8ff2..750a7b3ee 100644
--- a/web/src/pages/ai/hooks/useConversationList.test.ts
+++ b/web/src/pages/ai/hooks/useConversationList.test.ts
@@ -27,9 +27,10 @@ vi.mock('../../../api/aiConversations', () => ({
import { listConversations, type PageResult } from
'../../../api/aiConversations';
/**
- * Server-paged history: the two behaviours a rewrite most easily loses are
the requestId guard (a
- * slow response for an older query overwriting a newer one) and the "any
filter change jumps back to
- * page 1" rule, because page 7 of a different search does not exist.
+ * Server-paged history: the behaviours a rewrite most easily loses are the
requestId guard (a
+ * slow response for an older query overwriting a newer one) and the "any
filter change jumps back
+ * to page 1" rule, because page 7 of a different search does not exist. Also
covered: clamping the
+ * page when a shrinking server total leaves it past the last page.
*/
const listMock = vi.mocked(listConversations);
@@ -80,7 +81,8 @@ describe('useConversationList', () => {
});
it('forwardsPagingSearchAndTheArchivedFilterTest', async () => {
- listMock.mockResolvedValue(resultPage([], 0, 1));
+ // total must cover page 3, otherwise the hook clamps the page back to the
last valid one.
+ listMock.mockResolvedValue(resultPage([], 45, 3));
const { result } = renderHook(() => useConversationList(10));
await waitFor(() => expect(result.current.loading).toBe(false));
@@ -102,7 +104,8 @@ describe('useConversationList', () => {
});
it('jumpsBackToTheFirstPageWhenAFilterChangesTest', async () => {
- listMock.mockResolvedValue(resultPage([], 0, 1));
+ // total must cover pages 4 and 5, otherwise the hook clamps the page back
to page 1.
+ listMock.mockResolvedValue(resultPage([], 100, 1));
const { result } = renderHook(() => useConversationList());
await waitFor(() => expect(result.current.loading).toBe(false));
@@ -165,6 +168,59 @@ describe('useConversationList', () => {
});
});
+ it('clampsThePageWhenTheServerTotalShrinksTest', async () => {
+ const firstPage = Array.from({ length: 15 }, (_, i) => listItem(i + 1, `会话
${i + 1}`));
+ listMock
+ .mockResolvedValueOnce(resultPage(firstPage, 16, 1))
+ .mockResolvedValueOnce(resultPage([], 1, 2)) // page 2 no longer exists
after the shrink
+ .mockResolvedValueOnce(resultPage([listItem(1, '仅剩一条')], 1, 1));
+
+ const { result } = renderHook(() => useConversationList());
+ await waitFor(() => expect(result.current.items).toHaveLength(15));
+
+ await act(async () => {
+ result.current.setPage(2);
+ });
+
+ // The out-of-range page is discarded and the last valid page is
re-queried.
+ await waitFor(() => expect(listMock).toHaveBeenCalledTimes(3));
+ expect(listMock).toHaveBeenLastCalledWith({
+ page: 1,
+ size: 15,
+ search: undefined,
+ archived: false,
+ });
+ expect(result.current.page).toBe(1);
+ expect(result.current.total).toBe(1);
+ expect(result.current.items.map((row) => row.id)).toEqual([1]);
+ });
+
+ it('fallsBackToTheFirstPageWhenTheListBecomesEmptyTest', async () => {
+ const firstPage = Array.from({ length: 15 }, (_, i) => listItem(i + 1, `会话
${i + 1}`));
+ listMock
+ .mockResolvedValueOnce(resultPage(firstPage, 16, 1))
+ .mockResolvedValueOnce(resultPage([], 0, 2))
+ .mockResolvedValueOnce(resultPage([], 0, 1));
+
+ const { result } = renderHook(() => useConversationList());
+ await waitFor(() => expect(result.current.items).toHaveLength(15));
+
+ await act(async () => {
+ result.current.setPage(2);
+ });
+
+ await waitFor(() => expect(listMock).toHaveBeenCalledTimes(3));
+ expect(listMock).toHaveBeenLastCalledWith({
+ page: 1,
+ size: 15,
+ search: undefined,
+ archived: false,
+ });
+ expect(result.current.page).toBe(1);
+ expect(result.current.total).toBe(0);
+ expect(result.current.items).toEqual([]);
+ });
+
it('reportsTheServerMessageAndEmptiesTheListOnFailureTest', async () => {
listMock
.mockResolvedValueOnce(resultPage([listItem(7, '查看集群状态')], 1, 1))
diff --git a/web/src/pages/ai/hooks/useConversationList.ts
b/web/src/pages/ai/hooks/useConversationList.ts
index d6300b150..4783bc4f6 100644
--- a/web/src/pages/ai/hooks/useConversationList.ts
+++ b/web/src/pages/ai/hooks/useConversationList.ts
@@ -85,6 +85,16 @@ export function useConversationList(
archived,
});
if (id !== requestId.current) return;
+ // The server total can shrink between queries (archiving or deleting
here or in another
+ // client), leaving `page` past the new last page: an empty list with a
pager pointing at a
+ // page that no longer exists. Clamp to the new last page and let the
page effect re-query;
+ // `page` only decreases, so this cannot loop. total = 0 falls back to
page 1.
+ const maxPage = Math.max(1, Math.ceil(result.total / pageSize));
+ if (page > maxPage) {
+ setTotal(result.total);
+ setPage(maxPage);
+ return;
+ }
setItems(result.items);
setTotal(result.total);
} catch (loadError) {