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 }; +}