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();