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 29fe48abc fix(ai): surface transcript load failures and count every 
unread batch message (#5793)
29fe48abc is described below

commit 29fe48abc98583cfc77f97188f187cca3ad85dae
Author: Loyal-Young <[email protected]>
AuthorDate: Sat Oct 10 12:08:35 2026 +0800

    fix(ai): surface transcript load failures and count every unread batch 
message (#5793)
    
    Two AI-page defects from the same author.
    
    - #5793 `fix(ai): count all unread messages in transcript batches`
    - #5791 `fix(ai): show transcript load failures with retry`
    
    2 suites, 29 tests green.
    
    Maintainer edit: ran prettier over the two changed test files - the branch 
versions were not prettier-stable (trunk's copies are), so lint-staged would 
have reformatted them on the next contributor's commit.
    
    Folded in #5791 (same author). Merged through #5793.
---
 web/src/i18n/translations.ts                       |  1 +
 web/src/pages/ai/__tests__/AiPage.test.tsx         | 18 ++++++++++
 web/src/pages/ai/components/ChatThread.tsx         |  2 +-
 .../ai/components/__tests__/ChatThread.test.tsx    | 39 +++++++++++++++++++---
 web/src/pages/ai/hooks/useConversationTimeline.ts  | 12 +++----
 web/src/pages/ai/index.tsx                         | 21 ++++++++++--
 6 files changed, 80 insertions(+), 13 deletions(-)

diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 08d7f4161..b9a607189 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1706,6 +1706,7 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'ai.list.search': { zh: '搜索会话标题', en: 'Search conversation titles' },
   'ai.list.empty': { zh: '暂无会话', en: 'No conversations' },
   'ai.list.loadFailed': { zh: '会话列表加载失败', en: 'Failed to load conversations' },
+  'ai.timeline.loadFailed': { zh: '会话记录加载失败', en: 'Failed to load conversation 
history' },
   'ai.list.columnTitle': { zh: '会话', en: 'Conversation' },
   'ai.list.columnEngine': { zh: '引擎', en: 'Engine' },
   'ai.list.columnModel': { zh: '模型', en: 'Model' },
diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx 
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 1a963570c..90949dc71 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -255,6 +255,24 @@ describe('AiPage', () => {
     vi.mocked(listTools).mockResolvedValue([]);
   });
 
+  it.each([new Error('timeline unavailable'), {}])(
+    'offers recovery for a failed timeline load: %s',
+    async (failure) => {
+      vi.mocked(getConversationTimeline).mockRejectedValueOnce(failure);
+      renderRouted('/ai/c/7');
+      const alert = await screen.findByRole('alert');
+      expect(alert).toHaveTextContent('会话记录加载失败');
+      
expect(screen.queryByTestId('ai-welcome-starters')).not.toBeInTheDocument();
+      vi.mocked(getConversationTimeline).mockResolvedValue(
+        timelinePage([item(1, { type: 'user', text: 'recovered transcript' 
})]),
+      );
+      await userEvent.setup().click(within(alert).getByRole('button', { name: 
/重\s*试/ }));
+      expect(await screen.findByText('recovered 
transcript')).toBeInTheDocument();
+      expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+      expect(getConversationTimeline).toHaveBeenLastCalledWith(7, { after: 0, 
limit: 200 });
+    },
+  );
+
   it('rendersThePersistedTimelineOnAColdLoadTest', async () => {
     vi.mocked(getConversationTimeline).mockResolvedValue(
       timelinePage([
diff --git a/web/src/pages/ai/components/ChatThread.tsx 
b/web/src/pages/ai/components/ChatThread.tsx
index 40cecda1e..8d8185f75 100644
--- a/web/src/pages/ai/components/ChatThread.tsx
+++ b/web/src/pages/ai/components/ChatThread.tsx
@@ -167,7 +167,7 @@ const ChatThread = ({
     const previous = prevBubbleCountRef.current;
     prevBubbleCountRef.current = bubbleCount;
     if (atBottomRef.current || bubbleCount <= previous) return;
-    setUnread((count) => count + 1);
+    setUnread((count) => count + bubbleCount - previous);
   }, [bubbleCount]);
 
   return (
diff --git a/web/src/pages/ai/components/__tests__/ChatThread.test.tsx 
b/web/src/pages/ai/components/__tests__/ChatThread.test.tsx
index 3095f8cec..74abd1aa4 100644
--- a/web/src/pages/ai/components/__tests__/ChatThread.test.tsx
+++ b/web/src/pages/ai/components/__tests__/ChatThread.test.tsx
@@ -121,7 +121,11 @@ describe('ChatThread', () => {
     placeReader(0);
     rerender(
       <LangProvider>
-        <ChatThread bubbles={[userBubble('检查集群状态')]} 
liveBlocks={appendText([], '部分')} streaming />
+        <ChatThread
+          bubbles={[userBubble('检查集群状态')]}
+          liveBlocks={appendText([], '部分')}
+          streaming
+        />
       </LangProvider>,
     );
     scrollIntoView.mockClear();
@@ -172,9 +176,7 @@ describe('ChatThread', () => {
     // The pending question sits ahead of the streaming answer.
     const pending = screen.getByText('第二个问题');
     const answer = screen.getByText('正在回答');
-    expect(
-      pending.compareDocumentPosition(answer) & 
Node.DOCUMENT_POSITION_FOLLOWING,
-    ).toBeTruthy();
+    expect(pending.compareDocumentPosition(answer) & 
Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
   });
 
   it('stopsFollowingAndOffersAJumpOnceTheReaderScrollsUpTest', () => {
@@ -239,6 +241,35 @@ describe('ChatThread', () => {
     expect(screen.getByTestId('ai-thread-unread')).toHaveTextContent('2');
   });
 
+  it('counts every new message in a batch without counting updates or 
removals', () => {
+    const initial = [userBubble('first question')];
+    const { rerender } = renderThread({ bubbles: initial });
+    placeReader(600);
+    fireEvent.scroll(screen.getByTestId('ai-thread-scroll'));
+    scrollIntoView.mockClear();
+    const update = (bubbles: Bubble[]) =>
+      rerender(
+        <LangProvider>
+          <ChatThread bubbles={bubbles} />
+        </LangProvider>,
+      );
+    const batch = [
+      ...initial,
+      assistantBubble('first answer'),
+      userBubble('next question'),
+      assistantBubble('next answer'),
+    ];
+    update(batch);
+    expect(screen.getByTestId('ai-thread-unread')).toHaveTextContent('3');
+    update([...batch.slice(0, -1), assistantBubble('updated next answer')]);
+    expect(screen.getByTestId('ai-thread-unread')).toHaveTextContent('3');
+    update(initial);
+    expect(screen.getByTestId('ai-thread-unread')).toHaveTextContent('3');
+    update([...initial, userBubble('another question'), 
assistantBubble('another answer')]);
+    expect(screen.getByTestId('ai-thread-unread')).toHaveTextContent('5');
+    expect(scrollIntoView).not.toHaveBeenCalled();
+  });
+
   it('jumpsBackToTheLatestAndClearsTheBadgeTest', async () => {
     const user = userEvent.setup();
     renderThread({ bubbles: [userBubble('检查集群状态')] });
diff --git a/web/src/pages/ai/hooks/useConversationTimeline.ts 
b/web/src/pages/ai/hooks/useConversationTimeline.ts
index b119fa786..f862b37d1 100644
--- a/web/src/pages/ai/hooks/useConversationTimeline.ts
+++ b/web/src/pages/ai/hooks/useConversationTimeline.ts
@@ -86,8 +86,8 @@ export interface UseConversationTimelineResult {
   /** Highest `seq` held; pass it to `attachRunStream` so a re-attach does not 
replay anything twice. */
   lastSeq: number;
   loading: boolean;
-  /** Server-supplied message, or `''`; the caller pairs it with an i18n 
fallback. */
-  error: string;
+  /** Null on success; an empty failure message uses the caller's i18n 
fallback. */
+  error: string | null;
   /** True when the bounded forward walk stopped before the tail; `loadMore` 
continues it. */
   hasMore: boolean;
   /** Reload the whole transcript from `seq > 0`; reject on failure so live 
blocks are retained. */
@@ -107,7 +107,7 @@ export function useConversationTimeline(
   const [nextAfter, setNextAfter] = useState<number | null>(null);
   const [runSpeeds, setRunSpeeds] = useState<Map<number, number>>(new Map());
   const [loading, setLoading] = useState(false);
-  const [error, setError] = useState('');
+  const [error, setError] = useState<string | null>(null);
   const hasCurrentSnapshot = loadedActiveRun?.conversationId === 
conversationId;
   const refetchRequestId = useRef(0);
   const activeRefetchRef = useRef<number | null>(null);
@@ -129,7 +129,7 @@ export function useConversationTimeline(
       setItems([]);
       setLoadedActiveRun(null);
       setNextAfter(null);
-      setError('');
+      setError(null);
       setLoading(false);
       return;
     }
@@ -140,7 +140,7 @@ export function useConversationTimeline(
     loadMoreRequestId.current += 1;
     loadingMoreRef.current = null;
     setLoading(true);
-    setError('');
+    setError(null);
     try {
       let collected: TimelineItem[] = [];
       let after = 0;
@@ -193,7 +193,7 @@ export function useConversationTimeline(
     const refetchId = refetchRequestId.current;
     loadingMoreRef.current = id;
     setLoading(true);
-    setError('');
+    setError(null);
     try {
       const result = await getConversationTimeline(conversationId, { after, 
limit });
       if (id !== loadMoreRequestId.current || refetchId !== 
refetchRequestId.current) return;
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 93252f5e4..4c3030e7a 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -17,7 +17,7 @@
 
 import { useCallback, useMemo, useRef, useState } from 'react';
 import { useNavigate, useParams } from 'react-router-dom';
-import { Flex, message, theme } from 'antd';
+import { Alert, Button, Flex, message, theme } from 'antd';
 import { useLang } from '../../i18n/LangContext';
 import type { McpTool } from '../../api/ai';
 import useAuthStore from '../../stores/authStore';
@@ -160,7 +160,7 @@ const AiPage = () => {
         empty={
           // Suppressed while the transcript is loading: a welcome panel that 
flashes for one
           // instant before the persisted bubbles arrive is the other half of 
the refresh jitter.
-          timeline.loading ? null : (
+          timeline.loading || timeline.error !== null ? null : (
             <WelcomeStarters
               onPick={(prompt, mode) => {
                 setInputValue(prompt);
@@ -175,6 +175,23 @@ const AiPage = () => {
         resetKey={conversationId}
         footer={
           <>
+            {timeline.error !== null && (
+              <Alert
+                type="error"
+                showIcon
+                message={t('ai.timeline.loadFailed')}
+                description={timeline.error || undefined}
+                action={
+                  <Button
+                    size="small"
+                    loading={timeline.loading}
+                    onClick={() => void timeline.refetch().catch(() => 
undefined)}
+                  >
+                    {t('common.retry')}
+                  </Button>
+                }
+              />
+            )}
             <RuntimeNotices mock={useMock} rmqctlAvailable={rmqctlAvailable} 
runError={run.error} />
             <Composer
               value={inputValue}

Reply via email to