From efcc7dcbfd17340bc0b64c92c1065f8018f845ef Mon Sep 17 00:00:00 2001 From: Maaz Chowdhry Date: Sat, 24 Oct 2026 18:10:16 +0100 Subject: [PATCH 1/4] feat: add back and forward navigation controls --- apps/desktop/src/window/DesktopWindow.ts | 7 ++ apps/web/src/components/AppSidebarLayout.tsx | 6 +- .../components/NavigationHistoryControls.tsx | 58 +++++++++++++++ .../sidebar/mainAppLocation.browser.test.tsx | 74 +++++++++++++++++++ .../src/components/sidebar/mainAppLocation.ts | 41 ++++++++-- apps/web/src/navigationHistory.test.ts | 55 ++++++++++++++ apps/web/src/navigationHistory.ts | 32 ++++++++ 7 files changed, 264 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/NavigationHistoryControls.tsx create mode 100644 apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx create mode 100644 apps/web/src/navigationHistory.test.ts create mode 100644 apps/web/src/navigationHistory.ts diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 297c67ddace08..6572916a65938 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -694,6 +694,13 @@ export const make = Effect.gen(function* () { }); if (environment.platform === "darwin") { + // Some macOS mouse drivers send Back/Forward as page-swipe gestures, + // rather than DOM mouse button events. + window.on("swipe", (_event, direction) => { + const history = window.webContents.navigationHistory; + if (direction === "left" && history.canGoBack()) history.goBack(); + else if (direction === "right" && history.canGoForward()) history.goForward(); + }); window.on("enter-full-screen", () => { window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); }); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index e3213614c68ef..f2b6da621f654 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -38,6 +38,7 @@ import { usePanelNavigationSuppression, } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; +import { NavigationHistoryControls } from "./NavigationHistoryControls"; import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; @@ -204,6 +205,7 @@ function SidebarControl() { Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + ); } @@ -372,6 +374,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const sidebarProviderStyle = { "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`, "--panel-animation-duration": `${panelAnimationDurationMs}ms`, + "--workspace-titlebar-content-left": + "calc(var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap) + var(--workspace-history-controls-width))", ...windowControlsInsetStyle, } as CSSProperties; @@ -398,7 +402,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return ( { + const navigation = window.navigation; + const currentEntry = navigation?.currentEntry; + const forwardEntries = currentEntry ? navigation.entries().length - currentEntry.index - 1 : 0; + return createNavigationHistoryStore(history, forwardEntries); + }); + const { canGoBack, canGoForward } = useSyncExternalStore(store.subscribe, store.getSnapshot); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const controls = [ + ["Back", "navigation.back", ArrowLeftIcon, canGoBack, () => history.back()], + ["Forward", "navigation.forward", ArrowRightIcon, canGoForward, () => history.forward()], + ] as const; + + return ( +
+ {controls.map(([label, command, Icon, enabled, onClick]) => { + const shortcut = shortcutLabelForCommand(keybindings, command); + return ( + + }> + + + + {label} + {shortcut ? ` (${shortcut})` : ""} + + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx b/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx new file mode 100644 index 0000000000000..cf75800920d7e --- /dev/null +++ b/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { MainAppLocationTracker, useNavigateToMainApp } from "./mainAppLocation"; + +function LeaveUtilityPage() { + const navigateToMainApp = useNavigateToMainApp(); + return ( + <> + + + + ); +} + +describe("leaving a utility page", () => { + it("keeps the remembered entry when traversing past it without overwriting it", async () => { + await using screen = await render(, { router: "/settings" }); + const router = screen.router!; + + await act(() => router.navigate({ href: "/env/thread-a" })); + await act(() => router.navigate({ href: "/settings/general" })); + await act(() => router.history.go(-2)); + await act(() => router.history.go(2)); + await screen.getByRole("button", { name: "Back to threads" }).click(); + + expect(router.state.location.pathname).toBe("/env/thread-a"); + await act(() => router.history.forward()); + expect(router.state.location.pathname).toBe("/settings/general"); + }); + + it("does not traverse to a utility page that replaced the remembered entry", async () => { + await using screen = await render(, { router: "/env/thread-a" }); + const router = screen.router!; + + await act(() => router.navigate({ href: "/settings/general", replace: true })); + await act(() => router.navigate({ href: "/usage" })); + await screen.getByRole("button", { name: "Back to threads" }).click(); + expect(router.state.location.pathname).toBe("/env/thread-a"); + }); + + it("closing Settings preserves earlier threads and forward history", async () => { + await using screen = await render(, { router: "/env/thread-a" }); + const router = screen.router!; + + await act(() => router.navigate({ href: "/env/thread-b" })); + await act(() => router.navigate({ href: "/settings/general" })); + await act(() => router.navigate({ href: "/settings/appearance" })); + await act(() => router.navigate({ href: "/settings/open-source-licenses" })); + await screen.getByRole("button", { name: "Back to threads" }).click(); + expect(router.state.location.pathname).toBe("/env/thread-b"); + + await act(() => router.history.back()); + expect(router.state.location.pathname).toBe("/env/thread-a"); + await act(() => router.history.forward()); + expect(router.state.location.pathname).toBe("/env/thread-b"); + await act(() => router.history.forward()); + expect(router.state.location.pathname).toBe("/settings/general"); + }); + + it("returns to the remembered thread after its history entry is replaced", async () => { + await using screen = await render(, { router: "/settings" }); + const router = screen.router!; + + await act(() => router.navigate({ href: "/env/thread-a" })); + await act(() => router.history.back()); + await act(() => router.navigate({ href: "/usage" })); + await act(() => router.navigate({ href: "/settings" })); + await screen.getByRole("button", { name: "Back to threads" }).click(); + expect(router.state.location.pathname).toBe("/env/thread-a"); + }); +}); diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index 2e9134101e0d1..5dff89c396b88 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -1,4 +1,4 @@ -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useNavigate, useRouter } from "@tanstack/react-router"; import { useEffect } from "react"; export function isSidebarUtilityPage(pathname: string) { @@ -11,19 +11,44 @@ export function isSidebarUtilityPage(pathname: string) { ); } -let mainAppHref: string | null = null; +let mainAppEntry: { href: string; historyIndex: number | null } | null = null; export function MainAppLocationTracker() { - const href = useLocation({ - select: (location) => (isSidebarUtilityPage(location.pathname) ? null : location.href), - }); + const { history } = useRouter(); useEffect(() => { - if (href !== null) mainAppHref = href; - }, [href]); + const { location } = history; + if (!isSidebarUtilityPage(location.pathname)) { + mainAppEntry = { href: location.href, historyIndex: location.state.__TSR_index }; + } + return history.subscribe(({ location, action }) => { + const historyIndex = location.state.__TSR_index; + if (!isSidebarUtilityPage(location.pathname)) { + mainAppEntry = { href: location.href, historyIndex }; + } else if ( + mainAppEntry?.historyIndex != null && + ((action.type === "PUSH" && historyIndex <= mainAppEntry.historyIndex) || + (action.type === "REPLACE" && historyIndex === mainAppEntry.historyIndex)) + ) { + mainAppEntry = { href: mainAppEntry.href, historyIndex: null }; + } + }); + }, [history]); return null; } export function useNavigateToMainApp() { const navigate = useNavigate(); - return () => navigate({ href: mainAppHref ?? "/" }); + const router = useRouter(); + return () => { + // Pushing the thread URL again would leave Settings in the way of earlier threads. + const stepsBack = + mainAppEntry?.historyIndex != null + ? router.history.location.state.__TSR_index - mainAppEntry.historyIndex + : 0; + if (stepsBack > 0) { + router.history.go(-stepsBack); + return; + } + void navigate({ href: mainAppEntry?.href ?? "/" }); + }; } diff --git a/apps/web/src/navigationHistory.test.ts b/apps/web/src/navigationHistory.test.ts new file mode 100644 index 0000000000000..7699f359cace0 --- /dev/null +++ b/apps/web/src/navigationHistory.test.ts @@ -0,0 +1,55 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { describe, expect, it } from "vite-plus/test"; + +import { createNavigationHistoryStore } from "./navigationHistory"; + +describe("navigation history controls", () => { + it("picks up navigation before the controls subscribe", () => { + const history = createMemoryHistory({ initialEntries: ["/"] }); + const store = createNavigationHistoryStore(history); + + history.push("/threads/one"); + expect(store.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); + history.push("/settings"); + const unsubscribe = store.subscribe(() => {}); + try { + history.back(); + expect(store.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); + } finally { + unsubscribe(); + } + }); + + it("restores forward availability when mounting inside existing history", () => { + const history = createMemoryHistory({ + initialEntries: ["/", "/threads/one", "/settings"], + initialIndex: 1, + }); + const store = createNavigationHistoryStore(history, 1); + expect(store.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); + }); + + it("keeps Forward across a replace and clears it on a new push", () => { + const history = createMemoryHistory({ initialEntries: ["/"] }); + const store = createNavigationHistoryStore(history); + const unsubscribe = store.subscribe(() => {}); + try { + expect(store.getSnapshot()).toEqual({ canGoBack: false, canGoForward: false }); + history.push("/threads/one"); + history.push("/settings"); + expect(store.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); + history.back(); + expect(store.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); + history.replace("/threads/two"); + expect(store.getSnapshot().canGoForward).toBe(true); + history.forward(); + expect(store.getSnapshot().canGoForward).toBe(false); + history.go(-2); + expect(store.getSnapshot()).toEqual({ canGoBack: false, canGoForward: true }); + history.push("/usage"); + expect(store.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); + } finally { + unsubscribe(); + } + }); +}); diff --git a/apps/web/src/navigationHistory.ts b/apps/web/src/navigationHistory.ts new file mode 100644 index 0000000000000..759d4dfc9ca61 --- /dev/null +++ b/apps/web/src/navigationHistory.ts @@ -0,0 +1,32 @@ +import type { RouterHistory } from "@tanstack/react-router"; + +export function createNavigationHistoryStore(history: RouterHistory, forwardEntries = 0) { + // Browser history.length includes pages outside T3, so only offer Forward + // for entries known to belong to this app. + let lastIndex = history.location.state.__TSR_index + forwardEntries; + const readSnapshot = () => ({ + canGoBack: history.canGoBack(), + canGoForward: history.location.state.__TSR_index < lastIndex, + }); + let snapshot = readSnapshot(); + const getSnapshot = () => { + lastIndex = Math.max(lastIndex, history.location.state.__TSR_index); + const next = readSnapshot(); + if (next.canGoBack !== snapshot.canGoBack || next.canGoForward !== snapshot.canGoForward) { + snapshot = next; + } + return snapshot; + }; + + return { + getSnapshot, + subscribe: (onChange: () => void) => { + getSnapshot(); + return history.subscribe(({ location, action }) => { + if (action.type === "PUSH") lastIndex = location.state.__TSR_index; + const previous = snapshot; + if (getSnapshot() !== previous) onChange(); + }); + }, + }; +} From 6746fd17dbbe61851df13fec4e0f248dd73c3b45 Mon Sep 17 00:00:00 2001 From: Maaz Chowdhry Date: Sat, 24 Oct 2026 18:55:42 +0100 Subject: [PATCH 2/4] fix(web): preserve settings, PR, and usage navigation history Settings categories and PR navigation replaced earlier visits, while Usage views only lived in local state. Record discrete navigation in the existing router history and restore the visible PR and Usage selection on Back and Forward. Keep query typing and automatic redirects out of the visit stack. --- .../pullRequest/PullRequestDetailPanel.tsx | 5 +- .../pullRequest/PullRequestsPage.tsx | 54 +++---- .../pullRequestsRouteSearch.test.ts | 7 + .../pullRequest/usePullRequestListView.ts | 2 + .../usePullRequestNavigation.browser.test.tsx | 141 ++++++++++++++++++ .../pullRequest/usePullRequestNavigation.ts | 61 ++++++++ .../SettingsSidebarNav.browser.test.tsx | 64 ++++++++ .../settings/SettingsSidebarNav.tsx | 2 - apps/web/src/components/usage/UsagePage.tsx | 28 ++-- .../usage/usagePageNavigation.browser.test.ts | 66 ++++++++ .../components/usage/usagePagePreferences.ts | 16 ++ .../useUsagePageNavigation.browser.test.tsx | 59 ++++++++ .../usage/useUsagePageNavigation.ts | 35 +++++ apps/web/src/lib/openPullRequestLink.ts | 2 +- apps/web/src/routes/_chat.pull-requests.tsx | 1 + apps/web/src/routes/usage.tsx | 19 ++- 16 files changed, 505 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/components/pullRequest/usePullRequestNavigation.browser.test.tsx create mode 100644 apps/web/src/components/pullRequest/usePullRequestNavigation.ts create mode 100644 apps/web/src/components/settings/SettingsSidebarNav.browser.test.tsx create mode 100644 apps/web/src/components/usage/usagePageNavigation.browser.test.ts create mode 100644 apps/web/src/components/usage/useUsagePageNavigation.browser.test.tsx create mode 100644 apps/web/src/components/usage/useUsagePageNavigation.ts diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index f223fe25008df..983f47b8177dd 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -321,7 +321,10 @@ export function PullRequestDetailPanel({ : null; const [threadPickerOpen, setThreadPickerOpen] = useState(false); const [openReviewRequest, setOpenReviewRequest] = useState(0); - const [tab, setTab] = useState(initialTab); + const [tabSelection, setTabSelection] = useState({ initialTab, tab: initialTab }); + const tab = tabSelection.initialTab === initialTab ? tabSelection.tab : initialTab; + if (tabSelection.initialTab !== initialTab) setTabSelection({ initialTab, tab: initialTab }); + const setTab = (next: PullRequestDetailTab) => setTabSelection({ initialTab, tab: next }); const selectTab = (next: PullRequestDetailTab) => { setTab(next); onTabChange?.(next); diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 84bfd8959175e..70db9d74a9e3c 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -18,7 +18,7 @@ import type { PullRequestListState, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useSearch } from "@tanstack/react-router"; import { ArrowDownUpIcon, LayersIcon, Plug2Icon } from "lucide-react"; import { useEffect, useEffectEvent, useRef, useState, type ComponentProps } from "react"; @@ -53,6 +53,10 @@ import { } from "./pullRequestListPreferences"; import { environmentMachineIcon } from "../EnvironmentMachineIcon"; import { PullRequestDetailPanel } from "./PullRequestDetailPanel"; +import { + usePullRequestHistorySelection, + usePullRequestNavigation, +} from "./usePullRequestNavigation"; import { PullRequestFiltersMenu, PullRequestSearchInput, @@ -68,7 +72,6 @@ import { usePullRequestDisplayGroups, usePullRequestQueryFilters, usePullRequestScope, - type PullRequestsSearch, } from "./usePullRequestListView"; import { pullRequestGroups, pullRequestListEntryId } from "./pullRequestListGroups.logic"; import { PullRequestListEmptyState } from "./PullRequestListEmptyState"; @@ -143,7 +146,6 @@ export function PullRequestsPage() { const sort = search.sort ?? "ready"; const statsPolicy: PullRequestStatsPolicy = sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible"; - const navigate = useNavigate({ from: "/pull-requests" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { capableEnvironments, @@ -195,38 +197,10 @@ export function PullRequestsPage() { (renderedPullRequestSurface?.environmentId as EnvironmentId | undefined) ?? selectedProject?.environmentId ?? null; - const updateSearch = (patch: { - [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined; - }) => - void navigate({ - search: (previous: PullRequestsSearch): PullRequestsSearch => { - const next = { ...previous, ...patch }; - return { - involvement: next.involvement ?? previous.involvement, - state: next.state ?? previous.state, - ...(next.sort && next.sort !== "ready" ? { sort: next.sort } : {}), - ...(next.repository ? { repository: next.repository } : {}), - ...(next.number ? { number: next.number } : {}), - ...(next.projectId ? { projectId: next.projectId } : {}), - ...(next.environmentId ? { environmentId: next.environmentId } : {}), - ...(next.host ? { host: next.host } : {}), - ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}), - ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}), - ...(next.selectedEnvironmentId - ? { selectedEnvironmentId: next.selectedEnvironmentId } - : {}), - ...(next.q ? { q: next.q } : {}), - ...(next.draft ? { draft: next.draft } : {}), - ...(next.review ? { review: next.review } : {}), - ...(next.checks ? { checks: next.checks } : {}), - ...(next.author ? { author: next.author } : {}), - ...(next.labels && next.labels.length > 0 ? { labels: next.labels } : {}), - }; - }, - replace: true, - }); + const updateSearch = usePullRequestNavigation(); const clearedSelection = { + tab: undefined, repository: undefined, number: undefined, selectedProjectId: undefined, @@ -999,10 +973,11 @@ export function PullRequestsPage() { if (rightPanelState.isOpen) setMaximized((value) => !value); }; const onGuideShown = () => setMaximized(true); - useEffect(() => { - if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; - useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); - }, [linkedSelection, pullRequestsSupported, rightPanelRef]); + usePullRequestHistorySelection( + pullRequestsSupported ? rightPanelRef : null, + linkedSelection, + search, + ); const selected = rightPanelState.isOpen && activePullRequestSurface !== null @@ -1027,6 +1002,7 @@ export function PullRequestsPage() { surface === null ? clearedSelection : { + tab: surface.detailTab, repository: surface.repository, number: surface.number, selectedProjectId: surface.projectId as ProjectId, @@ -1063,6 +1039,7 @@ export function PullRequestsPage() { if (rightPanelRef === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, entry); updateSearch({ + tab: undefined, repository: entry.repository, number: entry.number, selectedProjectId: entry.projectId, @@ -1483,6 +1460,8 @@ export function PullRequestsPage() { pullRequestStatusSeeds={listedPullRequestTabStatuses} > updateSearch({ tab })} getShortcutContext={getShortcutContext} shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id} key={renderedPullRequestSurface.id} @@ -1498,6 +1477,7 @@ export function PullRequestsPage() { environmentId: panelEnvironmentId, }); updateSearch({ + tab: undefined, repository: reference.repository, number: reference.number, selectedHost: reference.host, diff --git a/apps/web/src/components/pullRequest/pullRequestsRouteSearch.test.ts b/apps/web/src/components/pullRequest/pullRequestsRouteSearch.test.ts index 61938207ce1ff..bec59cd282806 100644 --- a/apps/web/src/components/pullRequest/pullRequestsRouteSearch.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestsRouteSearch.test.ts @@ -36,4 +36,11 @@ describe("pull requests route search", () => { expect(matchedSearch("/pull-requests?q=fix")?.q).toBe("fix"); expect(matchedSearch("/pull-requests?q=%224089%22")?.q).toBe("4089"); }); + + it("restores supported PR detail tabs and drops unknown tabs", () => { + for (const tab of ["code", "timeline", "guide"]) { + expect(matchedSearch(`/pull-requests?tab=${tab}`)?.tab).toBe(tab); + } + expect(matchedSearch("/pull-requests?tab=unknown")?.tab).toBeUndefined(); + }); }); diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts index 2629fb6a34b30..2ecab5585e8e3 100644 --- a/apps/web/src/components/pullRequest/usePullRequestListView.ts +++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts @@ -9,6 +9,7 @@ import { type PullRequestListState, } from "@t3tools/contracts"; import { useState } from "react"; +import type { PullRequestDetailTab } from "../../rightPanelStore"; import { useAllEnvironmentShellsBootstrapped, useProjects } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; @@ -41,6 +42,7 @@ import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; import { pullRequestListEntryId } from "./pullRequestListGroups.logic"; export interface PullRequestsSearch extends PullRequestListPreferences { + readonly tab?: PullRequestDetailTab | undefined; readonly environmentId?: EnvironmentId; readonly projectId?: ProjectId; readonly host?: string; diff --git a/apps/web/src/components/pullRequest/usePullRequestNavigation.browser.test.tsx b/apps/web/src/components/pullRequest/usePullRequestNavigation.browser.test.tsx new file mode 100644 index 0000000000000..db2b65f8264c5 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestNavigation.browser.test.tsx @@ -0,0 +1,141 @@ +import { useLocation } from "@tanstack/react-router"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { + usePullRequestHistorySelection, + usePullRequestNavigation, +} from "./usePullRequestNavigation"; +import { + PULL_REQUESTS_PANEL_REF, + selectSelectedRightPanelSurface, + selectThreadRightPanelState, + useRightPanelStore, +} from "../../rightPanelStore"; + +function HistorySelection() { + const raw = useLocation({ select: (location) => location.search }); + const search = { + repository: typeof raw.repository === "string" ? raw.repository : undefined, + number: typeof raw.number === "number" ? raw.number : undefined, + tab: raw.tab === "code" ? ("code" as const) : undefined, + }; + const selection = + search.repository && search.number + ? { + repository: search.repository, + number: search.number, + projectId: ProjectId.make("navigation-project"), + environmentId: EnvironmentId.make("navigation-environment"), + } + : null; + usePullRequestHistorySelection(PULL_REQUESTS_PANEL_REF, selection, search); + const panel = useRightPanelStore((state) => + selectThreadRightPanelState(state.byThreadKey, PULL_REQUESTS_PANEL_REF), + ); + const surface = useRightPanelStore((state) => + selectSelectedRightPanelSurface(state.byThreadKey, PULL_REQUESTS_PANEL_REF), + ); + return ( + + {panel.isOpen && surface?.kind === "pull-request" + ? `${surface.number}:${surface.detailTab ?? "summary"}` + : "list"} + + ); +} + +function Navigation() { + const updateSearch = usePullRequestNavigation(); + const href = useLocation({ select: (location) => location.href }); + return ( + <> + {href} + + + + + updateSearch({ q: event.target.value })} /> + + ); +} + +describe("PR navigation history", () => { + it("restores the visible selection and detail tab, and closes details on return to the list", async () => { + const previous = useRightPanelStore.getState(); + useRightPanelStore.setState({ byThreadKey: {} }); + using _store = { [Symbol.dispose]: () => useRightPanelStore.setState(previous, true) }; + await using screen = await render( + <> + + + , + { router: "/pull-requests?involvement=all&state=open" }, + ); + const router = screen.router!; + await screen.getByRole("button", { name: "PR 12", exact: true }).click(); + await screen.getByRole("button", { name: "Code", exact: true }).click(); + await expect.element(screen.getByLabelText("PR view")).toHaveTextContent("12:code"); + await act(() => router.history.back()); + await expect.element(screen.getByLabelText("PR view")).toHaveTextContent("12:summary"); + await act(() => router.history.back()); + await expect.element(screen.getByLabelText("PR view")).toHaveTextContent("list"); + await act(() => router.history.forward()); + await expect.element(screen.getByLabelText("PR view")).toHaveTextContent("12:summary"); + await act(() => router.history.forward()); + await expect.element(screen.getByLabelText("PR view")).toHaveTextContent("12:code"); + }); + + it("revisits the list, filters, and selected PRs before leaving the page", async () => { + await using screen = await render(, { router: "/env/thread-a" }); + const router = screen.router!; + await act(() => router.navigate({ href: "/pull-requests?involvement=all&state=open" })); + const list = router.state.location.href; + await screen.getByRole("button", { name: "Reviewing", exact: true }).click(); + const reviewing = router.state.location.href; + await screen.getByRole("button", { name: "PR 12", exact: true }).click(); + const firstPr = router.state.location.href; + await screen.getByRole("button", { name: "PR 34", exact: true }).click(); + const secondPr = router.state.location.href; + + for (const href of [firstPr, reviewing, list, "/env/thread-a"]) { + await act(() => router.history.back()); + expect(router.state.location.href).toBe(href); + } + for (const href of [list, reviewing, firstPr, secondPr]) { + await act(() => router.history.forward()); + expect(router.state.location.href).toBe(href); + } + }); + + it("keeps query edits in one entry without replacing a previously selected PR", async () => { + await using screen = await render(, { + router: "/pull-requests?involvement=all&state=open", + }); + const router = screen.router!; + await screen.getByRole("button", { name: "PR 12", exact: true }).click(); + const firstPr = router.state.location.href; + await screen.getByRole("button", { name: "PR 34", exact: true }).click(); + const beforeTyping = router.history.length; + await screen.getByRole("textbox", { name: "Search" }).fill("fix"); + await screen.getByRole("textbox", { name: "Search" }).fill("fix navigation"); + expect(router.history.length).toBe(beforeTyping); + await act(() => router.history.back()); + expect(router.state.location.href).toBe(firstPr); + }); + + it("records the selected PR detail tab and restores it with Back and Forward", async () => { + await using screen = await render(, { + router: "/pull-requests?involvement=all&state=open&repository=owner%2Frepo&number=12", + }); + const router = screen.router!; + const summary = router.state.location.href; + await screen.getByRole("button", { name: "Code", exact: true }).click(); + expect(router.state.location.search).toMatchObject({ number: 12, tab: "code" }); + await act(() => router.history.back()); + expect(router.state.location.href).toBe(summary); + await act(() => router.history.forward()); + expect(router.state.location.search).toMatchObject({ number: 12, tab: "code" }); + }); +}); diff --git a/apps/web/src/components/pullRequest/usePullRequestNavigation.ts b/apps/web/src/components/pullRequest/usePullRequestNavigation.ts new file mode 100644 index 0000000000000..12f4141794ba3 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestNavigation.ts @@ -0,0 +1,61 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import type { PullRequestsSearch } from "./usePullRequestListView"; +import { pullRequestSurfaceId, useRightPanelStore } from "../../rightPanelStore"; + +export function usePullRequestHistorySelection( + panelRef: ScopedThreadRef | null, + selection: Parameters[0] | null, + search: { + [Key in "repository" | "number" | "tab"]?: PullRequestsSearch[Key] | undefined; + }, +) { + useEffect(() => { + if (panelRef === null) return; + if (selection === null) { + if (!search.repository || !search.number) useRightPanelStore.getState().close(panelRef); + return; + } + useRightPanelStore.getState().openPullRequest(panelRef, selection); + useRightPanelStore + .getState() + .setPullRequestDetailTab(panelRef, pullRequestSurfaceId(selection), search.tab ?? "summary"); + }, [panelRef, selection, search.repository, search.number, search.tab]); +} + +export function usePullRequestNavigation() { + const navigate = useNavigate({ from: "/pull-requests" }); + return (patch: { [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined }) => + void navigate({ + to: "/pull-requests", + search: (previous: PullRequestsSearch): PullRequestsSearch => { + const next = { ...previous, ...patch }; + return { + involvement: next.involvement ?? previous.involvement, + state: next.state ?? previous.state, + ...(next.sort && next.sort !== "ready" ? { sort: next.sort } : {}), + ...(next.repository ? { repository: next.repository } : {}), + ...(next.number ? { number: next.number } : {}), + ...(next.tab && next.tab !== "summary" ? { tab: next.tab } : {}), + ...(next.projectId ? { projectId: next.projectId } : {}), + ...(next.environmentId ? { environmentId: next.environmentId } : {}), + ...(next.host ? { host: next.host } : {}), + ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}), + ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}), + ...(next.selectedEnvironmentId + ? { selectedEnvironmentId: next.selectedEnvironmentId } + : {}), + ...(next.q ? { q: next.q } : {}), + ...(next.draft ? { draft: next.draft } : {}), + ...(next.review ? { review: next.review } : {}), + ...(next.checks ? { checks: next.checks } : {}), + ...(next.author ? { author: next.author } : {}), + ...(next.labels && next.labels.length > 0 ? { labels: next.labels } : {}), + }; + }, + // Typing refines the current visit; discrete filters and PR selections create visits. + replace: Object.keys(patch).every((key) => key === "q"), + }); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.browser.test.tsx b/apps/web/src/components/settings/SettingsSidebarNav.browser.test.tsx new file mode 100644 index 0000000000000..e2c0d3148896b --- /dev/null +++ b/apps/web/src/components/settings/SettingsSidebarNav.browser.test.tsx @@ -0,0 +1,64 @@ +import { useLocation } from "@tanstack/react-router"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { SidebarProvider } from "../ui/sidebar"; +import { SettingsSidebarNav } from "./SettingsSidebarNav"; +import type { SettingsSearchItem } from "./settingsSearch"; + +vi.mock("../clerk/T3ConnectSidebarSignIn", () => ({ + T3ConnectSidebarSignIn: () => null, + T3ConnectSidebarAvatar: () => null, +})); + +vi.mock("../sidebar/SidebarChrome", () => ({ SidebarUtilityMenu: () => null })); + +vi.mock("./useAvailableSettingsSearchItems", () => { + const items: ReadonlyArray = [ + { id: "interface-font", title: "Interface font", to: "/settings/appearance" }, + ]; + return { useAvailableSettingsSearchItems: () => items }; +}); + +function SettingsNavigation() { + const pathname = useLocation({ select: (location) => location.pathname }); + return ( + + + + ); +} + +describe("Settings navigation history", () => { + it("Back revisits Settings categories before returning to the thread", async () => { + await using screen = await render(, { router: "/env/thread-a" }); + const router = screen.router!; + + await act(() => router.navigate({ href: "/settings/general" })); + await screen.getByRole("button", { name: "Appearance", exact: true }).click(); + await screen.getByRole("button", { name: "SnapShots", exact: true }).click(); + + await act(() => router.history.back()); + expect(router.state.location.pathname).toBe("/settings/appearance"); + await act(() => router.history.back()); + expect(router.state.location.pathname).toBe("/settings/general"); + await act(() => router.history.back()); + expect(router.state.location.pathname).toBe("/env/thread-a"); + await act(() => router.history.forward()); + expect(router.state.location.pathname).toBe("/settings/general"); + }); + + it("Back returns from a settings search result to the previous Settings page", async () => { + await using screen = await render(, { router: "/settings/general" }); + const router = screen.router!; + + await screen.getByRole("combobox", { name: "Search settings" }).fill("Interface font"); + await screen.getByRole("option", { name: /Interface font/ }).click(); + expect(router.state.location.href).toBe("/settings/appearance#interface-font"); + + await act(() => router.history.back()); + expect(router.state.location.href).toBe("/settings/general"); + await act(() => router.history.forward()); + expect(router.state.location.href).toBe("/settings/appearance#interface-font"); + }); +}); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 9be694737838e..29e44011404d6 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -185,7 +185,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { void navigate({ to, hash: "", - replace: true, hashScrollIntoView: false, }); }; @@ -207,7 +206,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { void navigate({ to: item.to, hash: targetId, - replace: true, hashScrollIntoView: false, state: { settingsTargetHighlight: true }, }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 6552e10c41b3d..dc42c8b112c6f 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -103,11 +103,8 @@ import { } from "./usageShortcuts"; import { useEscapeToGoBack } from "../../hooks/useNavigateBack"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; -import { - readUsagePagePreferences, - saveUsagePagePreferences, - type UsagePagePreferences, -} from "./usagePagePreferences"; +import { type UsagePagePreferences } from "./usagePagePreferences"; +import { useUsagePageNavigation } from "./useUsagePageNavigation"; function isUsageMetric(value: string | null | undefined): value is UsageMetric { return METRIC_OPTIONS.some((option) => option.value === value); @@ -179,7 +176,7 @@ function UsageModelDialogHost({ } export function UsagePage() { - const [preferences, setPreferences] = useState(readUsagePagePreferences); + const { preferences, updatePreferences } = useUsagePageNavigation(); useEscapeToGoBack(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const hour12 = usePrimarySettings((settings) => resolveHour12(settings.timestampFormat)); @@ -199,6 +196,16 @@ export function UsagePage() { preferences.windowDays === 1 ? "hour" : "day", ), })); + if (windowSelection.days !== preferences.windowDays) { + setWindowSelection({ + days: preferences.windowDays, + window: makeWindow( + preferences.windowDays, + undefined, + preferences.windowDays === 1 ? "hour" : "day", + ), + }); + } const metric = preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); @@ -238,18 +245,9 @@ export function UsagePage() { (environment) => environment.canReadDiagnostics, ); - const updatePreferences = (patch: Partial) => { - const nextPreferences = { ...preferences, ...patch }; - setPreferences(nextPreferences); - saveUsagePagePreferences(nextPreferences); - }; const selectWindow = (days: number) => { if (!isUsageWindowDays(days)) return; updatePreferences({ windowDays: days }); - setWindowSelection({ - days, - window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), - }); }; const selectMetric = (nextMetric: UsageMetric) => { if (nextMetric === "limits") setLimitsNow(Date.now()); diff --git a/apps/web/src/components/usage/usagePageNavigation.browser.test.ts b/apps/web/src/components/usage/usagePageNavigation.browser.test.ts new file mode 100644 index 0000000000000..9641d200bebe2 --- /dev/null +++ b/apps/web/src/components/usage/usagePageNavigation.browser.test.ts @@ -0,0 +1,66 @@ +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { Route as UsageRoute } from "../../routes/usage"; +import * as preferences from "./usagePagePreferences"; + +vi.mock("./UsagePage", () => ({ UsagePage: () => null })); + +function savedPreferences(value: preferences.UsagePagePreferences) { + const key = "t3code:usage-page-preferences:v1"; + const previous = localStorage.getItem(key); + preferences.saveUsagePagePreferences(value); + return { + [Symbol.dispose]() { + if (previous === null) localStorage.removeItem(key); + else localStorage.setItem(key, previous); + }, + }; +} + +function createUsageRouter(initialEntry: string) { + const root = createRootRoute(); + const usage = createRoute({ + getParentRoute: () => root, + path: "usage", + validateSearch: UsageRoute.options.validateSearch, + beforeLoad: (options) => + UsageRoute.options.beforeLoad?.({ + ...options, + context: { authGateState: { status: "hosted-static" } }, + }), + }); + return createRouter({ + routeTree: root.addChildren([usage]), + history: createMemoryHistory({ initialEntries: [initialEntry] }), + }); +} + +describe("Usage route history", () => { + it("pins the entry view without creating a second visit, even when preferences later change", async () => { + using _saved = savedPreferences({ metric: "limits", windowDays: 30 }); + const router = createUsageRouter("/usage"); + await router.navigate({ href: "/usage", replace: true }); + expect(router.state.location.search).toEqual({ metric: "limits", windowDays: 30 }); + expect(router.history.length).toBe(1); + + preferences.saveUsagePagePreferences({ metric: "tokens", windowDays: 7 }); + await router.navigate({ to: "/usage", search: { metric: "tokens", windowDays: 7 } }); + router.history.back(); + await router.load(); + expect(router.state.location.search).toEqual({ metric: "limits", windowDays: 30 }); + }); + + it("keeps an explicit metric and repairs an invalid period from saved preferences", async () => { + using _saved = savedPreferences({ metric: "limits", windowDays: 7 }); + const router = createUsageRouter("/usage?metric=cost&windowDays=999"); + await router.navigate({ href: "/usage?metric=cost&windowDays=999", replace: true }); + expect(router.state.location.search).toEqual({ metric: "cost", windowDays: 7 }); + expect(router.history.length).toBe(1); + }); +}); diff --git a/apps/web/src/components/usage/usagePagePreferences.ts b/apps/web/src/components/usage/usagePagePreferences.ts index 1aeaefc43da12..a1385c90f1dd5 100644 --- a/apps/web/src/components/usage/usagePagePreferences.ts +++ b/apps/web/src/components/usage/usagePagePreferences.ts @@ -11,6 +11,22 @@ const UsagePagePreferencesSchema = Schema.Struct({ }); export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type; +export function validateUsagePageSearch(raw: Record): { + metric?: UsagePagePreferences["metric"] | undefined; + windowDays?: UsagePagePreferences["windowDays"] | undefined; +} { + return { + metric: + raw.metric === "cost" || raw.metric === "tokens" || raw.metric === "limits" + ? raw.metric + : undefined, + windowDays: + raw.windowDays === 1 || raw.windowDays === 7 || raw.windowDays === 30 || raw.windowDays === 90 + ? raw.windowDays + : undefined, + }; +} + const DEFAULT_PREFERENCES: UsagePagePreferences = { metric: "limits", windowDays: 30 }; export function readUsagePagePreferences(): UsagePagePreferences { diff --git a/apps/web/src/components/usage/useUsagePageNavigation.browser.test.tsx b/apps/web/src/components/usage/useUsagePageNavigation.browser.test.tsx new file mode 100644 index 0000000000000..de4ee7441503d --- /dev/null +++ b/apps/web/src/components/usage/useUsagePageNavigation.browser.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { useUsagePageNavigation } from "./useUsagePageNavigation"; + +vi.mock(import("./usagePagePreferences"), async (original) => ({ + ...(await original()), + readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }), + saveUsagePagePreferences: () => undefined, +})); + +function Navigation() { + const { preferences, updatePreferences } = useUsagePageNavigation(); + return ( + <> + {`${preferences.metric}:${preferences.windowDays}`} + + + + + + ); +} + +describe("Usage navigation history", () => { + it("restores metrics and periods through the existing Back/Forward history", async () => { + await using screen = await render(, { + router: "/usage?metric=limits&windowDays=30", + }); + const router = screen.router!; + await screen.getByRole("button", { name: "Cost", exact: true }).click(); + await screen.getByRole("button", { name: "7 days", exact: true }).click(); + await screen.getByRole("button", { name: "Tokens", exact: true }).click(); + await expect.element(screen.getByLabelText("Usage view")).toHaveTextContent("tokens:7"); + + for (const view of ["cost:7", "cost:30", "limits:30"]) { + await act(() => router.history.back()); + await expect.element(screen.getByLabelText("Usage view")).toHaveTextContent(view); + } + for (const view of ["cost:30", "cost:7", "tokens:7"]) { + await act(() => router.history.forward()); + await expect.element(screen.getByLabelText("Usage view")).toHaveTextContent(view); + } + }); + + it("does not add duplicate visits or entries for provider visibility preferences", async () => { + await using screen = await render(, { + router: "/usage?metric=cost&windowDays=7", + }); + const router = screen.router!; + await expect.element(screen.getByLabelText("Usage view")).toHaveTextContent("cost:7"); + const length = router.history.length; + await screen.getByRole("button", { name: "Cost", exact: true }).click(); + await screen.getByRole("button", { name: "Hide Claude", exact: true }).click(); + expect(router.history.length).toBe(length); + }); +}); diff --git a/apps/web/src/components/usage/useUsagePageNavigation.ts b/apps/web/src/components/usage/useUsagePageNavigation.ts new file mode 100644 index 0000000000000..0bb8bf2b2eccc --- /dev/null +++ b/apps/web/src/components/usage/useUsagePageNavigation.ts @@ -0,0 +1,35 @@ +import { useState } from "react"; +import { useNavigate, useSearch } from "@tanstack/react-router"; + +import { + readUsagePagePreferences, + saveUsagePagePreferences, + validateUsagePageSearch, + type UsagePagePreferences, +} from "./usagePagePreferences"; + +export function useUsagePageNavigation() { + const [savedPreferences, setSavedPreferences] = useState(readUsagePagePreferences); + const search = useSearch({ strict: false, select: validateUsagePageSearch }); + const navigate = useNavigate(); + const preferences = { + ...savedPreferences, + metric: search.metric ?? savedPreferences.metric, + windowDays: search.windowDays ?? savedPreferences.windowDays, + }; + const updatePreferences = (patch: Partial) => { + const nextPreferences = { ...preferences, ...patch }; + setSavedPreferences(nextPreferences); + saveUsagePagePreferences(nextPreferences); + if ( + nextPreferences.metric !== preferences.metric || + nextPreferences.windowDays !== preferences.windowDays + ) { + void navigate({ + to: "/usage", + search: { metric: nextPreferences.metric, windowDays: nextPreferences.windowDays }, + }); + } + }; + return { preferences, updatePreferences }; +} diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 8c9a41998641e..9f3f18668b170 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -142,13 +142,13 @@ export function useOpenChangeRequestLink( ...previous, involvement: previous.involvement ?? "all", state: previous.state ?? "all", + tab: undefined, repository, number: parsed.number, selectedHost: parsed.authority ?? parsed.host, selectedProjectId: project.id, selectedEnvironmentId: project.environmentId, }), - replace: true, }); } return true; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 1fd753fe50ccc..497b92ade1bd7 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -33,6 +33,7 @@ export const Route = createFileRoute("/_chat/pull-requests")({ raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", state: raw.state === "closed" || raw.state === "merged" || raw.state === "all" ? raw.state : "open", + tab: raw.tab === "code" || raw.tab === "timeline" || raw.tab === "guide" ? raw.tab : undefined, ...(SORT_OPTIONS.some((option) => option.value === raw.sort) ? { sort: raw.sort as PullRequestListSort } : {}), diff --git a/apps/web/src/routes/usage.tsx b/apps/web/src/routes/usage.tsx index 9f765b5e68276..f07a6a4c62bf8 100644 --- a/apps/web/src/routes/usage.tsx +++ b/apps/web/src/routes/usage.tsx @@ -1,15 +1,32 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { UsagePage } from "../components/usage/UsagePage"; +import { + readUsagePagePreferences, + validateUsagePageSearch, +} from "../components/usage/usagePagePreferences"; export const Route = createFileRoute("/usage")({ - beforeLoad: async ({ context }) => { + validateSearch: validateUsagePageSearch, + beforeLoad: async ({ context, search }) => { if ( context.authGateState.status !== "authenticated" && context.authGateState.status !== "hosted-static" ) { throw redirect({ to: "/pair", replace: true }); } + if (search.metric === undefined || search.windowDays === undefined) { + const preferences = readUsagePagePreferences(); + // Record the entry view explicitly so later preference changes cannot rewrite its history. + throw redirect({ + to: "/usage", + search: { + metric: search.metric ?? preferences.metric, + windowDays: search.windowDays ?? preferences.windowDays, + }, + replace: true, + }); + } }, component: UsagePage, }); From 46bbdfce7a8e84f559e580358e11a40c233953a2 Mon Sep 17 00:00:00 2001 From: Maaz Chowdhry Date: Sat, 24 Oct 2026 19:10:20 +0100 Subject: [PATCH 3/4] chore(desktop): remove gesture handling from navigation PR --- apps/desktop/src/window/DesktopWindow.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6572916a65938..297c67ddace08 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -694,13 +694,6 @@ export const make = Effect.gen(function* () { }); if (environment.platform === "darwin") { - // Some macOS mouse drivers send Back/Forward as page-swipe gestures, - // rather than DOM mouse button events. - window.on("swipe", (_event, direction) => { - const history = window.webContents.navigationHistory; - if (direction === "left" && history.canGoBack()) history.goBack(); - else if (direction === "right" && history.canGoForward()) history.goForward(); - }); window.on("enter-full-screen", () => { window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); }); From a108bcf0569496b9d5c7350a0a545a4b4131ca01 Mon Sep 17 00:00:00 2001 From: Maaz Chowdhry Date: Sat, 24 Oct 2026 19:13:02 +0100 Subject: [PATCH 4/4] fix(web): preserve forward history when closing earlier utility pages Traverse to the remembered thread even when it lies ahead of the current history index. Pushing its URL instead discarded the valid forward branch. Add a fail-before/pass-after browser regression for the reported sequence. --- .../sidebar/mainAppLocation.browser.test.tsx | 17 +++++++++++++++++ .../src/components/sidebar/mainAppLocation.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx b/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx index cf75800920d7e..dffe294ec57b5 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx +++ b/apps/web/src/components/sidebar/mainAppLocation.browser.test.tsx @@ -16,6 +16,23 @@ function LeaveUtilityPage() { } describe("leaving a utility page", () => { + it("preserves forward history when the remembered thread lies ahead", async () => { + await using screen = await render(, { router: "/env/thread-a" }); + const router = screen.router!; + + await act(() => router.navigate({ href: "/settings/general" })); + await act(() => router.navigate({ href: "/env/thread-b" })); + await act(() => router.navigate({ href: "/settings/appearance" })); + await act(() => router.history.back()); + await act(() => router.history.back()); + expect(router.state.location.pathname).toBe("/settings/general"); + + await screen.getByRole("button", { name: "Back to threads" }).click(); + expect(router.state.location.pathname).toBe("/env/thread-b"); + await act(() => router.history.forward()); + expect(router.state.location.pathname).toBe("/settings/appearance"); + }); + it("keeps the remembered entry when traversing past it without overwriting it", async () => { await using screen = await render(, { router: "/settings" }); const router = screen.router!; diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index 5dff89c396b88..aeb897c621c5e 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -45,7 +45,7 @@ export function useNavigateToMainApp() { mainAppEntry?.historyIndex != null ? router.history.location.state.__TSR_index - mainAppEntry.historyIndex : 0; - if (stepsBack > 0) { + if (stepsBack !== 0) { router.history.go(-stepsBack); return; }