From cb21743df858ae94b245c8e4656cdba820f81a00 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 23:16:46 +0000 Subject: [PATCH 1/3] test(web): reproduce sidebar double-click rename losing focus (#27195) Mounts the app on thread alpha, double-clicks thread beta's sidebar row and types. On main the composer focus that runs when beta opens takes focus from the rename input the same gesture opened, so the input commits unchanged and closes. This test reproduces pingdotgg/t3code#27195 and fails until that is fixed. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 apps/web/src/components/ChatView.browser.test.tsx diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx new file mode 100644 index 000000000000..c3e2a12a2f1d --- /dev/null +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -0,0 +1,58 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +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 "~/index.css"; + +import { AppRoot } from "~/AppRoot"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { getRouter } from "~/router"; +import { + ENVIRONMENT_ID, + installFakeEnvironment, + threadWithHistory, + type FakeDriver, +} from "../../test/fakeEnvironment"; +import { render } from "../../test/render"; + +vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { + const [actual, { fakeDriver }, Layer] = await Promise.all([ + importOriginal<{ readonly ConnectionDriver: Context.Key }>(), + import("../../test/fakeEnvironment"), + import("effect/Layer"), + ]); + return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; +}); + +async function mountAppOnThread(threadId: string) { + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + await page.viewport(1400, 900); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${threadId}`] }), + ); + await router.load(); + const container = document.createElement("div"); + container.style.cssText = "height: 900px; width: 1400px;"; + // The app owns its router, as main.tsx does. + return render(, { container, router: false }); +} + +it("keeps the sidebar rename input when a double-click opens another thread", async () => { + const [alpha, beta] = [threadWithHistory("alpha", 1), threadWithHistory("beta", 1)]; + using _environment = installFakeEnvironment([alpha, beta]); + await using _app = await mountAppOnThread(alpha.id); + await expect.element(page.getByRole("textbox", { name: `Message ${alpha.title}` })).toHaveFocus(); + + await userEvent.dblClick(page.getByRole("treeitem", { name: beta.title, exact: false })); + await expect.element(page.getByRole("textbox", { name: `Message ${beta.title}` })).toBeVisible(); + await userEvent.keyboard(" renamed"); + + // Checked once: by now beta has opened and any focus it takes has landed, and a polled + // assertion would outlast the test budget that mounting the whole app leaves. + expect(document.activeElement?.getAttribute("aria-label")).toBe("Thread title"); +}); From 34a88ed81339d09a9c9c4c04918e2be17263699f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 23:25:16 +0000 Subject: [PATCH 2/3] fix(web): double-clicking another thread's sidebar row keeps its rename field open The double-click that renames a thread also opens it, and the composer focus that runs when a thread opens took focus from the new rename field, which committed the unchanged title and closed. Thread-open focus now leaves an input inside a sidebar row alone; every other thread-open path still lands in the composer. Fixes pingdotgg/t3code#27195 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1bf6f9b0fc0e..c06520534a36 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2097,6 +2097,8 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { + // Double-clicking a sidebar row opens its thread and its rename field in one gesture. + if (document.activeElement?.matches("[data-sidebar-row] input")) return; focusComposerStable(); }); return () => { From adc83abfc7e90ba94606e77e2391fc49d824c968 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 29 Oct 2026 23:43:42 +0000 Subject: [PATCH 3/3] test(web): cover the sidebar rename focus guard without booting the app The #27195 regression test mounted AppRoot with a fake environment and took ~10s of the browser project's 15s budget. The thread-open composer focus effect, guard included, now lives in useFocusComposerOnThreadOpen, and a focused browser test drives it with fake frames in under 100ms. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 58 ------------------- apps/web/src/components/ChatView.tsx | 18 ++---- ...FocusComposerOnThreadOpen.browser.test.tsx | 36 ++++++++++++ .../chat/useFocusComposerOnThreadOpen.ts | 21 +++++++ 4 files changed, 63 insertions(+), 70 deletions(-) delete mode 100644 apps/web/src/components/ChatView.browser.test.tsx create mode 100644 apps/web/src/components/chat/useFocusComposerOnThreadOpen.browser.test.tsx create mode 100644 apps/web/src/components/chat/useFocusComposerOnThreadOpen.ts diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx deleted file mode 100644 index c3e2a12a2f1d..000000000000 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { createMemoryHistory } from "@tanstack/react-router"; -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 "~/index.css"; - -import { AppRoot } from "~/AppRoot"; -import { __setClientSettingsForTests } from "~/hooks/useSettings"; -import { getRouter } from "~/router"; -import { - ENVIRONMENT_ID, - installFakeEnvironment, - threadWithHistory, - type FakeDriver, -} from "../../test/fakeEnvironment"; -import { render } from "../../test/render"; - -vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { - const [actual, { fakeDriver }, Layer] = await Promise.all([ - importOriginal<{ readonly ConnectionDriver: Context.Key }>(), - import("../../test/fakeEnvironment"), - import("effect/Layer"), - ]); - return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; -}); - -async function mountAppOnThread(threadId: string) { - __setClientSettingsForTests({ - ...DEFAULT_CLIENT_SETTINGS, - onboardingCompletedAt: "2026-01-01T00:00:00.000Z", - }); - await page.viewport(1400, 900); - const router = getRouter( - createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${threadId}`] }), - ); - await router.load(); - const container = document.createElement("div"); - container.style.cssText = "height: 900px; width: 1400px;"; - // The app owns its router, as main.tsx does. - return render(, { container, router: false }); -} - -it("keeps the sidebar rename input when a double-click opens another thread", async () => { - const [alpha, beta] = [threadWithHistory("alpha", 1), threadWithHistory("beta", 1)]; - using _environment = installFakeEnvironment([alpha, beta]); - await using _app = await mountAppOnThread(alpha.id); - await expect.element(page.getByRole("textbox", { name: `Message ${alpha.title}` })).toHaveFocus(); - - await userEvent.dblClick(page.getByRole("treeitem", { name: beta.title, exact: false })); - await expect.element(page.getByRole("textbox", { name: `Message ${beta.title}` })).toBeVisible(); - await userEvent.keyboard(" renamed"); - - // Checked once: by now beta has opened and any focus it takes has landed, and a polled - // assertion would outlast the test budget that mounting the whole app leaves. - expect(document.activeElement?.getAttribute("aria-label")).toBe("Thread title"); -}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c06520534a36..867424ed77b0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4,6 +4,7 @@ import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { queuedRunEditBlockedReason } from "@t3tools/client-runtime/queued-run-edit"; import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; +import { useFocusComposerOnThreadOpen } from "./chat/useFocusComposerOnThreadOpen"; import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnouncement"; import { deriveAvailableRevertTurnCounts, @@ -2093,18 +2094,11 @@ export default function ChatView(props: ChatViewProps) { setTimelineAnchor, }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a frame to focus the composer DOM element when a thread opens with the terminal closed - useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen) return; - const frame = window.requestAnimationFrame(() => { - // Double-clicking a sidebar row opens its thread and its rename field in one gesture. - if (document.activeElement?.matches("[data-sidebar-row] input")) return; - focusComposerStable(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [activeThread?.id, focusComposerStable, terminalUiState.terminalOpen]); + useFocusComposerOnThreadOpen( + activeThread?.id, + !terminalUiState.terminalOpen, + focusComposerStable, + ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus useEffect(() => { diff --git a/apps/web/src/components/chat/useFocusComposerOnThreadOpen.browser.test.tsx b/apps/web/src/components/chat/useFocusComposerOnThreadOpen.browser.test.tsx new file mode 100644 index 000000000000..864f4c707949 --- /dev/null +++ b/apps/web/src/components/chat/useFocusComposerOnThreadOpen.browser.test.tsx @@ -0,0 +1,36 @@ +import { useRef } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, fakeTimers, render } from "../../../test/render"; +import { useFocusComposerOnThreadOpen } from "./useFocusComposerOnThreadOpen"; + +function ThreadProbe(props: { threadId: string }) { + const composerRef = useRef(null); + useFocusComposerOnThreadOpen(props.threadId, true, () => composerRef.current?.focus()); + return ( + <> +
+ +
+