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 (