diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx
index dc4908867a3e..7ed7d857ecc0 100644
--- a/apps/web/src/components/ChatView.browser.test.tsx
+++ b/apps/web/src/components/ChatView.browser.test.tsx
@@ -1,10 +1,10 @@
import { createMemoryHistory } from "@tanstack/react-router";
-import { scopeProjectRef } from "@t3tools/client-runtime/environment";
-import { ThreadId } from "@t3tools/contracts";
+import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { RunId, ThreadId } from "@t3tools/contracts";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import type * as Context from "effect/Context";
import { expect, it, vi } from "vite-plus/test";
-import { page } from "vite-plus/test/browser";
+import { cdp, page } from "vite-plus/test/browser";
import { AppRoot } from "~/AppRoot";
// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines)
@@ -13,14 +13,18 @@ import "~/components/ChatView";
import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { __setClientSettingsForTests } from "~/hooks/useSettings";
import { getRouter } from "~/router";
+import { appAtomRegistry } from "~/rpc/atomRegistry";
+import { environmentThreadDetails } from "~/state/threads";
import {
ENVIRONMENT_ID,
PROJECT_ID,
+ assistantItem,
+ emitThreadEvent,
installFakeEnvironment,
threadWithHistory,
type FakeDriver,
} from "../../test/fakeEnvironment";
-import { render } from "../../test/render";
+import { act, render } from "../../test/render";
// The socket is the only fake layer; see test/appScenarios.renders.tsx.
vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
@@ -91,3 +95,55 @@ it("opens the thread actions menu for an older settled thread the shell leaves o
.element(page.getByRole("menuitem", { name: "Rename thread" }), { timeout: 5_000 })
.toBeVisible();
});
+
+it("a mounted thread view lets go of the thread structures it has replaced", async () => {
+ // Items are shared with the fixture, so a reopen's snapshot also carries the earlier updates.
+ const history = threadWithHistory("updated", 3);
+ const items = [...history.items];
+ const updated = { ...history, items };
+ const other = threadWithHistory("other", 3);
+ using _environment = installFakeEnvironment([updated, other]);
+ __setClientSettingsForTests({
+ ...DEFAULT_CLIENT_SETTINGS,
+ onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
+ });
+ const router = getRouter(createMemoryHistory({ initialEntries: ["/"] }));
+ await router.load();
+ await using _app = await render(, { router: false });
+ const open = async (threadId: ThreadId, text: string) => {
+ await act(() =>
+ router.navigate({
+ to: "/$environmentId/$threadId",
+ params: { environmentId: ENVIRONMENT_ID, threadId },
+ }),
+ );
+ await expect.element(page.getByText(text).first()).toBeVisible();
+ };
+ const structureAtom = environmentThreadDetails.quietThreadAtom(
+ scopeThreadRef(ENVIRONMENT_ID, updated.id),
+ );
+
+ // Each round, a new answer arrives while the thread is shown, then the user switches away.
+ const shown: Array> = [];
+ await open(other.id, "Answer 2 for other.");
+ for (let round = 0; round < 6; round++) {
+ await open(updated.id, round === 0 ? "Answer 2 for updated." : `Update ${round - 1}.`);
+ const update = assistantItem(
+ updated.id,
+ RunId.make("updated-run-2"),
+ `updated-update-${round}`,
+ 6 + round,
+ `Update ${round}.`,
+ );
+ items.push(update);
+ await act(() => emitThreadEvent(updated.id, { type: "turn-item.updated", payload: update }));
+ await expect.element(page.getByText(`Update ${round}.`)).toBeVisible();
+ shown.push(new WeakRef(appAtomRegistry.get(structureAtom)!.projection));
+ await open(other.id, "Answer 2 for other.");
+ }
+ await cdp().send("HeapProfiler.collectGarbage");
+
+ // The thread keeps its latest structure, and the timeline may remember the one before it.
+ const kept = shown.slice(0, -2).flatMap((structure, round) => (structure.deref() ? [round] : []));
+ expect(kept).toEqual([]);
+});
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index f4561188df0c..7d021e3935e3 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -187,7 +187,6 @@ import { isEditableFocused } from "../lib/editableFocus";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
import { shortcutLabelForCommand } from "../keybindings";
import { cn } from "~/lib/utils";
-import { isModelPickerOpen } from "../modelPickerVisibility";
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
import { toastManager } from "./ui/toast";
import { projectScriptIdFromCommand } from "~/projectScripts";
@@ -345,6 +344,7 @@ import {
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { useComposerHandleContext } from "../composerHandleContext";
+import { useComposerFocus } from "./chat/useComposerFocus";
import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { assetEnvironment } from "../state/assets";
@@ -1795,10 +1795,8 @@ export default function ChatView(props: ChatViewProps) {
sendInFlightRef,
});
- const focusComposer = () => {
- composerRef.current?.focusAtEnd();
- };
- const focusComposerStable = useStableCallback(focusComposer);
+ const { focusComposer: focusComposerStable, scheduleComposerFocus: scheduleComposerFocusStable } =
+ useComposerFocus(composerRef);
const canInterruptRunningThread =
canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime);
const onInterrupt = async () => {
@@ -1825,13 +1823,6 @@ export default function ChatView(props: ChatViewProps) {
() => (embedded ? undefined : subscribeSnapShotComposerFocus(focusComposerStable)),
[embedded, focusComposerStable],
);
- const scheduleComposerFocus = () => {
- window.requestAnimationFrame(() => {
- // The model picker stays open through its own changes, so it keeps focus.
- if (!isModelPickerOpen()) focusComposer();
- });
- };
- const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus);
const insertArtifactTemplate = (template: CodexArtifactTemplate) => {
const composer = composerRef.current;
if (!composer) return;
@@ -1846,7 +1837,7 @@ export default function ChatView(props: ChatViewProps) {
});
return;
}
- scheduleComposerFocus();
+ scheduleComposerFocusStable();
};
const insertArtifactTemplateStable = useStableCallback(insertArtifactTemplate);
const sendAppMessage = async (text: string) => {
@@ -2851,7 +2842,7 @@ export default function ChatView(props: ChatViewProps) {
canOverrideServerThreadEnvMode,
setPendingServerThreadEnvModeEntry,
setPendingServerThreadStartFromOriginByThreadId,
- scheduleComposerFocus,
+ scheduleComposerFocus: scheduleComposerFocusStable,
});
const { onResume, isResuming } = useComposerResume({
environmentId,
diff --git a/apps/web/src/components/chat/useComposerFocus.browser.test.tsx b/apps/web/src/components/chat/useComposerFocus.browser.test.tsx
new file mode 100644
index 000000000000..e153c2669170
--- /dev/null
+++ b/apps/web/src/components/chat/useComposerFocus.browser.test.tsx
@@ -0,0 +1,65 @@
+import type { RefObject } from "react";
+import { expect, it, vi } from "vite-plus/test";
+
+import type { ChatComposerHandle } from "./ChatComposer";
+import { registerModelPickerContent } from "../../modelPickerVisibility";
+import { render } from "../../../test/render";
+import { useComposerFocus } from "./useComposerFocus";
+
+type Focus = ReturnType;
+
+function Probe(props: {
+ readonly composerRef: RefObject;
+ readonly onRender: (focus: Focus) => void;
+}) {
+ props.onRender(useComposerFocus(props.composerRef));
+ return null;
+}
+
+const handle = (focusAtEnd: () => void) => ({ focusAtEnd }) as unknown as ChatComposerHandle;
+
+it("focuses the composer's current handle without changing identity", async () => {
+ const composerRef: RefObject = { current: null };
+ const renders: Focus[] = [];
+ const onRender = (focus: Focus) => renders.push(focus);
+ await using screen = await render();
+
+ const replaced = vi.fn();
+ const current = vi.fn();
+ composerRef.current = handle(replaced);
+ // The composer replaces its handle as the thread changes, with no render here in between.
+ composerRef.current = handle(current);
+ renders.at(-1)!.focusComposer();
+ expect(current).toHaveBeenCalledTimes(1);
+
+ await screen.rerender();
+ expect(renders.at(-1)!.focusComposer).toBe(renders[0]!.focusComposer);
+ expect(renders.at(-1)!.scheduleComposerFocus).toBe(renders[0]!.scheduleComposerFocus);
+ expect(replaced).not.toHaveBeenCalled();
+});
+
+it("schedules focus for the next frame unless the model picker is open by then", async () => {
+ const focusAtEnd = vi.fn();
+ const composerRef: RefObject = { current: handle(focusAtEnd) };
+ const renders: Focus[] = [];
+ await using _screen = await render(
+ renders.push(focus)} />,
+ );
+ const { scheduleComposerFocus } = renders.at(-1)!;
+ const frames: FrameRequestCallback[] = [];
+ using _frames = vi.spyOn(window, "requestAnimationFrame").mockImplementation((frame) => {
+ frames.push(frame);
+ return frames.length;
+ });
+
+ scheduleComposerFocus();
+ expect(focusAtEnd).not.toHaveBeenCalled();
+ frames.shift()!(0);
+ expect(focusAtEnd).toHaveBeenCalledTimes(1);
+
+ scheduleComposerFocus();
+ const closeModelPicker = registerModelPickerContent();
+ frames.shift()!(0);
+ closeModelPicker();
+ expect(focusAtEnd).toHaveBeenCalledTimes(1);
+});
diff --git a/apps/web/src/components/chat/useComposerFocus.ts b/apps/web/src/components/chat/useComposerFocus.ts
new file mode 100644
index 000000000000..38158e26033a
--- /dev/null
+++ b/apps/web/src/components/chat/useComposerFocus.ts
@@ -0,0 +1,21 @@
+import type { ComposerHandleRef } from "../../composerHandleContext";
+import { useStableCallback } from "../../hooks/useStableCallback";
+import { isModelPickerOpen } from "../../modelPickerVisibility";
+
+/**
+ * Made outside ChatView's render: a memoized closure there keeps that render's scope, thread
+ * projection included, and the scope keeps the previous render's, so every thread copy
+ * ChatView showed stayed in memory until it unmounted.
+ */
+export function useComposerFocus(composerRef: ComposerHandleRef) {
+ const focusComposer = useStableCallback(() => {
+ composerRef.current?.focusAtEnd();
+ });
+ const scheduleComposerFocus = useStableCallback(() => {
+ window.requestAnimationFrame(() => {
+ // The model picker stays open through its own changes, so it keeps focus.
+ if (!isModelPickerOpen()) focusComposer();
+ });
+ });
+ return { focusComposer, scheduleComposerFocus };
+}