Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions apps/web/src/components/ChatView.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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(<AppRoot router={router} />, { 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<WeakRef<object>> = [];
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([]);
});
19 changes: 5 additions & 14 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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;
Expand All @@ -1884,7 +1875,7 @@ export default function ChatView(props: ChatViewProps) {
});
return;
}
scheduleComposerFocus();
scheduleComposerFocusStable();
};
const insertArtifactTemplateStable = useStableCallback(insertArtifactTemplate);
const sendAppMessage = async (text: string) => {
Expand Down Expand Up @@ -2890,7 +2881,7 @@ export default function ChatView(props: ChatViewProps) {
canOverrideServerThreadEnvMode,
setPendingServerThreadEnvModeEntry,
setPendingServerThreadStartFromOriginByThreadId,
scheduleComposerFocus,
scheduleComposerFocus: scheduleComposerFocusStable,
});
const { onResume, isResuming } = useComposerResume({
environmentId,
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/chat/useComposerFocus.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading