From b72d2d4db111739a1b24cc3ef056ab0e58c6f69d Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:30:47 +1100 Subject: [PATCH 1/3] fix(web): thread navigation shortcuts honor previewFocus and previewOpen conditions Co-Authored-By: Claude Opus 5.5 --- .../Sidebar.shortcuts.browser.test.tsx | 90 +++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 9 ++ 2 files changed, 99 insertions(+) create mode 100644 apps/web/src/components/Sidebar.shortcuts.browser.test.tsx diff --git a/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx b/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx new file mode 100644 index 0000000000000..b6965d9700fa6 --- /dev/null +++ b/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx @@ -0,0 +1,90 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +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 { AppRoot } from "~/AppRoot"; +// The thread route loads ChatView lazily; its first transform would eat the test's timeout. +import "~/components/ChatView"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { getRouter } from "~/router"; +import { + ENVIRONMENT_ID, + installFakeEnvironment, + threadWithHistory, + type FakeDriver, +} from "../../test/fakeEnvironment"; +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) => { + 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) }; +}); + +vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ + ...(await original()), + trackRpcRequestSent: () => {}, +})); + +vi.mock(import("~/state/server"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const { compileResolvedKeybindingsConfig } = await import("@t3tools/shared/keybindings"); + const keybindings = compileResolvedKeybindingsConfig([ + { key: "mod+shift+]", command: "thread.next", when: "!previewFocus && !previewOpen" }, + ]); + return { ...actual, primaryServerKeybindingsAtom: Atom.make(keybindings) }; +}); + +it("leaves thread.next to the preview when its rule excludes previewFocus and previewOpen", async () => { + const [first, second] = [threadWithHistory("thread-a", 1), threadWithHistory("thread-b", 1)]; + using _environment = installFakeEnvironment([first, second]); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + await page.viewport(1400, 900); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${first.id}`] }), + ); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByText(`Thread ${second.id}`).first()).toBeVisible(); + + const previewChrome = document.createElement("div"); + previewChrome.dataset.previewPanelMode = "browser"; + previewChrome.innerHTML = ''; + document.body.append(previewChrome); + using _chrome = { [Symbol.dispose]: () => previewChrome.remove() }; + + const press = () => userEvent.keyboard("{Control>}{Shift>}]{/Shift}{/Control}"); + const firstPath = `/${ENVIRONMENT_ID}/${first.id}`; + + await userEvent.click(page.getByRole("button", { name: "Reload" })); + await press(); + expect(router.history.location.pathname).toBe(firstPath); + + const firstRef = scopeThreadRef(ENVIRONMENT_ID, first.id); + await act(() => useRightPanelStore.getState().openBrowser(firstRef, null)); + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + await press(); + expect(router.history.location.pathname).toBe(firstPath); + + await act(() => useRightPanelStore.getState().close(firstRef)); + await press(); + await vi.waitFor(() => + expect(router.history.location.pathname).toBe(`/${ENVIRONMENT_ID}/${second.id}`), + ); +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index deede26763fdd..dd8cbf063236c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -99,8 +99,10 @@ import { } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { isPreviewFocused } from "../lib/previewFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { isMacPlatform } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; @@ -2881,6 +2883,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); + const routePreviewOpen = useRightPanelStore( + (state) => selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview", + ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown for thread navigation shortcuts using the current thread list useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { @@ -2892,6 +2897,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve context: { terminalFocus: isTerminalFocused(), terminalOpen: routeTerminalOpen, + previewFocus: isPreviewFocused(), + previewOpen: routePreviewOpen, modelPickerOpen: isModelPickerOpen(), isWeb: !isElectron, isDesktop: isElectron, @@ -2927,6 +2934,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve keybindings, navigateToThread, orderedThreadKeys, + routePreviewOpen, routeRowThreadKey, routeTerminalOpen, threadByKey, @@ -2940,6 +2948,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve context: { terminalFocus: terminalFocused, terminalOpen: routeTerminalOpen, + previewOpen: routePreviewOpen, modelPickerOpen: isModelPickerOpen(), isWeb: !isElectron, isDesktop: isElectron, From bb183f5fe9e75698a2739f669d06ce097f1ef271 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:01:47 +1100 Subject: [PATCH 2/3] fix(web): thread jump hints stay hidden while the preview has focus Co-Authored-By: Claude Opus 5.5 --- .../components/Sidebar.shortcuts.browser.test.tsx | 15 ++++++++++++++- apps/web/src/components/Sidebar.tsx | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx b/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx index b6965d9700fa6..010b17c122bc4 100644 --- a/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx +++ b/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx @@ -40,11 +40,12 @@ vi.mock(import("~/state/server"), async (original) => { const { compileResolvedKeybindingsConfig } = await import("@t3tools/shared/keybindings"); const keybindings = compileResolvedKeybindingsConfig([ { key: "mod+shift+]", command: "thread.next", when: "!previewFocus && !previewOpen" }, + { key: "mod+1", command: "thread.jump.1", when: "!previewFocus" }, ]); return { ...actual, primaryServerKeybindingsAtom: Atom.make(keybindings) }; }); -it("leaves thread.next to the preview when its rule excludes previewFocus and previewOpen", async () => { +it("leaves thread shortcuts and their hints to the preview when their rules exclude it", async () => { const [first, second] = [threadWithHistory("thread-a", 1), threadWithHistory("thread-b", 1)]; using _environment = installFakeEnvironment([first, second]); using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); @@ -70,11 +71,22 @@ it("leaves thread.next to the preview when its rule excludes previewFocus and pr using _chrome = { [Symbol.dispose]: () => previewChrome.remove() }; const press = () => userEvent.keyboard("{Control>}{Shift>}]{/Shift}{/Control}"); + const jumpHintsShownOnHold = async () => { + // Every deadline reads as due on subscribe, so hints skip the hold delay. + let now = Date.parse("2026-01-01T00:00:00.000Z"); + using _clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 1000)); + await userEvent.keyboard("{Control>}"); + await act(() => {}); + const shown = document.querySelector("[data-jump-hint]") !== null; + await userEvent.keyboard("{/Control}"); + return shown; + }; const firstPath = `/${ENVIRONMENT_ID}/${first.id}`; await userEvent.click(page.getByRole("button", { name: "Reload" })); await press(); expect(router.history.location.pathname).toBe(firstPath); + expect(await jumpHintsShownOnHold()).toBe(false); const firstRef = scopeThreadRef(ENVIRONMENT_ID, first.id); await act(() => useRightPanelStore.getState().openBrowser(firstRef, null)); @@ -87,4 +99,5 @@ it("leaves thread.next to the preview when its rule excludes previewFocus and pr await vi.waitFor(() => expect(router.history.location.pathname).toBe(`/${ENVIRONMENT_ID}/${second.id}`), ); + expect(await jumpHintsShownOnHold()).toBe(true); }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dd8cbf063236c..3eebea174ba1d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2948,6 +2948,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve context: { terminalFocus: terminalFocused, terminalOpen: routeTerminalOpen, + previewFocus: isPreviewFocused(), previewOpen: routePreviewOpen, modelPickerOpen: isModelPickerOpen(), isWeb: !isElectron, From 439c1c28e3a2532a28d6ecebd6b80040f86fee8f Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:38:40 +1100 Subject: [PATCH 3/3] test(web): assert sidebar jump hints with browser assertions Co-Authored-By: Claude Opus 5.5 --- .../src/components/Sidebar.shortcuts.browser.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx b/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx index 010b17c122bc4..44998d58841bc 100644 --- a/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx +++ b/apps/web/src/components/Sidebar.shortcuts.browser.test.tsx @@ -71,22 +71,22 @@ it("leaves thread shortcuts and their hints to the preview when their rules excl using _chrome = { [Symbol.dispose]: () => previewChrome.remove() }; const press = () => userEvent.keyboard("{Control>}{Shift>}]{/Shift}{/Control}"); - const jumpHintsShownOnHold = async () => { + const jumpHint = () => document.querySelector("[data-jump-hint]"); + const whileHoldingControl = async (assertJumpHints: () => void) => { // Every deadline reads as due on subscribe, so hints skip the hold delay. let now = Date.parse("2026-01-01T00:00:00.000Z"); using _clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 1000)); await userEvent.keyboard("{Control>}"); await act(() => {}); - const shown = document.querySelector("[data-jump-hint]") !== null; + await vi.waitFor(assertJumpHints); await userEvent.keyboard("{/Control}"); - return shown; }; const firstPath = `/${ENVIRONMENT_ID}/${first.id}`; await userEvent.click(page.getByRole("button", { name: "Reload" })); await press(); expect(router.history.location.pathname).toBe(firstPath); - expect(await jumpHintsShownOnHold()).toBe(false); + await whileHoldingControl(() => expect(jumpHint()).toBeNull()); const firstRef = scopeThreadRef(ENVIRONMENT_ID, first.id); await act(() => useRightPanelStore.getState().openBrowser(firstRef, null)); @@ -99,5 +99,5 @@ it("leaves thread shortcuts and their hints to the preview when their rules excl await vi.waitFor(() => expect(router.history.location.pathname).toBe(`/${ENVIRONMENT_ID}/${second.id}`), ); - expect(await jumpHintsShownOnHold()).toBe(true); + await whileHoldingControl(() => expect(jumpHint()).not.toBeNull()); });