diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx
index 46557548fec6..7b164e811542 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, scopeThreadRef } from "@t3tools/client-runtime/environment";
-import { ThreadId } from "@t3tools/contracts";
+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, userEvent } from "vite-plus/test/browser";
+import { cdp, page, userEvent } from "vite-plus/test/browser";
import { AppRoot } from "~/AppRoot";
// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines)
@@ -15,10 +15,14 @@ import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelSt
import { __setClientSettingsForTests } from "~/hooks/useSettings";
import { useRightPanelStore } from "~/rightPanelStore";
import { getRouter } from "~/router";
+import { appAtomRegistry } from "~/rpc/atomRegistry";
+import { environmentThreadDetails } from "~/state/threads";
import {
ENVIRONMENT_ID,
PROJECT_ID,
+ assistantItem,
dispatchedCommands,
+ emitThreadEvent,
installFakeEnvironment,
rejectPendingCommand,
threadWithHistory,
@@ -303,3 +307,55 @@ it("opening a thread or returning to the window on a touch tablet does not focus
vi.advanceTimersToNextFrame();
expect(document.activeElement).not.toBe(composer.element());
});
+
+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 4a964ad24dbd..754edc3912db 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -19,6 +19,7 @@ import {
import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread";
import { usePullRequestLinking } from "../hooks/usePullRequestLinking";
import { useComposerSelection } from "./chat/useComposerSelection";
+import { useComposerFocus } from "./chat/useComposerFocus";
import { useProjectCloneBanner } from "./chat/useProjectCloneBanner";
import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff";
import {
@@ -195,7 +196,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";
@@ -1833,10 +1833,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 () => {
@@ -1863,13 +1861,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;
@@ -1884,7 +1875,7 @@ export default function ChatView(props: ChatViewProps) {
});
return;
}
- scheduleComposerFocus();
+ scheduleComposerFocusStable();
};
const insertArtifactTemplateStable = useStableCallback(insertArtifactTemplate);
const sendAppMessage = async (text: string) => {
@@ -2890,7 +2881,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.ts b/apps/web/src/components/chat/useComposerFocus.ts
new file mode 100644
index 000000000000..2dbf82308f45
--- /dev/null
+++ b/apps/web/src/components/chat/useComposerFocus.ts
@@ -0,0 +1,18 @@
+import type { ComposerHandleRef } from "../../composerHandleContext";
+import { useStableCallback } from "../../hooks/useStableCallback";
+import { isModelPickerOpen } from "../../modelPickerVisibility";
+
+// Kept out of ChatView: a compiler-memoized closure there pins its render's scope, which pins the
+// previous render's, so every thread copy ChatView showed stayed alive 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 };
+}