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}