From fc0394901e8a7fe20dc239cc0a9302f73fb27f67 Mon Sep 17 00:00:00 2001 From: saphid Date: Sat, 3 Oct 2026 16:20:20 +1000 Subject: [PATCH 1/3] feat(desktop): add Back and Forward buttons to the titlebar Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/AppSidebarLayout.tsx | 83 ++++++++++++++++++-- apps/web/src/navigationHistory.test.ts | 52 ++++++++++++ apps/web/src/navigationHistory.ts | 35 +++++++++ apps/web/src/router.ts | 2 + 4 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/navigationHistory.test.ts create mode 100644 apps/web/src/navigationHistory.ts diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 918d28845068..0383bb3d0530 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -7,7 +7,14 @@ import { type CSSProperties, type ReactNode, } from "react"; -import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; +import { + useCanGoBack, + useLocation, + useNavigate, + useParams, + useRouter, +} from "@tanstack/react-router"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { isElectron } from "../env"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; @@ -24,6 +31,7 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { resolveThreadRouteRef } from "../threadRoutes"; import { cn, isMacPlatform } from "../lib/utils"; +import { useCanGoForward } from "../navigationHistory"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; import { @@ -55,6 +63,7 @@ import { useSidebar, useSidebarVisibility, } from "./ui/sidebar"; +import { Button } from "./ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; @@ -92,6 +101,9 @@ function SidebarControl() { const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { context: { usagePageOpen }, }); + // Over the stage artwork the controls sit on imagery, like the media viewer's + // arrows; that variant positions itself, so the layout is reset here. + const onStageBackdrop = isSidebarVisible && stageBackdropVariant !== null; useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -139,12 +151,10 @@ function SidebarControl() { @@ -154,6 +164,62 @@ function SidebarControl() { Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + {/* Browsers bring their own Back and Forward buttons; the desktop window has none. */} + {isElectron ? : null} + + ); +} + +// Titlebar Back and Forward buttons, the clickable form of navigation.back/forward. +function NavigationHistoryControls({ onStageBackdrop }: { onStageBackdrop: boolean }) { + const { history } = useRouter(); + const canGoBack = useCanGoBack(); + const canGoForward = useCanGoForward(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const controls = [ + { + label: "Back", + command: "navigation.back", + icon: , + enabled: canGoBack, + go: () => history.back(), + }, + { + label: "Forward", + command: "navigation.forward", + icon: , + enabled: canGoForward, + go: () => history.forward(), + }, + ] as const; + + return ( +
+ {controls.map(({ label, command, icon, enabled, go }) => { + const shortcutLabel = shortcutLabelForCommand(keybindings, command); + return ( + + + {icon} + + } + /> + + {label} + {shortcutLabel ? ` (${shortcutLabel})` : ""} + + + ); + })}
); } @@ -258,6 +324,13 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } : {}), + // Titlebar content clears the sidebar toggle plus the Back and Forward controls. + ...(isElectron + ? { + "--workspace-titlebar-content-left": + "calc(var(--workspace-controls-left) + 3 * var(--workspace-titlebar-control-size) + 2 * 0.125rem + var(--workspace-titlebar-control-gap))", + } + : {}), } as CSSProperties; useEffect(() => { diff --git a/apps/web/src/navigationHistory.test.ts b/apps/web/src/navigationHistory.test.ts new file mode 100644 index 000000000000..ab4c9ae9d88a --- /dev/null +++ b/apps/web/src/navigationHistory.test.ts @@ -0,0 +1,52 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { describe, expect, it } from "vite-plus/test"; + +import { canGoForward, trackNavigationHistory } from "./navigationHistory"; + +function trackedHistory() { + const history = createMemoryHistory({ initialEntries: ["/"] }); + trackNavigationHistory(history); + return history; +} + +describe("canGoForward", () => { + it("allows Forward only after going back", () => { + const history = trackedHistory(); + history.push("/a"); + history.push("/b"); + expect(canGoForward(history)).toBe(false); + + history.back(); + history.back(); + expect(canGoForward(history)).toBe(true); + + history.forward(); + expect(canGoForward(history)).toBe(true); + history.forward(); + expect(canGoForward(history)).toBe(false); + }); + + it("drops Forward entries when a new page is pushed", () => { + const history = trackedHistory(); + history.push("/a"); + history.push("/b"); + history.back(); + history.push("/c"); + expect(canGoForward(history)).toBe(false); + }); + + it("keeps Forward entries across a replace", () => { + const history = trackedHistory(); + history.push("/a"); + history.back(); + history.replace("/home"); + expect(canGoForward(history)).toBe(true); + }); + + it("does not offer Forward for an untracked history", () => { + const history = createMemoryHistory({ initialEntries: ["/"] }); + history.push("/a"); + history.back(); + expect(canGoForward(history)).toBe(false); + }); +}); diff --git a/apps/web/src/navigationHistory.ts b/apps/web/src/navigationHistory.ts new file mode 100644 index 000000000000..9be5e8c911b8 --- /dev/null +++ b/apps/web/src/navigationHistory.ts @@ -0,0 +1,35 @@ +import { useRouter, type RouterHistory } from "@tanstack/react-router"; +import { useSyncExternalStore } from "react"; + +// Browsers expose how far back history goes (TanStack's `__TSR_index`) but not +// how far forward it goes. Each history remembers the furthest entry reached +// since its latest push, so the Forward control knows whether it has anywhere +// to go. Start tracking before the first navigation with `trackNavigationHistory`. +const furthestIndexByHistory = new WeakMap(); + +function currentIndex(history: RouterHistory): number | null { + const index = history.location.state.__TSR_index; + return Number.isInteger(index) ? index : null; +} + +export function trackNavigationHistory(history: RouterHistory) { + if (furthestIndexByHistory.has(history)) return; + const tracked = { furthest: currentIndex(history) ?? 0 }; + furthestIndexByHistory.set(history, tracked); + history.subscribe(({ action }) => { + const index = currentIndex(history); + if (index === null) return; + tracked.furthest = action.type === "PUSH" ? index : Math.max(tracked.furthest, index); + }); +} + +export function canGoForward(history: RouterHistory): boolean { + const tracked = furthestIndexByHistory.get(history); + const index = currentIndex(history); + return tracked !== undefined && index !== null && index < tracked.furthest; +} + +export function useCanGoForward(): boolean { + const { history } = useRouter(); + return useSyncExternalStore(history.subscribe, () => canGoForward(history)); +} diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 4362dd3d70ba..a26b7d869b21 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -1,8 +1,10 @@ import { createRouter, RouterHistory } from "@tanstack/react-router"; +import { trackNavigationHistory } from "./navigationHistory"; import { routeTree } from "./routeTree.gen"; export function getRouter(history: RouterHistory) { + trackNavigationHistory(history); return createRouter({ routeTree, history, From 694dc7bfeeb53fc07ab097a5a76191faedf7bb5d Mon Sep 17 00:00:00 2001 From: saphid Date: Sat, 3 Oct 2026 16:25:48 +1000 Subject: [PATCH 2/3] fix(desktop): keep the sidebar brand inside narrow sidebars Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/sidebar/SidebarChrome.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ce2ef2629549..77f219d34f33 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -112,7 +112,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { Date: Sat, 3 Oct 2026 16:39:04 +1000 Subject: [PATCH 3/3] fix(desktop): keep Forward across reloads and the pill on Windows/Linux Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/navigationHistory.test.ts | 38 ++++++++++++++++++++++++-- apps/web/src/navigationHistory.ts | 38 ++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/apps/web/src/navigationHistory.test.ts b/apps/web/src/navigationHistory.test.ts index ab4c9ae9d88a..64acd017dccd 100644 --- a/apps/web/src/navigationHistory.test.ts +++ b/apps/web/src/navigationHistory.test.ts @@ -3,9 +3,23 @@ import { describe, expect, it } from "vite-plus/test"; import { canGoForward, trackNavigationHistory } from "./navigationHistory"; -function trackedHistory() { +function memoryStorage(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => void values.delete(key), + setItem: (key, value) => void values.set(key, value), + }; +} + +function trackedHistory(storage = memoryStorage()) { const history = createMemoryHistory({ initialEntries: ["/"] }); - trackNavigationHistory(history); + trackNavigationHistory(history, () => storage); return history; } @@ -49,4 +63,24 @@ describe("canGoForward", () => { history.back(); expect(canGoForward(history)).toBe(false); }); + + it("restores Forward after a reload of the same entry", () => { + const storage = memoryStorage(); + const history = createMemoryHistory({ initialEntries: ["/"] }); + storage.setItem( + "t3code:navigation-furthest-index", + JSON.stringify({ key: history.location.state.__TSR_key, furthest: 2 }), + ); + trackNavigationHistory(history, () => storage); + expect(canGoForward(history)).toBe(true); + }); + + it("ignores a stored position from a different entry", () => { + const storage = memoryStorage(); + storage.setItem( + "t3code:navigation-furthest-index", + JSON.stringify({ key: "another-entry", furthest: 2 }), + ); + expect(canGoForward(trackedHistory(storage))).toBe(false); + }); }); diff --git a/apps/web/src/navigationHistory.ts b/apps/web/src/navigationHistory.ts index 9be5e8c911b8..237563588251 100644 --- a/apps/web/src/navigationHistory.ts +++ b/apps/web/src/navigationHistory.ts @@ -5,6 +5,10 @@ import { useSyncExternalStore } from "react"; // how far forward it goes. Each history remembers the furthest entry reached // since its latest push, so the Forward control knows whether it has anywhere // to go. Start tracking before the first navigation with `trackNavigationHistory`. +// The position lives in sessionStorage because a reload keeps the window's +// forward entries. +const FURTHEST_INDEX_KEY = "t3code:navigation-furthest-index"; + const furthestIndexByHistory = new WeakMap(); function currentIndex(history: RouterHistory): number | null { @@ -12,14 +16,44 @@ function currentIndex(history: RouterHistory): number | null { return Number.isInteger(index) ? index : null; } -export function trackNavigationHistory(history: RouterHistory) { +// Stored with the current entry's key, so only a reload of that same entry reuses it. +function readStoredIndex(history: RouterHistory, getStorage: () => Storage): number | null { + try { + const stored = JSON.parse(getStorage().getItem(FURTHEST_INDEX_KEY) ?? "{}") as { + key?: unknown; + furthest?: unknown; + }; + const { key, furthest } = stored; + return key === history.location.state.__TSR_key && Number.isInteger(furthest) + ? Number(furthest) + : null; + } catch { + return null; + } +} + +function storeIndex(history: RouterHistory, getStorage: () => Storage, furthest: number) { + try { + const key = history.location.state.__TSR_key; + getStorage().setItem(FURTHEST_INDEX_KEY, JSON.stringify({ key, furthest })); + } catch { + // Without storage, Forward still works until the next reload. + } +} + +export function trackNavigationHistory( + history: RouterHistory, + getStorage: () => Storage = () => window.sessionStorage, +) { if (furthestIndexByHistory.has(history)) return; - const tracked = { furthest: currentIndex(history) ?? 0 }; + const index = currentIndex(history) ?? 0; + const tracked = { furthest: Math.max(index, readStoredIndex(history, getStorage) ?? index) }; furthestIndexByHistory.set(history, tracked); history.subscribe(({ action }) => { const index = currentIndex(history); if (index === null) return; tracked.furthest = action.type === "PUSH" ? index : Math.max(tracked.furthest, index); + storeIndex(history, getStorage, tracked.furthest); }); }