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 21fe53c6a fix(ai): close stale-state and storage gaps in the AI 
surface (#6048)
21fe53c6a is described below

commit 21fe53c6a9c9ad9b855bae67904f91acc3e788c4
Author: Loyal-Young <[email protected]>
AuthorDate: Mon Oct 12 16:11:05 2026 +0800

    fix(ai): close stale-state and storage gaps in the AI surface (#6048)
    
    Five fixes from the same author to the AI conversation surface, folded into 
a single
    commit. Each one closes a window where state or a callback outlives the 
context that
    produced it, plus one where a browser policy throws during render.
    
    - #6048 `web/src/pages/ai/hooks/useAgentRun.ts:270-272` leaves the `await 
persistRunSpeed()`
      window unguarded — `finishStream` re-checks `requestId` only in the 
refetch catch (`:274`)
      and after the refetch (`:285`) — so a run superseded while the speed 
report is in flight
      still calls `refetchTimeline()` through the newest render's `optionsRef`, 
issuing a timeline
      GET against the new conversation and bouncing `useConversationTimeline` 
back into
      `loading`; adds `if (requestId !== streamRequestIdRef.current) return;` 
between the two
      awaits, which is the only unprotected point left in the function.
    - #6052 `web/src/pages/ai/promptTemplates.ts:200-203` evaluates `typeof 
localStorage` outside
      any `try`, and `typeof` on a Window accessor still runs the getter, so 
denied site data or a
      sandboxed iframe throws a `SecurityError` inside the render-phase 
`loadPromptTemplateCatalog()`
      call in `PromptTemplateModal` and the whole template modal, built-ins 
included, fails to open;
      wraps the lookup in `try/catch` the way 
`web/src/utils/messageTraceTopicStorage.ts:47-55`
      already does, which is sufficient because `getStorage()` is this module's 
only storage entry
      point for read, write, catalog load, save and delete.
    - #6053 `web/src/pages/ai/components/ConversationListModal.tsx:236-241` 
computes "did we just
      delete the open conversation" from the `activeConversationId` captured in 
the panel's closure,
      while the deletes run as sequential awaits and `destroyOnHidden` (`:521`) 
unmounts that panel
      as soon as the user picks another row 
(`web/src/pages/ai/index.tsx:236-239`), so the in-flight
      closure resolves against a stale id and either navigates the user back to 
`/ai` from the
      conversation they just opened or leaves a deleted transcript active with 
the timeline polling
      an id that now answers 404; moves the decision into the still-mounted 
modal shell behind a
      `deletionContextRef` that is nulled on unmount, changes the private panel 
prop to
      `onDeleted(ids) => boolean`, and adds a `mountedRef` so an unmounted 
panel skips its own
      state updates and `reload()` — the exported 
`ConversationListModalProps.onActiveDeleted` is
      unchanged.
    - #6066 `web/src/pages/ai/components/blocks/ThinkingBlock.tsx:62-64` 
honours a remembered click
      only while `autoOpen` keeps its value, but for an earlier thinking block
      `autoOpen = streaming && latest` is `false` both during and after the run
      (`AssistantBubble.tsx:93` sets `lastThinking = -1` once streaming ends), 
so a block expanded
      by hand mid-stream stays open after the run finishes and contradicts the 
collapse-on-done
      contract written in this file's own header comment (`:34-42`); records 
`{streaming, latest, open}`
      on click and drops the click during render when either flag has moved.
    - #6070 `web/src/pages/ai/components/Composer.tsx:270` gates only on
      `event.nativeEvent.isComposing`, and WebKit can deliver `compositionend` 
before the `keydown`
      that confirms the candidate, so that keydown arrives with `isComposing 
=== false` and
      `keyCode === 229` and falls through to Enter, sending the half-typed 
prompt and clearing the
      box, or to Escape, stopping the generating run; extends the guard with
      `|| event.nativeEvent.keyCode === 229`, which covers both branches 
because it sits at the top
      of `handleKeyDown` (`:267-283`) — the guard this file already documents 
as load-bearing.
    
    Seventeen new cases across five test files (5 for #6048, 3 for #6052, 4 for 
#6053, 3 for #6066,
    2 for #6070), all mutation-sensitive: reverting the `useAgentRun` guard 
makes the three
    superseded-context cases re-issue the timeline GET, reverting the 
`getStorage` try/catch makes
    the three storage cases throw `SecurityError`, restoring the stale-closure 
computation flips the
    notify/no-notify pair in `ConversationListModal`, reverting the Toggle 
record leaves the earlier
    block open for all three run end states, and dropping the `keyCode === 229` 
clause makes both IME
    cases send or stop. Each member also carries positive controls — an owned 
run still reloads,
    built-in templates still save and load once storage recovers, closing the 
modal without
    navigating still notifies, an expansion after completion still survives a 
plain rerender, and the
    next ordinary keypress still fires exactly once — so pushing a guard too 
far turns tests red too.
    
    Folded: #6052, #6053, #6066, #6070 were closed as folded into this commit 
(same author).
---
 web/src/pages/ai/components/Composer.tsx           |   4 +-
 .../pages/ai/components/ConversationListModal.tsx  |  49 +++++++--
 .../components/__tests__/AssistantBubble.test.tsx  |  32 ++++++
 .../ai/components/__tests__/Composer.test.tsx      |  33 ++++++
 .../__tests__/ConversationListModal.test.tsx       |  79 ++++++++++++++
 .../__tests__/PromptTemplateModal.test.tsx         | 114 ++++++++++++++++++++
 .../pages/ai/components/blocks/ThinkingBlock.tsx   |  16 ++-
 .../pages/ai/hooks/useAgentRun.timeline.test.ts    | 117 ++++++++++++++++++++-
 web/src/pages/ai/hooks/useAgentRun.ts              |   3 +
 web/src/pages/ai/promptTemplates.ts                |   8 +-
 10 files changed, 435 insertions(+), 20 deletions(-)

diff --git a/web/src/pages/ai/components/Composer.tsx 
b/web/src/pages/ai/components/Composer.tsx
index 4d3637134..5b4dd65db 100644
--- a/web/src/pages/ai/components/Composer.tsx
+++ b/web/src/pages/ai/components/Composer.tsx
@@ -267,7 +267,9 @@ const Composer = ({
   const handleKeyDown = useCallback(
     (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
       // An IME candidate being confirmed must not send the half-typed prompt.
-      if (event.nativeEvent.isComposing) return;
+      // WebKit can deliver the confirming key after compositionend, with only 
keyCode 229
+      // identifying it as IME input 
(https://bugs.webkit.org/show_bug.cgi?id=165004).
+      if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) 
return;
       if (event.key === 'Escape') {
         if (generating) {
           event.preventDefault();
diff --git a/web/src/pages/ai/components/ConversationListModal.tsx 
b/web/src/pages/ai/components/ConversationListModal.tsx
index 0132d2063..70d25eef9 100644
--- a/web/src/pages/ai/components/ConversationListModal.tsx
+++ b/web/src/pages/ai/components/ConversationListModal.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useCallback, useMemo, useState, type Key } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState, type Key } from 
'react';
 import {
   Alert,
   Button,
@@ -163,8 +163,10 @@ export interface ConversationListModalProps {
 const ConversationListPanel = ({
   activeConversationId,
   onSelect,
-  onActiveDeleted,
-}: Omit<ConversationListModalProps, 'open' | 'onClose'>) => {
+  onDeleted,
+}: Pick<ConversationListModalProps, 'activeConversationId' | 'onSelect'> & {
+  onDeleted: (ids: readonly number[]) => boolean;
+}) => {
   const { t } = useLang();
   const list = useConversationList();
   const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
@@ -175,6 +177,13 @@ const ConversationListPanel = ({
   // depending on it would rebuild `columns` — and with it every cell — on 
each one.
   const { items, page, setPage, reload, archived } = list;
   const rowCount = items.length;
+  const mountedRef = useRef(false);
+  useEffect(() => {
+    mountedRef.current = true;
+    return () => {
+      mountedRef.current = false;
+    };
+  }, []);
 
   /**
    * Flips the row's archived flag (PATCH) and reloads the scope: an archived 
conversation leaves
@@ -220,6 +229,9 @@ const ConversationListPanel = ({
         }
       }
       const deletedCount = ids.length - failed.length;
+      // The modal shell survives closing its panel and knows which 
conversation is
+      // active now. Notify it even when the panel was closed during deletion.
+      const activeDeleted = onDeleted(ids.filter((id) => 
!failed.includes(id)));
       setDeletingIds([]);
       setSelectedRowKeys((keys) => keys.filter((key) => 
failed.includes(Number(key))));
       if (failed.length === 0) {
@@ -233,14 +245,14 @@ const ConversationListPanel = ({
       }
       // Only a delete that SUCCEEDED ends the open conversation: deletion may 
have failed, and then
       // the conversation is still on screen, so the caller must stay put and 
the list still refresh.
-      const activeDeleted =
-        activeConversationId != null &&
-        ids.includes(activeConversationId) &&
-        !failed.includes(activeConversationId);
       if (activeDeleted) {
-        onActiveDeleted?.();
         return;
       }
+      // The toasts above are global — antd mounts them at document level, not 
inside this panel — so
+      // closing the panel mid-delete must not swallow the outcome, least of 
all a failure. What does
+      // belong to the panel is the refresh below: re-querying a list nobody 
renders any more wastes a
+      // request and drives this hook's state from a component that is already 
gone.
+      if (!mountedRef.current) return;
       // A page emptied by the delete has to fall back one, or the pager keeps 
offering a page that
       // no longer exists; `setPage` re-queries through the hook, `reload` 
covers every other case.
       if (deletedCount > 0 && deletedCount >= rowCount && page > 1) {
@@ -249,7 +261,7 @@ const ConversationListPanel = ({
         reload();
       }
     },
-    [activeConversationId, onActiveDeleted, page, reload, rowCount, setPage, 
t],
+    [onDeleted, page, reload, rowCount, setPage, t],
   );
 
   const columns = useMemo<ColumnsType<AiConversationListItemVO>>(
@@ -509,6 +521,23 @@ const ConversationListModal = ({
   onActiveDeleted,
 }: ConversationListModalProps) => {
   const { t } = useLang();
+  const deletionContextRef = useRef<{
+    activeId: number | null;
+    notify: (() => void) | undefined;
+  } | null>(null);
+  useEffect(() => {
+    deletionContextRef.current = { activeId: activeConversationId, notify: 
onActiveDeleted };
+    return () => {
+      deletionContextRef.current = null;
+    };
+  }, [activeConversationId, onActiveDeleted]);
+
+  const handleDeleted = useCallback((ids: readonly number[]) => {
+    const context = deletionContextRef.current;
+    if (context?.activeId == null || !ids.includes(context.activeId)) return 
false;
+    context.notify?.();
+    return true;
+  }, []);
 
   return (
     <Modal
@@ -523,7 +552,7 @@ const ConversationListModal = ({
       <ConversationListPanel
         activeConversationId={activeConversationId}
         onSelect={onSelect}
-        onActiveDeleted={onActiveDeleted}
+        onDeleted={handleDeleted}
       />
     </Modal>
   );
diff --git a/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx 
b/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
index c334de76b..98c716059 100644
--- a/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
+++ b/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
@@ -273,6 +273,38 @@ describe('AssistantBubble', () => {
     expect(screen.getByText('mqadmin 
topicList').closest('pre')).toBeInTheDocument();
   });
 
+  it.each(['COMPLETED', 'STOPPED', 'FAILED'] as const)(
+    'collapsesManuallyExpandedEarlierReasoningOnCompletionTest (%s)',
+    (runStatus) => {
+      const blocks: RenderBlock[] = [
+        { kind: 'thinking', source: 'enhance', text: 'earlier rewrite' },
+        { kind: 'thinking', source: 'model', text: 'latest reasoning' },
+        { kind: 'text', text: 'final answer' },
+      ];
+      const view = renderBubble({ blocks, streaming: true });
+      const earlier = screen.getAllByTestId('ai-thinking-block')[0];
+      fireEvent.click(within(earlier).getByTestId('ai-thinking-summary'));
+      expect(earlier).toHaveAttribute('open');
+      view.rerender(
+        <LangProvider>
+          <AssistantBubble bubble={bubble(blocks, { runStatus })} 
streaming={false} />
+        </LangProvider>,
+      );
+      for (const details of screen.getAllByTestId('ai-thinking-block')) {
+        expect(details).not.toHaveAttribute('open');
+      }
+      // A reader can still expand a completed answer's reasoning.
+      fireEvent.click(within(earlier).getByTestId('ai-thinking-summary'));
+      expect(earlier).toHaveAttribute('open');
+      view.rerender(
+        <LangProvider>
+          <AssistantBubble bubble={bubble([...blocks], { runStatus })} 
streaming={false} />
+        </LangProvider>,
+      );
+      expect(earlier).toHaveAttribute('open');
+    },
+  );
+
   it('collapsesThinkingByDefaultAndCountsItsCharactersTest', () => {
     const text = '先确认实例能力。';
     renderBubble({ blocks: appendThinking([], text, 'model') });
diff --git a/web/src/pages/ai/components/__tests__/Composer.test.tsx 
b/web/src/pages/ai/components/__tests__/Composer.test.tsx
index 3c170d7b5..afd3d48c4 100644
--- a/web/src/pages/ai/components/__tests__/Composer.test.tsx
+++ b/web/src/pages/ai/components/__tests__/Composer.test.tsx
@@ -184,6 +184,39 @@ describe('Composer', () => {
     expect(textarea()).toHaveValue('检查集群状态');
   });
 
+  it.each([
+    { key: 'Enter', isStreaming: false },
+    { key: 'Escape', isStreaming: true },
+  ])('ignoresImeProcessKeysAfterCompositionEndTest ($key)', ({ key, 
isStreaming }) => {
+    const draft = '检查消费延迟';
+    const { props } = renderComposer({ value: draft, isStreaming });
+    fireEvent.compositionStart(textarea());
+    fireEvent.compositionEnd(textarea(), { data: '迟' });
+    const imeKey = new KeyboardEvent('keydown', {
+      key,
+      keyCode: 229,
+      isComposing: false,
+      bubbles: true,
+      cancelable: true,
+    });
+    fireEvent(textarea(), imeKey);
+    expect(imeKey.defaultPrevented).toBe(false);
+    expect(props.onSend).not.toHaveBeenCalled();
+    expect(props.onStop).not.toHaveBeenCalled();
+    expect(props.onChange).not.toHaveBeenCalled();
+    expect(textarea()).toHaveValue(draft);
+
+    // The next ordinary keypress must work immediately, without a timer or a 
swallowed key.
+    fireEvent.keyDown(textarea(), { key, keyCode: key === 'Enter' ? 13 : 27, 
isComposing: false });
+    if (key === 'Enter') {
+      expect(props.onSend).toHaveBeenCalledExactlyOnceWith(draft);
+      expect(props.onChange).toHaveBeenCalledExactlyOnceWith('');
+    } else {
+      expect(props.onStop).toHaveBeenCalledTimes(1);
+      expect(props.onChange).not.toHaveBeenCalled();
+    }
+  });
+
   it('sendsOnEnterAndClearsTheDraftTest', () => {
     const { props } = renderComposer({ value: '检查集群状态' });
 
diff --git 
a/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx 
b/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx
index d0c66bd99..1df2a5e59 100644
--- a/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx
+++ b/web/src/pages/ai/components/__tests__/ConversationListModal.test.tsx
@@ -358,6 +358,85 @@ describe('ConversationListModal', () => {
     expect(await screen.findByText('检查集群状态')).toBeInTheDocument();
   });
 
+  it.each([
+    { target: 7, nextActive: 8, lifecycle: 'navigate', notified: false },
+    { target: 8, nextActive: 8, lifecycle: 'navigate', notified: true },
+    { target: 7, nextActive: 7, lifecycle: 'close', notified: true },
+    { target: 7, nextActive: 7, lifecycle: 'unmount', notified: false },
+  ])(
+    'usesTheCurrentConversationWhenDeletionFinishesTest ($target/$lifecycle)',
+    async ({ target, nextActive, lifecycle, notified }) => {
+      const user = userEvent.setup();
+      listMock.mockResolvedValue(page([conversation(7, 'first'), 
conversation(8, 'second')], 2, 1));
+      let finish!: () => void;
+      deleteMock.mockImplementation(
+        () =>
+          new Promise<void>((resolve) => {
+            finish = resolve;
+          }),
+      );
+      const view = renderModal({ activeConversationId: 7 });
+      await screen.findByTestId('ai-conversation-row-delete-' + target);
+      await user.click(screen.getByTestId('ai-conversation-row-delete-' + 
target));
+      await confirmDelete(user);
+      await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(target));
+      if (lifecycle === 'unmount') view.unmount();
+      else
+        view.rerender(
+          <App>
+            <LangProvider>
+              <ConversationListModal
+                open={lifecycle !== 'close'}
+                onClose={vi.fn()}
+                onSelect={view.onSelect}
+                activeConversationId={nextActive}
+                onActiveDeleted={view.onActiveDeleted}
+              />
+            </LangProvider>
+          </App>,
+        );
+      await act(async () => {
+        finish();
+      });
+      expect(view.onActiveDeleted).toHaveBeenCalledTimes(notified ? 1 : 0);
+    },
+  );
+
+  /**
+   * The delete toasts are global, so closing the panel mid-delete must not 
swallow them. Guarding on
+   * `mountedRef` one statement earlier — where the notify call is — passes 
every other case in this
+   * file and still loses the failure message, which is the one an operator 
cannot recover by looking
+   * at a list that is no longer on screen.
+   */
+  it('stillReportsAFailedDeleteAfterThePanelWasClosedTest', async () => {
+    const user = userEvent.setup();
+    listMock.mockResolvedValue(page([conversation(7, 'first'), conversation(8, 
'second')], 2, 1));
+    let fail!: (reason: Error) => void;
+    deleteMock.mockImplementation(
+      () =>
+        new Promise<void>((_, reject) => {
+          fail = reject;
+        }),
+    );
+    const view = renderModal({ activeConversationId: 8 });
+    await screen.findByTestId('ai-conversation-row-delete-7');
+    await user.click(screen.getByTestId('ai-conversation-row-delete-7'));
+    await confirmDelete(user);
+    await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(7));
+
+    // Deleting row 7 while row 8 is open: the shell has nothing to navigate 
away from, so the toast
+    // is the only signal left that the delete did not happen. `unmount` 
rather than `open={false}`
+    // because jsdom never finishes the close animation, so `destroyOnHidden` 
would leave the panel
+    // — and its `mountedRef` — alive and the guard under test would never 
engage.
+    view.unmount();
+    await act(async () => {
+      fail(new Error('boom'));
+    });
+
+    expect(await screen.findByText('删除失败,请稍后重试')).toBeInTheDocument();
+    expect(view.onActiveDeleted).not.toHaveBeenCalled();
+  });
+
   it('deletesASingleConversationFromItsRowActionTest', async () => {
     const user = userEvent.setup();
     listMock
diff --git a/web/src/pages/ai/components/__tests__/PromptTemplateModal.test.tsx 
b/web/src/pages/ai/components/__tests__/PromptTemplateModal.test.tsx
new file mode 100644
index 000000000..c4b124f98
--- /dev/null
+++ b/web/src/pages/ai/components/__tests__/PromptTemplateModal.test.tsx
@@ -0,0 +1,114 @@
+/*
+ * 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 { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+import PromptTemplateModal from '../PromptTemplateModal';
+import {
+  builtinPromptTemplates,
+  loadPromptTemplateCatalog,
+  saveCustomPromptTemplate,
+  deleteCustomPromptTemplate,
+} from '../../promptTemplates';
+
+describe('prompt templates with denied storage access', () => {
+  beforeAll(() => {
+    Object.defineProperty(window, 'matchMedia', {
+      writable: true,
+      value: vi.fn().mockImplementation((query: string) => ({
+        matches: false,
+        media: query,
+        onchange: null,
+        addListener: vi.fn(),
+        removeListener: vi.fn(),
+        addEventListener: vi.fn(),
+        removeEventListener: vi.fn(),
+        dispatchEvent: vi.fn(),
+      })),
+    });
+  });
+  afterEach(() => vi.restoreAllMocks());
+
+  function denyStorage() {
+    vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
+      throw new DOMException('Storage access denied', 'SecurityError');
+    });
+  }
+
+  it('reportsUnavailableStorageAcrossPublicOperationsTest', () => {
+    denyStorage();
+    expect(loadPromptTemplateCatalog()).toMatchObject({
+      storageAvailable: false,
+      customCount: 0,
+      templates: builtinPromptTemplates,
+    });
+    expect(saveCustomPromptTemplate({ title: 'check', body: 'inspect brokers' 
})).toEqual({
+      ok: false,
+      reason: 'storage_unavailable',
+    });
+    expect(deleteCustomPromptTemplate('custom-existing')).toBe(false);
+  });
+
+  it('keepsBuiltinPromptsUsableInTheModalTest', async () => {
+    denyStorage();
+    const apply = vi.fn();
+    render(
+      <PromptTemplateModal
+        open
+        onClose={vi.fn()}
+        inputValue=""
+        mode="chat"
+        enhance={false}
+        onApply={apply}
+      />,
+    );
+    expect(
+      await screen.findByText('浏览器存储不可用,自定义 Prompt 模板暂时无法保存。'),
+    ).toBeInTheDocument();
+    fireEvent.click(screen.getAllByRole('button', { name: /使\s*用/ })[0]);
+    expect(apply).toHaveBeenCalledWith(
+      expect.objectContaining({
+        id: builtinPromptTemplates[0].id,
+        body: builtinPromptTemplates[0].body,
+        mode: builtinPromptTemplates[0].mode,
+      }),
+      'replace',
+    );
+  });
+
+  it('canSaveLoadAndDeleteAfterStorageAccessRecoversTest', () => {
+    denyStorage();
+    expect(loadPromptTemplateCatalog().storageAvailable).toBe(false);
+    vi.restoreAllMocks();
+    const storage = localStorage;
+    const original = storage.getItem('rocketmq-studio-ai-prompt-templates');
+    try {
+      storage.removeItem('rocketmq-studio-ai-prompt-templates');
+      const result = saveCustomPromptTemplate({ title: 'Recovered', body: 
'完整检查内容' });
+      expect(result.ok).toBe(true);
+      expect(loadPromptTemplateCatalog().templates[0]).toMatchObject({
+        title: 'Recovered',
+        body: '完整检查内容',
+      });
+      expect(deleteCustomPromptTemplate(result.template!.id)).toBe(true);
+      expect(loadPromptTemplateCatalog().customCount).toBe(0);
+    } finally {
+      if (original === null) 
storage.removeItem('rocketmq-studio-ai-prompt-templates');
+      else storage.setItem('rocketmq-studio-ai-prompt-templates', original);
+    }
+  });
+});
diff --git a/web/src/pages/ai/components/blocks/ThinkingBlock.tsx 
b/web/src/pages/ai/components/blocks/ThinkingBlock.tsx
index 4ccbc3df7..4d1620d87 100644
--- a/web/src/pages/ai/components/blocks/ThinkingBlock.tsx
+++ b/web/src/pages/ai/components/blocks/ThinkingBlock.tsx
@@ -38,7 +38,7 @@ import type { ThinkingBlock as ThinkingBlockData } from 
'../../render/blocks';
  * run stay closed.
  *
  * The open state is derived, not effect-synced: a manual toggle is remembered 
only for as long as
- * `autoOpen` keeps its value, so the transition into or out of streaming 
always wins over a stale
+ * the streaming context keeps its value, so the transition into or out of 
streaming wins over a stale
  * click. That is what makes "collapse on done" unconditional without an 
effect fighting the user.
  */
 
@@ -51,8 +51,8 @@ export interface ThinkingBlockProps {
 }
 
 interface Toggle {
-  /** Value of `autoOpen` the user's click applied to. */
-  autoOpen: boolean;
+  streaming: boolean;
+  latest: boolean;
   open: boolean;
 }
 
@@ -61,7 +61,13 @@ const ThinkingBlock = ({ block, streaming = false, latest = 
false }: ThinkingBlo
   const { token } = theme.useToken();
   const autoOpen = streaming && latest;
   const [toggle, setToggle] = useState<Toggle | null>(null);
-  const open = toggle !== null && toggle.autoOpen === autoOpen ? toggle.open : 
autoOpen;
+  const currentToggle =
+    toggle !== null && toggle.streaming === streaming && toggle.latest === 
latest ? toggle : null;
+  if (toggle !== null && currentToggle === null) {
+    // Discard an old context's click, including earlier blocks whose autoOpen 
stays false.
+    setToggle(null);
+  }
+  const open = currentToggle?.open ?? autoOpen;
 
   const enhance = block.source === 'enhance';
   const label = enhance ? t('ai.thinking.enhance') : t('ai.thinking.model');
@@ -74,7 +80,7 @@ const ThinkingBlock = ({ block, streaming = false, latest = 
false }: ThinkingBlo
         // auto-expand and the collapse-on-done rule are the only other 
writers of `open`.
         onClick={(event) => {
           event.preventDefault();
-          setToggle({ autoOpen, open: !open });
+          setToggle({ streaming, latest, open: !open });
         }}
         style={{
           display: 'flex',
diff --git a/web/src/pages/ai/hooks/useAgentRun.timeline.test.ts 
b/web/src/pages/ai/hooks/useAgentRun.timeline.test.ts
index 7b306882d..2ca1ffec4 100644
--- a/web/src/pages/ai/hooks/useAgentRun.timeline.test.ts
+++ b/web/src/pages/ai/hooks/useAgentRun.timeline.test.ts
@@ -16,11 +16,11 @@
  */
 
 import { act, renderHook, waitFor } from '@testing-library/react';
-import { expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import { useAgentRun } from './useAgentRun';
 import { useConversationTimeline } from './useConversationTimeline';
 import { openRunStream, type RunStreamHandlers } from '../../../api/ai';
-import { getConversationTimeline } from '../../../api/aiConversations';
+import { getConversationTimeline, reportRunSpeed } from 
'../../../api/aiConversations';
 
 vi.mock('../../../api/ai', () => ({ openRunStream: vi.fn(), attachRunStream: 
vi.fn() }));
 vi.mock('../../../api/aiConversations', () => ({
@@ -64,3 +64,116 @@ it('keepsTheLiveAnswerWhenTheRealTimelineRefreshFailsTest', 
async () => {
   expect(result.current.run.blocksRef.current).toEqual(liveAnswer);
   expect(result.current.run.error).toBe('timeline unavailable');
 });
+
+describe('timeline ownership while reporting run speed', () => {
+  beforeEach(() => {
+    vi.mocked(getConversationTimeline).mockReset().mockResolvedValue({
+      items: [],
+      nextAfter: null,
+      activeRun: null,
+    });
+    vi.mocked(reportRunSpeed).mockReset();
+    vi.mocked(openRunStream).mockReset();
+  });
+  afterEach(() => vi.restoreAllMocks());
+
+  async function finishMeasuredRun() {
+    let settleReport!: (failure?: Error) => void;
+    vi.mocked(reportRunSpeed).mockImplementation(
+      () =>
+        new Promise<void>((resolve, reject) => {
+          settleReport = (failure) => (failure ? reject(failure) : resolve());
+        }),
+    );
+    const streams: { handlers: RunStreamHandlers; finish: () => void }[] = [];
+    vi.mocked(openRunStream).mockImplementation(
+      (_id, _body, handlers) =>
+        new Promise<void>((finish) => {
+          streams.push({ handlers, finish });
+        }),
+    );
+    const view = renderHook(
+      ({ id }) => {
+        const timeline = useConversationTimeline(id);
+        return { timeline, run: useAgentRun(id, { refetchTimeline: 
timeline.refetch }) };
+      },
+      { initialProps: { id: 7 } },
+    );
+    await waitFor(() => 
expect(view.result.current.timeline.loading).toBe(false));
+    let sent!: Promise<void>;
+    await act(async () => {
+      sent = view.result.current.run.send(7, { message: 'first question' });
+      const { handlers } = streams[0];
+      handlers.onEvent({
+        type: 'run_started',
+        runId: 41,
+        conversationId: 7,
+        turn: 1,
+        title: 'first',
+      });
+      const clock = vi.spyOn(Date, 'now').mockReturnValue(10000);
+      handlers.onEvent({ type: 'text_delta', content: 'first ' });
+      clock.mockReturnValue(13000);
+      handlers.onEvent({ type: 'text_delta', content: 'answer' });
+      clock.mockRestore();
+      streams[0].finish();
+    });
+    expect(reportRunSpeed).toHaveBeenCalledWith(41, expect.any(Number));
+    expect(getConversationTimeline).toHaveBeenCalledTimes(1);
+    return { ...view, streams, sent, settleReport };
+  }
+
+  it.each(['navigation', 'unmount', 'new run'] as const)(
+    'doesNotReloadAfterAReportOutlivesItsContextTest (%s)',
+    async (context) => {
+      const view = await finishMeasuredRun();
+      let nextSend: Promise<void> | undefined;
+      if (context === 'navigation') {
+        view.rerender({ id: 8 });
+        await waitFor(() => 
expect(view.result.current.timeline.loading).toBe(false));
+        expect(getConversationTimeline).toHaveBeenLastCalledWith(8, { after: 
0, limit: 200 });
+      } else if (context === 'unmount') {
+        view.unmount();
+      } else {
+        await act(async () => {
+          nextSend = view.result.current.run.send(7, { message: 'next 
question' });
+          view.streams[1].handlers.onEvent({ type: 'text_delta', content: 
'next answer' });
+        });
+      }
+      const calls = vi.mocked(getConversationTimeline).mock.calls.length;
+      await act(async () => {
+        view.settleReport();
+        await view.sent;
+      });
+      expect(getConversationTimeline).toHaveBeenCalledTimes(calls);
+      if (context === 'new run') {
+        expect(view.result.current.run.isStreaming).toBe(true);
+        expect(view.result.current.run.blocksRef.current).toEqual([
+          { kind: 'text', text: 'next answer' },
+        ]);
+        await act(async () => {
+          view.streams[1].finish();
+          await nextSend;
+        });
+        expect(getConversationTimeline).toHaveBeenCalledTimes(calls + 1);
+        expect(view.result.current.run.isStreaming).toBe(false);
+        expect(view.result.current.run.blocksRef.current).toEqual([]);
+      }
+    },
+  );
+
+  it.each([false, true])(
+    'reloadsTheOwnedRunAfterReportSettlesTest (failure=%s)',
+    async (failed) => {
+      const view = await finishMeasuredRun();
+      await act(async () => {
+        view.settleReport(failed ? new Error('speed unavailable') : undefined);
+        await view.sent;
+      });
+      expect(getConversationTimeline).toHaveBeenCalledTimes(2);
+      expect(getConversationTimeline).toHaveBeenLastCalledWith(7, { after: 0, 
limit: 200 });
+      expect(view.result.current.run.blocksRef.current).toEqual([]);
+      expect(view.result.current.run.pendingUserMessage).toBeNull();
+    },
+  );
+});
diff --git a/web/src/pages/ai/hooks/useAgentRun.ts 
b/web/src/pages/ai/hooks/useAgentRun.ts
index c0b148501..5348bec93 100644
--- a/web/src/pages/ai/hooks/useAgentRun.ts
+++ b/web/src/pages/ai/hooks/useAgentRun.ts
@@ -268,6 +268,9 @@ export function useAgentRun(
       // instead of flashing an empty transcript. It is also what makes a run 
that died mid-stream
       // render correctly after a reload: whatever reached the database comes 
back.
       await persistRunSpeed();
+      // Reporting can outlive navigation or a newer send. Re-check before 
invoking the
+      // current page's callback, which may now belong to a different 
conversation.
+      if (requestId !== streamRequestIdRef.current) return;
       try {
         await optionsRef.current.refetchTimeline();
       } catch (refetchError) {
diff --git a/web/src/pages/ai/promptTemplates.ts 
b/web/src/pages/ai/promptTemplates.ts
index 6a1c817c4..4e79c2038 100644
--- a/web/src/pages/ai/promptTemplates.ts
+++ b/web/src/pages/ai/promptTemplates.ts
@@ -198,8 +198,12 @@ const isRecord = (value: unknown): value is Record<string, 
unknown> =>
   typeof value === 'object' && value !== null && !Array.isArray(value);
 
 const getStorage = (): PromptTemplateStorage | undefined => {
-  if (typeof localStorage === 'undefined') return undefined;
-  return localStorage;
+  try {
+    return typeof localStorage === 'undefined' ? undefined : localStorage;
+  } catch {
+    // Access to the Storage property itself can be denied before getItem is 
called.
+    return undefined;
+  }
 };
 
 const nowMs = () => Date.now();

Reply via email to