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 cc9a7cd36 fix(ai): close three run-lifecycle leaks around unmount, 
conversation switch and clipboard (#5782)
cc9a7cd36 is described below

commit cc9a7cd36c0bcd449b12812b728fa8544cb2169e
Author: Chstercc <[email protected]>
AuthorDate: Sat Oct 10 12:01:52 2026 +0800

    fix(ai): close three run-lifecycle leaks around unmount, conversation 
switch and clipboard (#5782)
    
    Three independent AI-page defects from the same author, in the same 
hook/component family.
    
    - #5782 `fix(ai): invalidate pending runtime loads on unmount`
    - #5786 `fix(ai): scope timeline snapshots to the current conversation`
    - #5799 `fix(web): recover from assistant clipboard write failures`
    
    4 suites, 67 tests green; prettier and eslint clean.
    
    Folded in #5786, #5799 (same author). Merged through #5782.
---
 web/src/pages/ai/__tests__/AiPage.test.tsx         |  48 ++++++-
 web/src/pages/ai/components/AssistantBubble.tsx    |  26 ++--
 .../components/__tests__/AssistantBubble.test.tsx  | 127 +++++++++++++++++-
 .../pages/ai/hooks/useConversationTimeline.test.ts | 145 ++++++++++++++++++++-
 web/src/pages/ai/hooks/useConversationTimeline.ts  |  26 +++-
 web/src/pages/ai/hooks/useLlmRuntime.test.ts       |  78 ++++++++++-
 web/src/pages/ai/hooks/useLlmRuntime.ts            |   7 +-
 7 files changed, 431 insertions(+), 26 deletions(-)

diff --git a/web/src/pages/ai/__tests__/AiPage.test.tsx 
b/web/src/pages/ai/__tests__/AiPage.test.tsx
index 9c2ca4de1..1a963570c 100644
--- a/web/src/pages/ai/__tests__/AiPage.test.tsx
+++ b/web/src/pages/ai/__tests__/AiPage.test.tsx
@@ -37,7 +37,7 @@ import type {
 } from '../../../api/aiEvents';
 import { listClusters, type ClusterInfo } from '../../../api/cluster';
 import type { Instance } from '../../../api/instance';
-import { getLlmConfig, getLlmModels } from '../../../api/llm';
+import { getLlmConfig, getLlmModels, type LlmConfig } from '../../../api/llm';
 import { listInstances } from '../../../services/instanceService';
 import useAuthStore from '../../../stores/authStore';
 import { useEngineStore } from '../../../stores/engineStore';
@@ -123,6 +123,9 @@ const NavProbe = () => {
       <button type="button" onClick={() => navigate('/ai/c/7')}>
         probe-conversation
       </button>
+      <button type="button" onClick={() => navigate('/')}>
+        probe-home
+      </button>
     </div>
   );
 };
@@ -148,6 +151,14 @@ const renderRouted = (path: string, state?: unknown) =>
           <NavProbe />
           <LocationProbe />
           <Routes>
+            <Route
+              path="/"
+              element={
+                <button type="button" onClick={() => 
useEngineStore.getState().setEngine('qoder')}>
+                  choose-qoder
+                </button>
+              }
+            />
             <Route path="/ai" element={<AiPage />} />
             <Route path="/ai/c/:conversationId" element={<AiPage />} />
           </Routes>
@@ -509,6 +520,41 @@ describe('AiPage', () => {
     expect(openRunStream).not.toHaveBeenCalled();
   });
 
+  it('preservesTheNewEnginePreferenceWhenAnUnmountedRuntimeLoadCompletesTest', 
async () => {
+    let resolveConfig!: (value: LlmConfig) => void;
+    vi.mocked(getLlmConfig).mockReturnValueOnce(
+      new Promise<LlmConfig>((resolve) => {
+        resolveConfig = resolve;
+      }),
+    );
+    const user = userEvent.setup();
+    renderPage();
+    expect(getLlmConfig).toHaveBeenCalledTimes(1);
+
+    await user.click(screen.getByRole('button', { name: 'probe-home' }));
+    expect(readProbeLocation().pathname).toBe('/');
+    await user.click(screen.getByRole('button', { name: 'choose-qoder' }));
+
+    await act(async () => {
+      resolveConfig({
+        provider: 'openai',
+        engine: 'http',
+        apiBase: 'https://example.invalid',
+        model: 'gpt-test',
+        maxTokens: 1024,
+        temperature: 0,
+        enabled: true,
+        ready: true,
+      });
+    });
+
+    expect(useEngineStore.getState().engine).toBe('qoder');
+    
expect(JSON.parse(localStorage.getItem('rocketmq-studio-agent-engine')!)).toMatchObject({
+      state: { engine: 'qoder' },
+    });
+    expect(getLlmModels).not.toHaveBeenCalled();
+  });
+
   it('doesNotLoadTheLlmRuntimeInMockModeAndDisablesTheComposerTest', async () 
=> {
     dataModeMocks.useMock = true;
     const user = userEvent.setup();
diff --git a/web/src/pages/ai/components/AssistantBubble.tsx 
b/web/src/pages/ai/components/AssistantBubble.tsx
index adb43685d..9e149b7c2 100644
--- a/web/src/pages/ai/components/AssistantBubble.tsx
+++ b/web/src/pages/ai/components/AssistantBubble.tsx
@@ -107,28 +107,32 @@ const AssistantBubble = ({
 
   // The standard per-message action: copy the answer as markdown. Clipboard 
API first, the
   // hidden-textarea fallback second (non-secure contexts), silent no-op third.
-  const handleCopy = () => {
+  const handleCopy = async () => {
     const done = () => {
       setCopied(true);
       window.setTimeout(() => setCopied(false), 2000);
     };
-    if (navigator.clipboard?.writeText) {
-      navigator.clipboard
-        .writeText(answerText)
-        .then(done)
-        .catch(() => undefined);
-      return;
+    try {
+      if (navigator.clipboard?.writeText) {
+        await navigator.clipboard.writeText(answerText);
+        done();
+        return;
+      }
+    } catch {
+      // A denied Clipboard API write can still succeed through the textarea 
fallback.
     }
     const textarea = document.createElement('textarea');
     textarea.value = answerText;
     textarea.style.position = 'fixed';
     textarea.style.opacity = '0';
-    document.body.appendChild(textarea);
-    textarea.select();
     try {
-      if (document.execCommand('copy')) done();
+      document.body.appendChild(textarea);
+      textarea.select();
+      if (document.execCommand?.('copy')) done();
+    } catch {
+      // Unsupported copying degrades silently without reporting success.
     } finally {
-      document.body.removeChild(textarea);
+      textarea.remove();
     }
   };
 
diff --git a/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx 
b/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
index db691c9ad..c334de76b 100644
--- a/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
+++ b/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { beforeEach, describe, expect, it } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import { fireEvent, render, screen, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { LangProvider } from '../../../../i18n/LangContext';
@@ -97,6 +97,131 @@ describe('AssistantBubble', () => {
     localStorage.clear();
   });
 
+  describe('copy response', () => {
+    const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 
'clipboard');
+    const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, 
'execCommand');
+    const answer = '**First answer**\n\nSecond answer';
+    let execCommand: ReturnType<typeof vi.fn>;
+
+    beforeEach(() => {
+      Object.defineProperty(navigator, 'clipboard', { configurable: true, 
value: undefined });
+      execCommand = vi.fn(() => true);
+      Object.defineProperty(document, 'execCommand', { configurable: true, 
value: execCommand });
+    });
+
+    afterEach(() => {
+      if (clipboardDescriptor) {
+        Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
+      } else {
+        Reflect.deleteProperty(navigator, 'clipboard');
+      }
+      if (execCommandDescriptor) {
+        Object.defineProperty(document, 'execCommand', execCommandDescriptor);
+      } else {
+        Reflect.deleteProperty(document, 'execCommand');
+      }
+      vi.restoreAllMocks();
+    });
+
+    async function copyResponse() {
+      renderBubble({
+        blocks: [
+          ...appendText([], '**First answer**'),
+          ...appendThinking([], 'Private reasoning', 'model'),
+          ...appendText([], 'Second answer'),
+        ],
+      });
+      const button = screen.getByRole('button', { name: '复制回复' });
+      fireEvent.click(button);
+      fireEvent.mouseEnter(button);
+      return screen.findByRole('tooltip');
+    }
+
+    function expectFallbackText() {
+      const textarea = document.querySelector('textarea');
+      expect(textarea).toHaveValue(answer);
+      expect(textarea?.selectionStart).toBe(0);
+      expect(textarea?.selectionEnd).toBe(answer.length);
+    }
+
+    it('copiesOnlyAnswerMarkdownWithTheClipboardApiTest', async () => {
+      const writeText = vi.fn().mockResolvedValue(undefined);
+      Object.defineProperty(navigator, 'clipboard', { configurable: true, 
value: { writeText } });
+
+      expect(await copyResponse()).toHaveTextContent('已复制');
+
+      expect(writeText).toHaveBeenCalledExactlyOnceWith(answer);
+      expect(execCommand).not.toHaveBeenCalled();
+      expect(document.querySelector('textarea')).toBeNull();
+    });
+
+    it.each([
+      { label: 'rejected', writeText: () => Promise.reject(new 
Error('Permission denied')) },
+      {
+        label: 'throwing',
+        writeText: () => {
+          throw new Error('Clipboard unavailable');
+        },
+      },
+    ])('fallsBackAfterThe$labelClipboardWriteTest', async ({ writeText }) => {
+      Object.defineProperty(navigator, 'clipboard', { configurable: true, 
value: { writeText } });
+      execCommand.mockImplementation(() => {
+        expectFallbackText();
+        return true;
+      });
+
+      expect(await copyResponse()).toHaveTextContent('已复制');
+
+      expect(execCommand).toHaveBeenCalledExactlyOnceWith('copy');
+      expect(document.querySelector('textarea')).toBeNull();
+    });
+
+    it('fallsBackWhenTheClipboardApiIsUnavailableTest', async () => {
+      execCommand.mockImplementation(() => {
+        expectFallbackText();
+        return true;
+      });
+
+      expect(await copyResponse()).toHaveTextContent('已复制');
+
+      expect(execCommand).toHaveBeenCalledExactlyOnceWith('copy');
+      expect(document.querySelector('textarea')).toBeNull();
+    });
+
+    it.each(['false', 'throwing', 'unavailable'] as const)(
+      'doesNotReportCopiedWhenTheFallbackIs%sTest',
+      async (failure) => {
+        if (failure === 'unavailable') {
+          Object.defineProperty(document, 'execCommand', { configurable: true, 
value: undefined });
+        } else {
+          execCommand.mockImplementation(() => {
+            expectFallbackText();
+            if (failure === 'throwing') throw new Error('Copy unsupported');
+            return false;
+          });
+        }
+
+        expect(await copyResponse()).toHaveTextContent('复制回复');
+
+        if (failure !== 'unavailable') {
+          expect(execCommand).toHaveBeenCalledExactlyOnceWith('copy');
+        }
+        expect(document.querySelector('textarea')).toBeNull();
+      },
+    );
+
+    it('cleansUpWhenSelectingTheFallbackTextThrowsTest', async () => {
+      vi.spyOn(HTMLTextAreaElement.prototype, 'select').mockImplementation(() 
=> {
+        throw new Error('Selection unavailable');
+      });
+
+      expect(await copyResponse()).toHaveTextContent('复制回复');
+
+      expect(execCommand).not.toHaveBeenCalled();
+      expect(document.querySelector('textarea')).toBeNull();
+    });
+  });
+
   it('rendersEveryBlockKindTest', () => {
     const blocks: RenderBlock[] = [
       ...appendThinking([], '先确认实例能力,再列 Topic。', 'model'),
diff --git a/web/src/pages/ai/hooks/useConversationTimeline.test.ts 
b/web/src/pages/ai/hooks/useConversationTimeline.test.ts
index eed5e2156..108850b7c 100644
--- a/web/src/pages/ai/hooks/useConversationTimeline.test.ts
+++ b/web/src/pages/ai/hooks/useConversationTimeline.test.ts
@@ -48,10 +48,12 @@ function page(
 
 function deferred<T>() {
   let resolve: (value: T) => void = () => undefined;
-  const promise = new Promise<T>((release) => {
+  let reject: (reason: unknown) => void = () => undefined;
+  const promise = new Promise<T>((release, fail) => {
     resolve = release;
+    reject = fail;
   });
-  return { promise, resolve };
+  return { promise, resolve, reject };
 }
 
 function render(conversationId: number | null = 7, limit?: number, maxPages?: 
number) {
@@ -181,6 +183,145 @@ describe('useConversationTimeline', () => {
     expect(result.current.loading).toBe(false);
   });
 
+  it.each([{ label: 'pending' }, { label: 'failed' }])(
+    'hidesThePreviousTranscriptWhenTheNewSnapshotIs($label)Test',
+    async ({ label }) => {
+      const second = deferred<AiTimelineVO>();
+      timelineMock
+        .mockResolvedValueOnce(page([item(5, { type: 'user', text: 
'conversation 7' })], 5))
+        .mockReturnValueOnce(second.promise);
+
+      const { result, rerender } = render(7, 1, 1);
+      await waitFor(() => expect(result.current.lastSeq).toBe(5));
+      expect(result.current.bubbles).toHaveLength(1);
+      expect(result.current.hasMore).toBe(true);
+
+      rerender({ id: 8 });
+      if (label === 'failed') {
+        await act(async () => {
+          second.reject(new Error('conversation 8 unavailable'));
+        });
+        expect(result.current.error).toBe('conversation 8 unavailable');
+        expect(result.current.loading).toBe(false);
+      } else {
+        expect(result.current.loading).toBe(true);
+      }
+
+      expect(result.current.items).toEqual([]);
+      expect(result.current.bubbles).toEqual([]);
+      expect(result.current.lastSeq).toBe(0);
+      expect(result.current.hasMore).toBe(false);
+      expect(result.current.activeRun).toBeNull();
+
+      if (label === 'pending') {
+        await act(async () => {
+          second.resolve(page([], null));
+        });
+      }
+    },
+  );
+
+  it.each([{ label: 'pending' }, { label: 'failed' }])(
+    'blocksLoadMoreWhenTheNewSnapshotIs($label)Test',
+    async ({ label }) => {
+      const second = deferred<AiTimelineVO>();
+      timelineMock
+        .mockResolvedValueOnce(page([item(5, { type: 'text', text: 
'conversation 7' })], 5))
+        .mockReturnValueOnce(second.promise)
+        .mockResolvedValue(page([item(6, { type: 'text', text: 'conversation 
8' })], null));
+
+      const { result, rerender } = render(7, 1, 1);
+      await waitFor(() => expect(result.current.lastSeq).toBe(5));
+      rerender({ id: 8 });
+      if (label === 'failed') {
+        await act(async () => {
+          second.reject(new Error('conversation 8 unavailable'));
+        });
+      }
+
+      await act(async () => {
+        await result.current.loadMore();
+      });
+      expect(timelineMock).toHaveBeenCalledTimes(2);
+      expect(timelineMock).toHaveBeenLastCalledWith(8, { after: 0, limit: 1 });
+
+      if (label === 'pending') {
+        await act(async () => {
+          second.resolve(page([], null));
+        });
+      } else {
+        expect(result.current.error).toBe('conversation 8 unavailable');
+      }
+    },
+  );
+
+  it('loadsMoreUsingOnlyTheNewConversationsSnapshotAndCursorTest', async () => 
{
+    const second = deferred<AiTimelineVO>();
+    timelineMock
+      .mockResolvedValueOnce(page([item(5, { type: 'text', text: 'conversation 
7' })], 5))
+      .mockReturnValueOnce(second.promise)
+      .mockResolvedValueOnce(page([item(12, { type: 'text', text: 'more 
conversation 8' })], null));
+
+    const { result, rerender } = render(7, 1, 1);
+    await waitFor(() => expect(result.current.lastSeq).toBe(5));
+    rerender({ id: 8 });
+
+    await act(async () => {
+      second.resolve(page([item(11, { type: 'text', text: 'conversation 8' 
})], 11));
+    });
+    expect(result.current.items.map((row) => row.seq)).toEqual([11]);
+    expect(result.current.hasMore).toBe(true);
+
+    await act(async () => {
+      await result.current.loadMore();
+    });
+    expect(timelineMock).toHaveBeenLastCalledWith(8, { after: 11, limit: 1 });
+    expect(result.current.items.map((row) => row.seq)).toEqual([11, 12]);
+    expect(result.current.lastSeq).toBe(12);
+    expect(result.current.hasMore).toBe(false);
+  });
+
+  it('retainsTheCurrentTranscriptAndCursorWhenItsRefreshFailsTest', async () 
=> {
+    const refreshed = deferred<AiTimelineVO>();
+    timelineMock
+      .mockResolvedValueOnce(page([item(5, { type: 'user', text: 'conversation 
7' })], 5))
+      .mockReturnValueOnce(refreshed.promise)
+      .mockResolvedValueOnce(page([item(6, { type: 'text', text: 'more 
conversation 7' })], null));
+
+    const { result } = render(7, 1, 1);
+    await waitFor(() => expect(result.current.lastSeq).toBe(5));
+    const bubbles = result.current.bubbles;
+
+    let refetchPromise: Promise<void> = Promise.resolve();
+    act(() => {
+      refetchPromise = result.current.refetch();
+    });
+    expect(result.current.items.map((row) => row.seq)).toEqual([5]);
+    expect(result.current.bubbles).toEqual(bubbles);
+    expect(result.current.lastSeq).toBe(5);
+    expect(result.current.hasMore).toBe(true);
+    expect(result.current.loading).toBe(true);
+
+    await act(async () => {
+      const rejection = expect(refetchPromise).rejects.toThrow('refresh 
unavailable');
+      refreshed.reject(new Error('refresh unavailable'));
+      await rejection;
+    });
+    expect(result.current.items.map((row) => row.seq)).toEqual([5]);
+    expect(result.current.bubbles).toEqual(bubbles);
+    expect(result.current.lastSeq).toBe(5);
+    expect(result.current.hasMore).toBe(true);
+    expect(result.current.loading).toBe(false);
+    expect(result.current.error).toBe('refresh unavailable');
+
+    await act(async () => {
+      await result.current.loadMore();
+    });
+    expect(timelineMock).toHaveBeenLastCalledWith(7, { after: 5, limit: 1 });
+    expect(result.current.items.map((row) => row.seq)).toEqual([5, 6]);
+    expect(result.current.hasMore).toBe(false);
+  });
+
   it('refetchReloadsTheWholeTranscriptAndIsAwaitableTest', async () => {
     timelineMock
       .mockResolvedValueOnce(page([item(1, { type: 'text', text: 'before' })], 
null))
diff --git a/web/src/pages/ai/hooks/useConversationTimeline.ts 
b/web/src/pages/ai/hooks/useConversationTimeline.ts
index fc34091df..b119fa786 100644
--- a/web/src/pages/ai/hooks/useConversationTimeline.ts
+++ b/web/src/pages/ai/hooks/useConversationTimeline.ts
@@ -38,6 +38,7 @@ import { describeThrownMessage } from 
'../../../utils/apiError';
 export const TIMELINE_PAGE_LIMIT = 200;
 /** 25 pages x 200 events: far past any real conversation, small enough to 
stay a bounded walk. */
 export const TIMELINE_MAX_PAGES = 25;
+const EMPTY_TIMELINE_ITEMS: TimelineItem[] = [];
 
 /** Fold the envelope's per-run stats into the speed map; null entries never 
overwrite. */
 function collectRunSpeeds(
@@ -63,6 +64,8 @@ export interface UseConversationTimelineOptions {
  * 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}.
+ * This also identifies the owner of the rows and cursor committed with the 
run, even when the run
+ * is null. Keep that snapshot during a same-conversation refresh, but hide it 
after a switch.
  */
 interface LoadedActiveRun {
   conversationId: number;
@@ -105,6 +108,7 @@ export function useConversationTimeline(
   const [runSpeeds, setRunSpeeds] = useState<Map<number, number>>(new Map());
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState('');
+  const hasCurrentSnapshot = loadedActiveRun?.conversationId === 
conversationId;
   const refetchRequestId = useRef(0);
   const activeRefetchRef = useRef<number | null>(null);
   const loadMoreRequestId = useRef(0);
@@ -176,7 +180,13 @@ export function useConversationTimeline(
   }, [conversationId, limit, maxPages]);
 
   const loadMore = useCallback(async (): Promise<void> => {
-    if (conversationId === null || nextAfter === null || 
loadingMoreRef.current !== null) return;
+    if (
+      conversationId === null ||
+      !hasCurrentSnapshot ||
+      nextAfter === null ||
+      loadingMoreRef.current !== null
+    )
+      return;
 
     const after = nextAfter;
     const id = ++loadMoreRequestId.current;
@@ -204,7 +214,7 @@ export function useConversationTimeline(
         if (activeRefetchRef.current === null) setLoading(false);
       }
     }
-  }, [conversationId, limit, nextAfter]);
+  }, [conversationId, hasCurrentSnapshot, limit, nextAfter]);
 
   useEffect(() => {
     // Loading is asynchronous; state updates happen after the timeline API 
resolves.
@@ -218,8 +228,12 @@ export function useConversationTimeline(
     };
   }, [refetch]);
 
-  const bubbles = useMemo(() => groupIntoBubbles(items, runSpeeds), [items, 
runSpeeds]);
-  const lastSeq = items.length ? items[items.length - 1].seq : 0;
+  const visibleItems = hasCurrentSnapshot ? items : EMPTY_TIMELINE_ITEMS;
+  const bubbles = useMemo(
+    () => groupIntoBubbles(visibleItems, runSpeeds),
+    [visibleItems, runSpeeds],
+  );
+  const lastSeq = visibleItems.length ? visibleItems[visibleItems.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 =
@@ -228,13 +242,13 @@ export function useConversationTimeline(
       : null;
 
   return {
-    items,
+    items: visibleItems,
     bubbles,
     activeRun,
     lastSeq,
     loading,
     error,
-    hasMore: nextAfter !== null,
+    hasMore: hasCurrentSnapshot && nextAfter !== null,
     refetch,
     loadMore,
   };
diff --git a/web/src/pages/ai/hooks/useLlmRuntime.test.ts 
b/web/src/pages/ai/hooks/useLlmRuntime.test.ts
index 0713db18a..f41ca07b8 100644
--- a/web/src/pages/ai/hooks/useLlmRuntime.test.ts
+++ b/web/src/pages/ai/hooks/useLlmRuntime.test.ts
@@ -16,8 +16,9 @@
  */
 
 import { act, renderHook } from '@testing-library/react';
+import { StrictMode } from 'react';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import type { LlmConfig } from '../../../api/llm';
+import type { LlmConfig, LlmModelsResult } from '../../../api/llm';
 import { useLlmRuntime } from './useLlmRuntime';
 
 vi.mock('../../../api/llm', () => ({
@@ -32,10 +33,12 @@ const modelsMock = vi.mocked(getLlmModels);
 
 function deferred<T>() {
   let resolve!: (value: T) => void;
-  const promise = new Promise<T>((res) => {
+  let reject!: (reason?: unknown) => void;
+  const promise = new Promise<T>((res, rej) => {
     resolve = res;
+    reject = rej;
   });
-  return { promise, resolve };
+  return { promise, resolve, reject };
 }
 
 const config: LlmConfig = {
@@ -65,6 +68,75 @@ describe('useLlmRuntime', () => {
     vi.clearAllMocks();
   });
 
+  it('ignoresConfigCompletionAndDoesNotLoadModelsAfterUnmountTest', async () 
=> {
+    const pending = deferred<LlmConfig>();
+    configMock.mockReturnValue(pending.promise);
+    modelsMock.mockResolvedValue({ status: 0, data: [] });
+    const onEngine = vi.fn();
+    const onError = vi.fn();
+    const { unmount } = renderHook(() => useLlmRuntime({ enabled: true, 
onEngine, onError }));
+    expect(configMock).toHaveBeenCalledTimes(1);
+
+    unmount();
+    await act(async () => pending.resolve(config));
+
+    expect(onEngine).not.toHaveBeenCalled();
+    expect(onError).not.toHaveBeenCalled();
+    expect(modelsMock).not.toHaveBeenCalled();
+  });
+
+  it('ignoresConfigFailureAfterUnmountTest', async () => {
+    const pending = deferred<LlmConfig>();
+    configMock.mockReturnValue(pending.promise);
+    const onError = vi.fn();
+    const { unmount } = renderHook(() => useLlmRuntime({ enabled: true, 
onError }));
+
+    unmount();
+    await act(async () => pending.reject(new Error('late config failure')));
+
+    expect(onError).not.toHaveBeenCalled();
+    expect(modelsMock).not.toHaveBeenCalled();
+  });
+
+  it('ignoresModelCatalogFailureAfterUnmountTest', async () => {
+    const pending = deferred<LlmModelsResult>();
+    configMock.mockResolvedValue(config);
+    modelsMock.mockReturnValue(pending.promise);
+    const onEngine = vi.fn();
+    const onError = vi.fn();
+    const { unmount } = renderHook(() => useLlmRuntime({ enabled: true, 
onEngine, onError }));
+    await act(async () => {});
+    expect(onEngine).toHaveBeenCalledWith('http');
+    expect(modelsMock).toHaveBeenCalledTimes(1);
+
+    unmount();
+    await act(async () => pending.reject(new Error('late models failure')));
+
+    expect(onError).not.toHaveBeenCalled();
+  });
+
+  it('keepsTheActiveStrictModeLoadAfterEffectCleanupTest', async () => {
+    const superseded = deferred<LlmConfig>();
+    const active = deferred<LlmConfig>();
+    
configMock.mockReturnValueOnce(superseded.promise).mockReturnValueOnce(active.promise);
+    modelsMock.mockResolvedValue({ status: 0, data: [{ id: 'active-model' }] 
});
+    const onEngine = vi.fn();
+    const { result } = renderHook(() => useLlmRuntime({ enabled: true, 
onEngine }), {
+      wrapper: StrictMode,
+    });
+    expect(configMock).toHaveBeenCalledTimes(2);
+
+    await act(async () => active.resolve({ ...config, engine: 'qoder', model: 
'active-model' }));
+    expect(result.current.selectedModel).toBe('active-model');
+    expect(result.current.modelsLoading).toBe(false);
+    expect(result.current.llmReady).toBe(true);
+
+    await act(async () => superseded.resolve(config));
+    expect(onEngine).toHaveBeenCalledExactlyOnceWith('qoder');
+    expect(modelsMock).toHaveBeenCalledTimes(1);
+    expect(result.current.config?.engine).toBe('qoder');
+  });
+
   it('a late response for a disabled runtime never repopulates the state', 
async () => {
     const configDeferred = deferred<LlmConfig>();
     const modelsDeferred = deferred<unknown>();
diff --git a/web/src/pages/ai/hooks/useLlmRuntime.ts 
b/web/src/pages/ai/hooks/useLlmRuntime.ts
index 655fe54c7..8de7e28da 100644
--- a/web/src/pages/ai/hooks/useLlmRuntime.ts
+++ b/web/src/pages/ai/hooks/useLlmRuntime.ts
@@ -87,8 +87,8 @@ export function useLlmRuntime(options: UseLlmRuntimeOptions): 
UseLlmRuntimeResul
     );
   }, []);
 
-  // Monotonic id per load: a response from a superseded load (an enabled flip 
or an overlapping
-  // reload) must not repopulate the state, or a disabled runtime could come 
back "ready".
+  // Monotonic id per load: a response superseded by an enabled flip, 
overlapping reload or
+  // unmount must not publish state or callbacks belonging to an obsolete 
runtime.
   const loadSeqRef = useRef(0);
 
   const load = useCallback(async () => {
@@ -131,6 +131,9 @@ export function useLlmRuntime(options: 
UseLlmRuntimeOptions): UseLlmRuntimeResul
     // Loading is asynchronous; state updates happen after the runtime APIs 
resolve.
     // eslint-disable-next-line react-hooks/set-state-in-effect
     void load();
+    return () => {
+      loadSeqRef.current += 1;
+    };
   }, [load]);
 
   const llmReady = Boolean((config?.ready ?? config?.enabled) && 
selectedModel);

Reply via email to