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,

Reply via email to