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) {

Reply via email to