fix(webui): isolate thread cache during chat switches

This commit is contained in:
ramonpaolo 2026-05-01 13:16:33 -03:00 committed by Xubin Ren
parent 76e3f74df7
commit 08744ce408
2 changed files with 95 additions and 0 deletions

View File

@ -44,6 +44,7 @@ export function ThreadShell({
const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef<string | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const lastCachedChatIdRef = useRef<string | null>(null);
const initial = useMemo(() => {
if (!chatId) return historical;
@ -91,6 +92,13 @@ export function ThreadShell({
useEffect(() => {
if (!chatId) return;
// Skip the first cache write after a chat switch. During that render,
// `messages` can still belong to the previous chat until the stream hook
// resets its local state for the new session.
if (lastCachedChatIdRef.current !== chatId) {
lastCachedChatIdRef.current = chatId;
return;
}
messageCacheRef.current.set(chatId, messages);
}, [chatId, messages]);

View File

@ -267,6 +267,93 @@ describe("ThreadShell", () => {
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
});
it("does not cache optimistic messages under the next chat during a session switch", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-b");
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "only in chat a" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"only in chat a",
undefined,
),
);
expect(screen.getByText("only in chat a")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-b")}
title="Chat chat-b"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
await waitFor(() => {
expect(screen.queryByText("only in chat a")).not.toBeInTheDocument();
});
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
expect(screen.getByText("only in chat a")).toBeInTheDocument();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-b")}
title="Chat chat-b"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
});
await waitFor(() => {
expect(screen.queryByText("only in chat a")).not.toBeInTheDocument();
});
});
it("surfaces a dismissible banner when the stream reports message_too_big", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");