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/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 }) {
();
+ 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, () => storage);
+ 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);
+ });
+
+ 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
new file mode 100644
index 000000000000..237563588251
--- /dev/null
+++ b/apps/web/src/navigationHistory.ts
@@ -0,0 +1,69 @@
+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`.
+// 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 {
+ const index = history.location.state.__TSR_index;
+ return Number.isInteger(index) ? index : null;
+}
+
+// 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 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);
+ });
+}
+
+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,