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 930e27df5 fix(ai): attach only the run of the conversation on screen
(#4696)
930e27df5 is described below
commit 930e27df58df11bcd36193f9d6e73ad12065664f
Author: Apulupie <[email protected]>
AuthorDate: Mon Sep 21 21:09:23 2026 +0800
fix(ai): attach only the run of the conversation on screen (#4696)
`useConversationTimeline` held the active run in a bare `activeRun` state,
written only once the timeline request resolved. Switching conversations re-ran
`refetch`, but until it returned `activeRun` still carried the previous
conversation's run, while `useActiveRunAttach`'s reset effect — declared first
so it runs in the same commit — had already cleared `requestedRunRef` for the
new id. The attach effect saw a new `conversationId`, a stale `activeRun` and a
null `requestedRunRef`, p [...]
The state now stores the conversation id alongside the run and `activeRun`
is derived from it, null unless the loaded pair belongs to the conversation
being rendered; all three write points carry the id. `useActiveRunAttach`
already early-returns on a null `activeRun`, so an honest null is what it
needs, and one of the two new tests wires the real hooks together to pin the
handoff itself.
Fixes #4697
---
web/src/pages/ai/hooks/useActiveRunAttach.test.ts | 77 ++++++++++++++++++++++
.../pages/ai/hooks/useConversationTimeline.test.ts | 27 ++++++++
web/src/pages/ai/hooks/useConversationTimeline.ts | 33 ++++++++--
3 files changed, 132 insertions(+), 5 deletions(-)
diff --git a/web/src/pages/ai/hooks/useActiveRunAttach.test.ts
b/web/src/pages/ai/hooks/useActiveRunAttach.test.ts
new file mode 100644
index 000000000..6e4298831
--- /dev/null
+++ b/web/src/pages/ai/hooks/useActiveRunAttach.test.ts
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import type { AiTimelineVO } from '../../../api/aiEvents';
+import { useActiveRunAttach } from './useActiveRunAttach';
+import { useConversationTimeline } from './useConversationTimeline';
+
+vi.mock('../../../api/aiConversations', () => ({
+ getConversationTimeline: vi.fn(),
+}));
+
+import { getConversationTimeline } from '../../../api/aiConversations';
+
+/**
+ * `useActiveRunAttach` decides what to re-attach; `useConversationTimeline`
is what it trusts to say
+ * which run belongs to the conversation on screen. They are exercised
together because the property
+ * is the handover between them: an attach is addressed by run id alone, so
one made while the user
+ * switches conversation streams the previous conversation's frames into the
new transcript.
+ */
+
+const timelineMock = vi.mocked(getConversationTimeline);
+
+function page(activeRun: AiTimelineVO['activeRun']): AiTimelineVO {
+ return { items: [], nextAfter: null, activeRun };
+}
+
+describe('useActiveRunAttach', () => {
+ it('attachesOnlyTheRunOfTheConversationOnScreenTest', async () => {
+ let resolveSecond: (value: AiTimelineVO) => void = () => {};
+ timelineMock.mockImplementation((id) =>
+ id === 7
+ ? Promise.resolve(page({ id: 41, status: 'RUNNING' }))
+ : new Promise<AiTimelineVO>((resolve) => {
+ resolveSecond = resolve;
+ }),
+ );
+ const attach = vi.fn().mockResolvedValue(undefined);
+
+ const { rerender } = renderHook(
+ ({ id }: { id: number | null }) =>
+ useActiveRunAttach(id, useConversationTimeline(id), { isStreaming:
false, attach }),
+ { initialProps: { id: 7 } },
+ );
+
+ await waitFor(() => expect(attach).toHaveBeenCalledWith(7, 41, 0));
+
+ // The user switches conversation and the new timeline has not answered
yet, so the last thing the
+ // server said is still conversation 7's run.
+ rerender({ id: 9 });
+
+ expect(attach).toHaveBeenCalledTimes(1);
+
+ // Once conversation 9's own timeline lands, its run is the one that gets
attached.
+ await act(async () => {
+ resolveSecond(page({ id: 77, status: 'RUNNING' }));
+ });
+
+ await waitFor(() => expect(attach).toHaveBeenCalledWith(9, 77, 0));
+ expect(attach).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/web/src/pages/ai/hooks/useConversationTimeline.test.ts
b/web/src/pages/ai/hooks/useConversationTimeline.test.ts
index 7133bda8a..c43dd09e0 100644
--- a/web/src/pages/ai/hooks/useConversationTimeline.test.ts
+++ b/web/src/pages/ai/hooks/useConversationTimeline.test.ts
@@ -213,4 +213,31 @@ describe('useConversationTimeline', () => {
expect(result.current.bubbles).toEqual([]);
expect(result.current.lastSeq).toBe(0);
});
+
+ it('doesNotReportThePreviousConversationsActiveRunTest', async () => {
+ let resolveSecond: (value: AiTimelineVO) => void = () => {};
+ timelineMock.mockImplementation((id) =>
+ id === 7
+ ? Promise.resolve(page([], null, { id: 41, status: 'RUNNING' }))
+ : new Promise<AiTimelineVO>((resolve) => {
+ resolveSecond = resolve;
+ }),
+ );
+
+ const { result, rerender } = render(7);
+
+ await waitFor(() => expect(result.current.activeRun).toEqual({ id: 41,
status: 'RUNNING' }));
+
+ // The refetch is asynchronous, so for a commit the last successful load
is still conversation 7's.
+ // Reporting it as the run of conversation 9 is what let a switch attach
the previous
+ // conversation's run, whose frames then rendered under the wrong
transcript.
+ rerender({ id: 9 });
+ expect(result.current.activeRun).toBeNull();
+
+ await act(async () => {
+ resolveSecond(page([], null, { id: 77, status: 'RUNNING' }));
+ });
+
+ await waitFor(() => expect(result.current.activeRun).toEqual({ id: 77,
status: 'RUNNING' }));
+ });
});
diff --git a/web/src/pages/ai/hooks/useConversationTimeline.ts
b/web/src/pages/ai/hooks/useConversationTimeline.ts
index b0de6e306..bc900fd6a 100644
--- a/web/src/pages/ai/hooks/useConversationTimeline.ts
+++ b/web/src/pages/ai/hooks/useConversationTimeline.ts
@@ -56,12 +56,29 @@ export interface UseConversationTimelineOptions {
maxPages?: number;
}
+/**
+ * The run the server reported, paired with the conversation it was reported
for.
+ *
+ * The pairing is the point. `refetch` is asynchronous, so after the user
switches conversation the
+ * state below still holds the previous one's run for at least a commit — and
a run id is the only
+ * thing the attach endpoint needs, so acting on it would stream the previous
conversation's frames
+ * into the transcript on screen. See {@link
UseConversationTimelineResult.activeRun}.
+ */
+interface LoadedActiveRun {
+ conversationId: number;
+ run: AiActiveRunRef | null;
+}
+
export interface UseConversationTimelineResult {
/** Persisted rows in `seq` order, exactly as the server returned them. */
items: TimelineItem[];
/** The same rows folded into transcript bubbles — what the thread renders.
*/
bubbles: Bubble[];
- /** The run still streaming, so a reload can re-attach instead of showing a
dead transcript. */
+ /**
+ * The run still streaming in this conversation, so a reload can re-attach
instead of showing a
+ * dead transcript. Null while that conversation's timeline has not loaded
yet, even if the
+ * previous conversation's run is still the last thing the server reported.
+ */
activeRun: AiActiveRunRef | null;
/** Highest `seq` held; pass it to `attachRunStream` so a re-attach does not
replay anything twice. */
lastSeq: number;
@@ -83,7 +100,7 @@ export function useConversationTimeline(
const maxPages = options.maxPages ?? TIMELINE_MAX_PAGES;
const [items, setItems] = useState<TimelineItem[]>([]);
- const [activeRun, setActiveRun] = useState<AiActiveRunRef | null>(null);
+ const [loadedActiveRun, setLoadedActiveRun] = useState<LoadedActiveRun |
null>(null);
const [nextAfter, setNextAfter] = useState<number | null>(null);
const [runSpeeds, setRunSpeeds] = useState<Map<number, number>>(new Map());
const [loading, setLoading] = useState(false);
@@ -100,7 +117,7 @@ export function useConversationTimeline(
const refetch = useCallback(async (): Promise<void> => {
if (conversationId === null) {
setItems([]);
- setActiveRun(null);
+ setLoadedActiveRun(null);
setNextAfter(null);
setError('');
setLoading(false);
@@ -129,7 +146,7 @@ export function useConversationTimeline(
}
setItems(collected);
- setActiveRun(run);
+ setLoadedActiveRun({ conversationId, run });
setNextAfter(cursor);
setRunSpeeds(speeds);
} catch (loadError) {
@@ -152,7 +169,7 @@ export function useConversationTimeline(
const result = await getConversationTimeline(conversationId, { after,
limit });
if (id !== requestId.current) return;
setItems((previous) => previous.concat(result.items));
- setActiveRun(result.activeRun);
+ setLoadedActiveRun({ conversationId, run: result.activeRun });
setNextAfter(result.nextAfter);
setRunSpeeds((previous) => {
const merged = new Map(previous);
@@ -179,6 +196,12 @@ export function useConversationTimeline(
const bubbles = useMemo(() => groupIntoBubbles(items, runSpeeds), [items,
runSpeeds]);
const lastSeq = items.length ? items[items.length - 1].seq : 0;
+ // Only the run of the conversation on screen: a run loaded for another one
is not this
+ // conversation's to attach, and it is not this conversation's to render
either.
+ const activeRun =
+ loadedActiveRun !== null && loadedActiveRun.conversationId ===
conversationId
+ ? loadedActiveRun.run
+ : null;
return {
items,