diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift
index ea572cc7a018..ab5365b55200 100644
--- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift
+++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift
@@ -26,6 +26,7 @@ public final class T3KeyboardCommandsView: ExpoView {
enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"),
enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"),
enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"),
+ enabledCommand("forward", input: "]", modifiers: .command, action: #selector(goForward), title: "Forward"),
enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),
enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),
enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"),
@@ -103,6 +104,8 @@ public final class T3KeyboardCommandsView: ExpoView {
@objc private func newTask() { emit("newTask") }
@objc private func focusSearch() { emit("focusSearch") }
@objc private func goBack() { emit("back") }
+
+ @objc private func goForward() { emit("forward") }
@objc private func openFiles() { emit("files") }
@objc private func openTerminal() { emit("terminal") }
@objc private func openReview() { emit("review") }
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 7cffbf62b0d7..b09053e912ba 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -10,7 +10,7 @@ import {
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
-import { useEffect, useRef } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
import { useResolveClassNames } from "uniwind";
@@ -23,6 +23,9 @@ import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardi
import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
+import { MobileNavigationHistoryButtons } from "./features/navigation/MobileNavigationHistoryButtons";
+import { MobileNavigationHistoryProvider } from "./features/navigation/MobileNavigationHistoryProvider";
+import { normalizeMobileNavigationPath } from "./features/navigation/mobile-navigation-history";
import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
import { ReviewSheet } from "./features/review/ReviewSheet";
import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
@@ -87,6 +90,7 @@ type AppScreenOptions = NativeStackNavigationOptions & {
// iOS versions. Pre-glass iOS gets the same solid material as internal-scroll
// surfaces so content is laid out below the bar instead of underlapping it.
const GLASS_HEADER_OPTIONS: AppScreenOptions = {
+ headerLeft: () => ,
headerBackButtonDisplayMode: "minimal",
headerBackTitle: "",
headerLargeTitle: false,
@@ -102,6 +106,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
// SOLID: opaque sheet-colored header for surfaces whose content scrolls internally
// (file viewer, terminal, review) — there is nothing for glass to sample there.
const SOLID_HEADER_OPTIONS: AppScreenOptions = {
+ headerLeft: () => ,
headerBackButtonDisplayMode: "minimal",
headerBackTitle: "",
headerLargeTitle: false,
@@ -349,6 +354,15 @@ function workspacePathFromState(state: NavigationState): string {
return path.startsWith("/") ? path : `/${path}`;
}
+function activeNavigationTransitionKey(state: NavigationState): string {
+ const route = state.routes[state.index];
+ if (!route) {
+ return "empty";
+ }
+ const nestedState = route.state as NavigationState | undefined;
+ return nestedState ? `${route.key}/${activeNavigationTransitionKey(nestedState)}` : route.key;
+}
+
// The drain hook subscribes to the outbox, all thread shells, projects, and
// connection statuses. Hosting it in a null-rendering leaf keeps those
// updates from re-rendering RootStackLayout (and with it every screen) on
@@ -387,20 +401,27 @@ function RootStackLayout(props: {
}, [navigation, pendingShare, props.state]);
// Full pathname (sheets included) for keyboard-command scoping; the
// workspace layout only reacts to the underlying non-overlay route.
- const path = getPathFromState(props.state, navigationPathConfig);
+ const path = normalizeMobileNavigationPath(getPathFromState(props.state, navigationPathConfig));
const pathname = path.startsWith("/") ? path : `/${path}`;
const workspacePathname = workspacePathFromState(props.state);
+ const transitionKey = activeNavigationTransitionKey(props.state);
+ const navigationLocation = useMemo(
+ () => ({ pathname, transitionKey }),
+ [pathname, transitionKey],
+ );
return (
-
-
-
-
-
- {props.children}
-
-
-
+
+
+
+
+
+
+ {props.children}
+
+
+
+
);
}
diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx
index 7fe21fb44ff3..5dfb4dd65fe8 100644
--- a/apps/mobile/src/components/AndroidScreenHeader.tsx
+++ b/apps/mobile/src/components/AndroidScreenHeader.tsx
@@ -51,6 +51,7 @@ export function AndroidScreenHeader(props: {
readonly actions?: ReadonlyArray;
readonly trailing?: ReactNode;
readonly onBack?: () => void;
+ readonly backDisabled?: boolean;
readonly embedded?: boolean;
}) {
const insets = useSafeAreaInsets();
@@ -66,11 +67,15 @@ export function AndroidScreenHeader(props: {
{props.onBack ? (
+
+
(null);
const iconColor = useThemeColor("--color-icon");
// Thread List v2 lays the list out in fixed creation order, so the
@@ -317,11 +323,26 @@ function IosHomeHeader(props: HomeHeaderProps) {
...props,
listOrganization: !threadListV2Enabled,
});
+ const navigationHeaderItems = useMemo(
+ () =>
+ createNativeNavigationHistoryItems({
+ canGoBack: navigationHistory.canGoBack,
+ canGoForward: navigationHistory.canGoForward,
+ identifierPrefix: "home-navigation",
+ onBack: navigationHistory.back,
+ onForward: navigationHistory.forward,
+ }),
+ [navigationHistory],
+ );
return (
<>
[
+ ...navigationHeaderItems,
withNativeGlassHeaderItem({
accessibilityLabel: "Open settings",
icon: { name: "ellipsis", type: "sfSymbol" } as const,
diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx
index 1867042988ba..df0036caafb9 100644
--- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx
+++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx
@@ -181,6 +181,7 @@ export function getConnectionAwareBrandHeaderOptions(opts: {
}
return {
+ headerLeft: () => null,
headerTitle: () => (
}
diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx
index 96a4a63e9018..757721db49b1 100644
--- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx
+++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx
@@ -1,4 +1,4 @@
-import { StackActions, useNavigation } from "@react-navigation/native";
+import { useNavigation } from "@react-navigation/native";
import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react";
import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
@@ -10,12 +10,14 @@ import {
subscribeToHardwareKeyboardCommandRegistrations,
type HardwareKeyboardCommand,
} from "./hardwareKeyboardCommands";
+import { useMobileNavigationHistory } from "../navigation/MobileNavigationHistoryProvider";
export function HardwareKeyboardCommandProvider({
children,
pathname,
}: PropsWithChildren<{ readonly pathname: string }>) {
const navigation = useNavigation();
+ const navigationHistory = useMobileNavigationHistory();
const registrationVersion = useSyncExternalStore(
subscribeToHardwareKeyboardCommandRegistrations,
getHardwareKeyboardCommandRegistrationVersion,
@@ -24,14 +26,15 @@ export function HardwareKeyboardCommandProvider({
const enabledCommands = useMemo(() => {
const commands = new Set(getRegisteredHardwareKeyboardCommands());
commands.add("newTask");
- if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
+ if (navigationHistory.canGoBack) commands.add("back");
+ if (navigationHistory.canGoForward) commands.add("forward");
if (parseActiveThreadPath(pathname)) {
commands.add("files");
commands.add("terminal");
commands.add("review");
}
return [...commands];
- }, [pathname, registrationVersion, navigation]);
+ }, [navigationHistory.canGoBack, navigationHistory.canGoForward, pathname, registrationVersion]);
const onCommand = useCallback(
(command: HardwareKeyboardCommand) => {
@@ -42,11 +45,11 @@ export function HardwareKeyboardCommandProvider({
return;
}
if (command === "back") {
- if (navigation.canGoBack()) {
- navigation.goBack();
- } else {
- navigation.dispatch(StackActions.replace("Home"));
- }
+ navigationHistory.back();
+ return;
+ }
+ if (command === "forward") {
+ navigationHistory.forward();
return;
}
@@ -62,7 +65,7 @@ export function HardwareKeyboardCommandProvider({
navigation.navigate("ThreadReview", thread);
}
},
- [pathname, navigation],
+ [navigation, navigationHistory, pathname],
);
return (
diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts
index 300434eb736a..cd73bf7c08f8 100644
--- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts
+++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts
@@ -5,6 +5,7 @@ export type HardwareKeyboardCommand =
| "newTask"
| "focusSearch"
| "back"
+ | "forward"
| "files"
| "terminal"
| "review"
diff --git a/apps/mobile/src/features/navigation/MobileNavigationHistoryButtons.tsx b/apps/mobile/src/features/navigation/MobileNavigationHistoryButtons.tsx
new file mode 100644
index 000000000000..3e9b2f070744
--- /dev/null
+++ b/apps/mobile/src/features/navigation/MobileNavigationHistoryButtons.tsx
@@ -0,0 +1,32 @@
+import { View } from "react-native";
+
+import { ControlPill } from "../../components/ControlPill";
+import { useMobileNavigationHistory } from "./MobileNavigationHistoryProvider";
+
+export function MobileNavigationHistoryButtons({
+ grouped = false,
+}: {
+ readonly grouped?: boolean;
+}) {
+ const history = useMobileNavigationHistory();
+ const groupedClassName = grouped ? "bg-transparent" : undefined;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx
new file mode 100644
index 000000000000..4e10cb09ee23
--- /dev/null
+++ b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx
@@ -0,0 +1,109 @@
+import { StackActions, useLinkBuilder, useNavigation } from "@react-navigation/native";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ useSyncExternalStore,
+ type PropsWithChildren,
+} from "react";
+import {
+ createMobileNavigationHistory,
+ type MobileNavigationLocation,
+ type MobileNavigationHistorySnapshot,
+} from "./mobile-navigation-history";
+interface MobileNavigationHistoryValue extends MobileNavigationHistorySnapshot {
+ readonly back: () => void;
+ readonly forward: () => void;
+}
+const MobileNavigationHistoryContext = createContext(null);
+export function MobileNavigationHistoryProvider({
+ children,
+ location,
+}: PropsWithChildren<{ readonly location: MobileNavigationLocation }>) {
+ const [history] = useState(() => createMobileNavigationHistory(location));
+ const snapshot = useSyncExternalStore(
+ history.subscribe,
+ history.getSnapshot,
+ history.getSnapshot,
+ );
+ const { back, forward } = useMobileNavigationHistoryCoordinator(history, location);
+ const value = useMemo(() => ({ ...snapshot, back, forward }), [back, forward, snapshot]);
+ return (
+
+ {children}
+
+ );
+}
+function useMobileNavigationHistoryCoordinator(
+ history: ReturnType,
+ location: MobileNavigationLocation,
+) {
+ const navigation = useNavigation();
+ const { buildAction } = useLinkBuilder();
+ useCancelBlockedTraversal(history);
+ useEffect(() => {
+ history.visit(location);
+ }, [history, location]);
+ const requestTraversal = useCallback(
+ (target: ReturnType) => {
+ if (!target) {
+ return;
+ }
+ const action = buildAction(target.location.pathname);
+ if (action.type !== "NAVIGATE") return history.cancelPendingTraversal();
+ const state = navigation.getState()!;
+ const targetRootKey = target.location.transitionKey.split("/")[0]!;
+ const targetRouteExists = state.routes.some((route) => route.key === targetRootKey);
+ const currentRootKey = state.routes[state.index]?.key;
+ if (target.direction === "back" && targetRouteExists && targetRootKey !== currentRootKey) {
+ navigation.dispatch({
+ ...StackActions.popTo(action.payload.name, action.payload.params),
+ source: targetRootKey,
+ target: state.key,
+ });
+ } else if (target.direction === "forward" && !targetRouteExists) {
+ navigation.dispatch(StackActions.push(action.payload.name, action.payload.params));
+ } else {
+ navigation.dispatch({ ...action, payload: { ...action.payload, pop: true } });
+ }
+ },
+ [buildAction, history, navigation],
+ );
+ const back = useCallback(
+ () => requestTraversal(history.requestBack()),
+ [history, requestTraversal],
+ );
+ const forward = useCallback(
+ () => requestTraversal(history.requestForward()),
+ [history, requestTraversal],
+ );
+ return { back, forward };
+}
+function useCancelBlockedTraversal(history: ReturnType) {
+ const navigation = useNavigation();
+ useEffect(() => {
+ // React Navigation emits this pinned core event after routing an action.
+ // `noop` is true when a beforeRemove guard blocks it or no navigator handles it.
+ const actionEvents = navigation as typeof navigation & {
+ addListener: (
+ type: "__unsafe_action__",
+ listener: (event: { readonly data: { readonly noop: boolean } }) => void,
+ ) => () => void;
+ };
+ return actionEvents.addListener("__unsafe_action__", (event) => {
+ if (event.data.noop) {
+ history.cancelPendingTraversal();
+ }
+ });
+ }, [history, navigation]);
+}
+export function useMobileNavigationHistory(): MobileNavigationHistoryValue {
+ const value = useContext(MobileNavigationHistoryContext);
+ if (!value) {
+ throw new Error("useMobileNavigationHistory must be used within its provider");
+ }
+ return value;
+}
diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts
new file mode 100644
index 000000000000..77f3b9d2b036
--- /dev/null
+++ b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import {
+ createMobileNavigationHistory,
+ normalizeMobileNavigationPath,
+} from "./mobile-navigation-history";
+
+describe("createMobileNavigationHistory", () => {
+ const location = (pathname: string, transitionKey = pathname) => ({
+ pathname,
+ transitionKey,
+ });
+
+ it("moves backward and forward through visited paths", () => {
+ const history = createMobileNavigationHistory(location("/"));
+ history.visit(location("/threads/env/thread-a", "thread-a"));
+ history.visit(location("/threads/env/thread-b", "thread-b"));
+ history.visit(location("/threads/env/thread-b", "thread-b-remount"));
+ const backTarget = history.requestBack();
+ expect(backTarget?.location.pathname).toBe("/threads/env/thread-a");
+ expect(history.requestBack()).toBeNull();
+ history.visit(location(backTarget!.location.pathname, "thread-a"));
+ expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true });
+
+ const forwardTarget = history.requestForward();
+ expect(forwardTarget?.location.pathname).toBe("/threads/env/thread-b");
+ expect(forwardTarget?.location.transitionKey).toBe("thread-b-remount");
+ history.visit(location(forwardTarget!.location.pathname, "thread-b"));
+ expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false });
+ });
+
+ it("drops forward paths after a new visit", () => {
+ const history = createMobileNavigationHistory(location("/"));
+ history.visit(location("/threads/env/thread-a", "thread-a"));
+ history.visit(location("/threads/env/thread-b", "thread-b"));
+ history.visit(history.requestBack()!.location);
+ history.visit(location("/settings"));
+ expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false });
+ expect(history.requestForward()).toBeNull();
+ });
+
+ it("refreshes recreated nested host keys across forward entries", () => {
+ const thread = location("/threads/env/thread-a", "thread-a");
+ const history = createMobileNavigationHistory(thread);
+ history.visit(location("/settings", "settings-old/content-old/settings-old"));
+ history.visit(location("/settings/appearance", "settings-old/content-old/appearance-old"));
+ history.visit(thread);
+ const forward = history.requestForward()!;
+ history.visit(location(forward.location.pathname, "settings-new/content-new/settings-new"));
+ expect(history.requestBack()?.location.transitionKey).toBe("thread-a");
+ history.cancelPendingTraversal();
+ expect(history.requestForward()?.location.transitionKey).toMatch(/^settings-new\//);
+ });
+
+ it("reconciles non-adjacent native back navigation without adding a duplicate", () => {
+ const history = createMobileNavigationHistory(location("/"));
+ history.visit(location("/threads/env/thread-a", "thread-a"));
+ history.visit(location("/threads/env/thread-b", "thread-b"));
+ history.visit(location("/"));
+ expect(history.getSnapshot()).toEqual({ canGoBack: false, canGoForward: true });
+ expect(history.requestForward()?.location.pathname).toBe("/threads/env/thread-a");
+ });
+
+ it("records a new visit when an old pathname is selected again", () => {
+ const history = createMobileNavigationHistory(location("/"));
+ history.visit(location("/threads/env/thread/terminal?terminalId=a", "thread"));
+ history.visit(location("/threads/env/thread/terminal?terminalId=b", "thread"));
+ history.visit(location("/threads/env/thread/terminal?terminalId=c", "thread"));
+ history.visit(location("/threads/env/thread/terminal?terminalId=a", "thread"));
+ expect(
+ normalizeMobileNavigationPath(
+ "/settings?params=%5Bobject%20Object%5D¶ms=keep&state=%5Bobject%20Object%5D&terminalId=a",
+ ),
+ ).toBe("/settings?params=keep&terminalId=a");
+ expect(history.requestBack()?.location.pathname).toContain("terminalId=c");
+ expect(history.requestForward()).toBeNull();
+ });
+
+ it("distinguishes identical Back and Forward pathnames by target index", () => {
+ const history = createMobileNavigationHistory(location("/threads/env/thread-a", "a-1"));
+ history.visit(location("/threads/env/thread-b", "b"));
+ history.visit(location("/threads/env/thread-a", "a-2"));
+ history.visit(history.requestBack()!.location);
+ const forward = history.requestForward();
+ expect(forward).toEqual({
+ direction: "forward",
+ index: 2,
+ location: location("/threads/env/thread-a", "a-2"),
+ });
+ history.visit(forward!.location);
+
+ expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false });
+ });
+
+ it("treats a later visit as new after a blocked traversal is cancelled", () => {
+ const history = createMobileNavigationHistory(location("/threads/env/thread-a", "thread"));
+ history.visit(location("/threads/env/thread-b", "thread"));
+ history.visit(location("/threads/env/thread-c", "thread"));
+
+ history.requestBack();
+ history.cancelPendingTraversal();
+ history.visit(location("/threads/env/thread-b", "thread"));
+ expect(history.requestBack()?.location.pathname).toBe("/threads/env/thread-c");
+ expect(history.requestForward()).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.ts
new file mode 100644
index 000000000000..183108867c7c
--- /dev/null
+++ b/apps/mobile/src/features/navigation/mobile-navigation-history.ts
@@ -0,0 +1,107 @@
+export interface MobileNavigationHistorySnapshot {
+ readonly canGoBack: boolean;
+ readonly canGoForward: boolean;
+}
+export interface MobileNavigationLocation {
+ readonly pathname: string;
+ readonly transitionKey: string;
+}
+function snapshotFor(cursor: number, entryCount: number) {
+ return {
+ canGoBack: cursor > 0,
+ canGoForward: cursor < entryCount - 1,
+ };
+}
+export function normalizeMobileNavigationPath(rawPath: string) {
+ const url = new URL(rawPath, "t3code://app");
+ const search = new URLSearchParams(
+ Array.from(url.searchParams).filter(([, value]) => value !== "[object Object]"),
+ );
+ return `${url.pathname}${search.size > 0 ? `?${search}` : ""}`;
+}
+export function createMobileNavigationHistory(initialLocation: MobileNavigationLocation) {
+ let entries = [initialLocation];
+ let cursor = 0;
+ let snapshot = snapshotFor(cursor, entries.length);
+ let pendingTarget: {
+ direction: "back" | "forward";
+ index: number;
+ location: MobileNavigationLocation;
+ } | null = null;
+ const listeners = new Set<() => void>();
+ const request = (direction: "back" | "forward", index: number) => {
+ if (pendingTarget) return null;
+ const location = entries[index];
+ pendingTarget = location ? { direction, index, location } : null;
+ return pendingTarget;
+ };
+ const publish = () => {
+ const nextSnapshot = snapshotFor(cursor, entries.length);
+ if (
+ nextSnapshot.canGoBack === snapshot.canGoBack &&
+ nextSnapshot.canGoForward === snapshot.canGoForward
+ ) {
+ return;
+ }
+ snapshot = nextSnapshot;
+ listeners.forEach((listener) => listener());
+ };
+ return {
+ cancelPendingTraversal: () => {
+ pendingTarget = null;
+ },
+ getSnapshot: () => snapshot,
+ requestBack: () => request("back", cursor - 1),
+ requestForward: () => request("forward", cursor + 1),
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ visit: (location: MobileNavigationLocation) => {
+ const current = entries[cursor];
+ const target = pendingTarget;
+ if (target?.location.pathname === location.pathname) {
+ pendingTarget = null;
+ const previousRoot = target.location.transitionKey.split("/")[0]!;
+ const nextRoot = location.transitionKey.split("/")[0]!;
+ entries = entries.map((entry, index) => {
+ const [root, ...nestedKeys] = entry.transitionKey.split("/");
+ return index === target.index
+ ? location
+ : previousRoot !== nextRoot && root === previousRoot
+ ? { ...entry, transitionKey: [nextRoot, ...nestedKeys].join("/") }
+ : entry;
+ });
+ cursor = target.index;
+ publish();
+ return;
+ }
+ if (location.pathname === current?.pathname) {
+ entries = entries.map((entry, index) => (index === cursor ? location : entry));
+ return;
+ }
+ pendingTarget = null;
+ if (location.transitionKey !== current?.transitionKey) {
+ const priorIndex = entries.findLastIndex(
+ (entry, index) => index < cursor && entry.transitionKey === location.transitionKey,
+ );
+ if (priorIndex >= 0) {
+ cursor = priorIndex;
+ publish();
+ return;
+ }
+ const forwardIndex = entries.findIndex(
+ (entry, index) => index > cursor && entry.transitionKey === location.transitionKey,
+ );
+ if (forwardIndex >= 0) {
+ cursor = forwardIndex;
+ publish();
+ return;
+ }
+ }
+ entries = [...entries.slice(0, cursor + 1), location];
+ cursor = entries.length - 1;
+ publish();
+ },
+ };
+}
diff --git a/apps/mobile/src/features/navigation/native-navigation-history-items.ts b/apps/mobile/src/features/navigation/native-navigation-history-items.ts
new file mode 100644
index 000000000000..d5fa2b0ee3cd
--- /dev/null
+++ b/apps/mobile/src/features/navigation/native-navigation-history-items.ts
@@ -0,0 +1,38 @@
+import type { NativeStackHeaderItem } from "@react-navigation/native-stack";
+
+import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
+
+type NativeHeaderIcon = NonNullable["icon"]>;
+
+function navigationIcon(name: "chevron.left" | "chevron.right"): NativeHeaderIcon {
+ return { name, type: "sfSymbol" };
+}
+
+export function createNativeNavigationHistoryItems(input: {
+ readonly canGoBack: boolean;
+ readonly canGoForward: boolean;
+ readonly identifierPrefix: string;
+ readonly onBack: () => void;
+ readonly onForward: () => void;
+}): NativeStackHeaderItem[] {
+ return [
+ withNativeGlassHeaderItem({
+ accessibilityLabel: "Back",
+ disabled: !input.canGoBack,
+ icon: navigationIcon("chevron.left"),
+ identifier: `${input.identifierPrefix}-back`,
+ label: "",
+ onPress: input.onBack,
+ type: "button" as const,
+ }),
+ withNativeGlassHeaderItem({
+ accessibilityLabel: "Forward",
+ disabled: !input.canGoForward,
+ icon: navigationIcon("chevron.right"),
+ identifier: `${input.identifierPrefix}-forward`,
+ label: "",
+ onPress: input.onForward,
+ type: "button" as const,
+ }),
+ ];
+}
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index b0e851b59d88..aad6b6a89b54 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -32,6 +32,7 @@ import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
+import { MobileNavigationHistoryButtons } from "../navigation/MobileNavigationHistoryButtons";
import { runtime } from "../../lib/runtime";
import { useThemeColor } from "../../lib/useThemeColor";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
@@ -74,7 +75,11 @@ export function SettingsRouteScreen() {
<>
{/* Android renders its own in-screen header instead of the native bar. */}
- navigation.goBack()} />
+ }
+ onBack={() => navigation.goBack()}
+ />
>
) : (
createSidebarHeaderItems({
+ canGoBack: navigationHistory.canGoBack,
+ canGoForward: navigationHistory.canGoForward,
filterIcon,
filterMenu,
+ onBack: navigationHistory.back,
+ onForward: navigationHistory.forward,
onOpenSettings: props.onOpenSettings,
}),
- [filterIcon, filterMenu, props.onOpenSettings],
+ [filterIcon, filterMenu, navigationHistory, props.onOpenSettings],
);
// Snoozed threads need no special case: the shelf header is a list row
// even while collapsed.
@@ -1383,6 +1390,7 @@ function ThreadNavigationSidebarPane(
}
/>
+
+ createNativeNavigationHistoryItems({
+ canGoBack: navigationHistory.canGoBack,
+ canGoForward: navigationHistory.canGoForward,
+ identifierPrefix: "thread-navigation",
+ onBack: navigationHistory.back,
+ onForward: navigationHistory.forward,
+ }),
+ [navigationHistory],
+ );
const splitLeftHeaderItems = useMemo(
() => [
{
@@ -682,6 +696,19 @@ function ThreadRouteContent(
if (Platform.OS !== "android") return [];
const actions: AndroidHeaderAction[] = [];
+ actions.push({
+ accessibilityLabel: "Forward",
+ disabled: !navigationHistory.canGoForward,
+ icon: "chevron.right",
+ onPress: navigationHistory.forward,
+ });
+ if (!navigationHistory.canGoBack) {
+ actions.push({
+ accessibilityLabel: "Go to threads list",
+ icon: "list.bullet",
+ onPress: () => navigation.dispatch(StackActions.replace("Home")),
+ });
+ }
if (props.onReturnToThread) {
actions.push({
accessibilityLabel: "Return to chat",
@@ -722,6 +749,8 @@ function ThreadRouteContent(
handleOpenTerminal,
handleOpenGitInspector,
handleToggleInspector,
+ navigationHistory,
+ navigation,
props.onReturnToThread,
selectedThreadCwd,
selectedThreadProject?.workspaceRoot,
@@ -730,7 +759,6 @@ function ThreadRouteContent(
// Deep links / cold starts land with Thread as the ONLY route, where the
// native back button does not render. Provide an explicit Home escape for
// that case; when history exists the native back button is used instead.
- const canGoBack = navigation.canGoBack();
const compactHomeHeaderItems = useMemo(
() => [
withNativeGlassHeaderItem({
@@ -816,6 +844,10 @@ function ThreadRouteContent(
<>
{activeInspectorRenderer ? : null}
splitLeftHeaderItems
- : canGoBack
- ? undefined
- : () => compactHomeHeaderItems
+ : () => [
+ ...compactNavigationHeaderItems,
+ ...(navigationHistory.canGoBack ? [] : compactHomeHeaderItems),
+ ]
: undefined,
// Search lives in the persistent sidebar, so the split header keeps
// the git controls on the RIGHT (no center items — center space is
@@ -853,9 +887,10 @@ function ThreadRouteContent(
{Platform.OS === "android" ? (
navigation.goBack()}
+ onBack={layout.usesSplitView ? undefined : navigationHistory.back}
actions={androidHeaderActions}
/>
) : null}
diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.ts
index b80fffda057d..fc5d0faeb10c 100644
--- a/apps/mobile/src/features/threads/sidebar-native-header-items.ts
+++ b/apps/mobile/src/features/threads/sidebar-native-header-items.ts
@@ -5,6 +5,7 @@ import type {
import type { HomeListFilterMenu } from "../home/home-list-filter-menu";
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
+import { createNativeNavigationHistoryItems } from "../navigation/native-navigation-history-items";
type NativeHeaderMenuItems = NativeStackHeaderItemMenu["menu"]["items"];
type NativeHeaderIcon = NonNullable["icon"]>;
@@ -32,16 +33,26 @@ function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHead
}
/**
- * Right-side UINavigationBar items for the sidebar column: the thread list
- * filter/sort menu plus the settings button, sharing one glass capsule —
- * the Messages-style grouped header buttons.
+ * Right-side UINavigationBar items for the sidebar column: navigation history,
+ * the thread-list menu, and settings, sharing one Messages-style glass group.
*/
export function createSidebarHeaderItems(input: {
+ readonly canGoBack: boolean;
+ readonly canGoForward: boolean;
readonly filterIcon: string;
readonly filterMenu: HomeListFilterMenu;
+ readonly onBack: () => void;
+ readonly onForward: () => void;
readonly onOpenSettings: () => void;
}): NativeStackHeaderItem[] {
return [
+ ...createNativeNavigationHistoryItems({
+ canGoBack: input.canGoBack,
+ canGoForward: input.canGoForward,
+ identifierPrefix: "sidebar-navigation",
+ onBack: input.onBack,
+ onForward: input.onForward,
+ }),
withNativeGlassHeaderItem({
type: "menu",
label: "",
diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts
index 24a137d933fa..bc18a9aad8e4 100644
--- a/apps/server/src/keybindings.test.ts
+++ b/apps/server/src/keybindings.test.ts
@@ -202,6 +202,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p");
assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f");
assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
+ assert.equal(defaultsByCommand.get("navigation.back"), "mod+[");
+ assert.equal(defaultsByCommand.get("navigation.forward"), "mod+]");
assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized"));
assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx
index a3ba76679689..689160daab74 100644
--- a/apps/web/src/components/AppSidebarLayout.tsx
+++ b/apps/web/src/components/AppSidebarLayout.tsx
@@ -19,6 +19,7 @@ import LegacyThreadSidebar from "./LegacySidebar";
import ThreadSidebar from "./Sidebar";
import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
+import { NavigationHistoryControls } from "./NavigationHistoryControls";
import {
resolveSidebarStageFocusRingOffsetClass,
useSidebarStageBackdropVariant,
@@ -64,7 +65,7 @@ function readInitialThreadSidebarWidth(): number {
}
}
-function SidebarControl() {
+function WorkspaceChromeControls() {
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const { toggleSidebar } = useSidebar();
const isSidebarVisible = useSidebarVisibility();
@@ -73,6 +74,13 @@ function SidebarControl() {
environmentIdentificationMode === "artwork",
);
const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle");
+ const backdropControlClass =
+ isSidebarVisible && stageBackdropVariant
+ ? cn(
+ "focus-visible:ring-white/90 [&_svg]:stroke-white/90! [&_svg]:opacity-100! not-aria-disabled:hover:[&_svg]:stroke-white! not-aria-disabled:[:hover,[data-pressed]]:bg-white/15",
+ resolveSidebarStageFocusRingOffsetClass(stageBackdropVariant),
+ )
+ : undefined;
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -107,15 +115,7 @@ function SidebarControl() {
}
@@ -124,6 +124,11 @@ function SidebarControl() {
Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""}
+
+
+
);
}
@@ -166,6 +171,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
});
const sidebarProviderStyle = {
"--sidebar-width": `${sidebarWidth}px`,
+ "--workspace-titlebar-content-left":
+ "calc(var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-size) + 0.25rem + var(--workspace-titlebar-control-gap))",
...(isMacosDesktop && !isWindowFullscreen
? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
: {}),
@@ -239,7 +246,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
{children}
-
+
);
}
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index c5ec3f095167..8cdecaaa58b7 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -36,6 +36,7 @@ import { useNavigate, useParams } from "@tanstack/react-router";
import * as Option from "effect/Option";
import {
ArrowLeftIcon,
+ ArrowRightIcon,
CornerLeftUpIcon,
FileSearchIcon,
FolderIcon,
@@ -153,6 +154,7 @@ import {
type ProviderInstanceEntry,
} from "../providerInstances";
import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
+import { useNavigationHistory } from "../navigationHistory";
import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
import { Button } from "./ui/button";
import { Kbd, KbdGroup } from "./ui/kbd";
@@ -591,6 +593,7 @@ function OpenCommandPaletteDialog(props: {
const projectOrder = useUiStateStore((store) => store.projectOrder);
const threads = useThreadShells();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
+ const navigationHistory = useNavigationHistory();
const { theme, themeHalves, resolvedTheme } = useTheme();
const providers = useAtomValue(primaryServerProvidersAtom);
const providerEntryByEnvironmentAndInstanceId = useMemo(() => {
@@ -1612,6 +1615,33 @@ function OpenCommandPaletteDialog(props: {
},
});
+ actionItems.push(
+ {
+ kind: "action",
+ value: "action:navigation-back",
+ searchTerms: ["back", "previous", "history", "navigation"],
+ title: "Go back",
+ disabled: !navigationHistory.canGoBack,
+ icon: ,
+ shortcutCommand: "navigation.back",
+ run: async () => {
+ navigationHistory.back();
+ },
+ },
+ {
+ kind: "action",
+ value: "action:navigation-forward",
+ searchTerms: ["forward", "next", "history", "navigation"],
+ title: "Go forward",
+ disabled: !navigationHistory.canGoForward,
+ icon: ,
+ shortcutCommand: "navigation.forward",
+ run: async () => {
+ navigationHistory.forward();
+ },
+ },
+ );
+
actionItems.push({
kind: "action",
value: "action:settings",
diff --git a/apps/web/src/components/NavigationHistoryControls.test.tsx b/apps/web/src/components/NavigationHistoryControls.test.tsx
new file mode 100644
index 000000000000..4aa2c7cd31d1
--- /dev/null
+++ b/apps/web/src/components/NavigationHistoryControls.test.tsx
@@ -0,0 +1,21 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { expect, it, vi } from "vite-plus/test";
+
+import { NavigationHistoryButtons } from "./NavigationHistoryControls";
+
+it("exposes named back and forward buttons with independent disabled states", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toMatch(/aria-disabled="true" aria-label="Back"/);
+ expect(markup).toMatch(/aria-disabled="false" aria-label="Forward"/);
+ expect(markup).toContain('aria-label="Navigation history"');
+});
diff --git a/apps/web/src/components/NavigationHistoryControls.tsx b/apps/web/src/components/NavigationHistoryControls.tsx
new file mode 100644
index 000000000000..593bbb3727a0
--- /dev/null
+++ b/apps/web/src/components/NavigationHistoryControls.tsx
@@ -0,0 +1,130 @@
+import { useAtomValue } from "@effect/atom-react";
+import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
+import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
+import { useEffect } from "react";
+
+import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
+import { isPreviewFocused } from "../lib/previewFocus";
+import { isTerminalFocused } from "../lib/terminalFocus";
+import { cn } from "../lib/utils";
+import { useNavigationHistory } from "../navigationHistory";
+import { primaryServerKeybindingsAtom } from "../state/server";
+import { Button } from "./ui/button";
+import { WORKSPACE_TITLEBAR_CONTROL_CLASS } from "./ui/sidebar";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
+interface NavigationHistoryButtonsProps {
+ readonly backShortcut: string | null;
+ readonly buttonClassName?: string;
+ readonly canGoBack: boolean;
+ readonly canGoForward: boolean;
+ readonly forwardShortcut: string | null;
+ readonly onBack: () => void;
+ readonly onForward: () => void;
+}
+function NavigationButton(props: {
+ readonly available: boolean;
+ readonly className?: string;
+ readonly icon: "back" | "forward";
+ readonly label: string;
+ readonly onPress: () => void;
+ readonly shortcut: string | null;
+}) {
+ return (
+
+ {
+ if (props.available) props.onPress();
+ }}
+ size="icon"
+ variant="ghost"
+ >
+ {props.icon === "back" ? : }
+
+ }
+ />
+
+ {props.shortcut ? `${props.label} (${props.shortcut})` : props.label}
+
+
+ );
+}
+export function NavigationHistoryButtons(props: NavigationHistoryButtonsProps) {
+ return (
+
+
+
+
+ );
+}
+function useNavigationHistoryShortcuts(input: {
+ readonly back: () => void;
+ readonly forward: () => void;
+ readonly keybindings: ResolvedKeybindingsConfig;
+}) {
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented || event.repeat) return;
+ if (event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]"))
+ return;
+ const command = resolveShortcutCommand(event, input.keybindings, {
+ context: {
+ previewFocus: isPreviewFocused(),
+ terminalFocus: isTerminalFocused(),
+ },
+ });
+ if (command !== "navigation.back" && command !== "navigation.forward") {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ (command === "navigation.back" ? input.back : input.forward)();
+ };
+ window.addEventListener("keydown", onKeyDown, true);
+ return () => window.removeEventListener("keydown", onKeyDown, true);
+ }, [input.back, input.forward, input.keybindings]);
+}
+export function NavigationHistoryControls({
+ buttonClassName,
+}: {
+ readonly buttonClassName?: string;
+}) {
+ const keybindings = useAtomValue(primaryServerKeybindingsAtom);
+ const { back, canGoBack, canGoForward, forward } = useNavigationHistory();
+ const backShortcut = shortcutLabelForCommand(keybindings, "navigation.back");
+ const forwardShortcut = shortcutLabelForCommand(keybindings, "navigation.forward");
+ useNavigationHistoryShortcuts({ back, forward, keybindings });
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx
index ce6e4cc78ca9..ca62b61f0da7 100644
--- a/apps/web/src/components/ui/sidebar.tsx
+++ b/apps/web/src/components/ui/sidebar.tsx
@@ -318,16 +318,16 @@ function Sidebar({
);
}
+export const WORKSPACE_TITLEBAR_CONTROL_CLASS =
+ "size-[var(--workspace-titlebar-control-size)]! [-webkit-app-region:no-drag]";
+
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) {
const { toggleSidebar } = useSidebar();
const isOpen = useSidebarVisibility();
return (