From 3859147db074b26309605cb190ce074e503e72c2 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Thu, 20 Aug 2026 18:46:29 -0400 Subject: [PATCH 01/21] feat(web): add back and forward navigation --- apps/server/src/keybindings.test.ts | 2 + apps/web/src/components/AppSidebarLayout.tsx | 23 +-- apps/web/src/components/CommandPalette.tsx | 30 ++++ .../NavigationHistoryControls.test.tsx | 25 ++++ .../components/NavigationHistoryControls.tsx | 141 ++++++++++++++++++ apps/web/src/components/ui/sidebar.tsx | 2 +- apps/web/src/navigationHistory.test.ts | 59 ++++++++ apps/web/src/navigationHistory.ts | 93 ++++++++++++ docs/user/keybindings.md | 5 + packages/contracts/src/keybindings.test.ts | 12 ++ packages/contracts/src/keybindings.ts | 2 + packages/shared/src/keybindings.ts | 2 + 12 files changed, 386 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/NavigationHistoryControls.test.tsx create mode 100644 apps/web/src/components/NavigationHistoryControls.tsx create mode 100644 apps/web/src/navigationHistory.test.ts create mode 100644 apps/web/src/navigationHistory.ts 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..f8b4c2647ef3 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, @@ -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! [&_svg]:hover:stroke-white! [: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})` : ""} +
+ +
); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b96eac7b0d71..0451ebc7401c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -34,6 +34,7 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { ArrowLeftIcon, + ArrowRightIcon, CornerLeftUpIcon, FileSearchIcon, FolderIcon, @@ -145,6 +146,7 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; +import { useNavigationHistory } from "../navigationHistory"; import { CommandDialog, CommandDialogPopup } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; @@ -593,6 +595,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(() => { @@ -1488,6 +1491,33 @@ function OpenCommandPaletteDialog(props: { const actionItems: Array = []; + 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(); + }, + }, + ); + if (projects.length > 0) { const activeProjectTitle = projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? diff --git a/apps/web/src/components/NavigationHistoryControls.test.tsx b/apps/web/src/components/NavigationHistoryControls.test.tsx new file mode 100644 index 000000000000..5192b31f31b4 --- /dev/null +++ b/apps/web/src/components/NavigationHistoryControls.test.tsx @@ -0,0 +1,25 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { NavigationHistoryButtons } from "./NavigationHistoryControls"; + +describe("NavigationHistoryButtons", () => { + it("exposes named back and forward buttons with independent disabled states", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Back"'); + expect(markup).toContain('aria-label="Forward"'); + 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..e98f9e6adad0 --- /dev/null +++ b/apps/web/src/components/NavigationHistoryControls.tsx @@ -0,0 +1,141 @@ +import { useAtomValue } from "@effect/atom-react"; +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 { 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 tooltipLabel(label: string, shortcut: string | null): string { + return shortcut ? `${label} (${shortcut})` : label; +} + +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" ? : } + + } + /> + {tooltipLabel(props.label, props.shortcut)} + + ); +} + +export function NavigationHistoryButtons(props: NavigationHistoryButtonsProps) { + return ( +
+ + +
+ ); +} + +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"); + + 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, keybindings, { + context: { + previewFocus: isPreviewFocused(), + terminalFocus: isTerminalFocused(), + }, + }); + if (command !== "navigation.back" && command !== "navigation.forward") { + return; + } + + event.preventDefault(); + event.stopPropagation(); + if (command === "navigation.back") { + back(); + } else { + forward(); + } + }; + + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [back, forward, keybindings]); + + return ( + + ); +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index ce6e4cc78ca9..d80bbd1506af 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -167,7 +167,7 @@ function SidebarProvider({ "--sidebar-width": SIDEBAR_WIDTH, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, "--workspace-titlebar-content-left": - "calc(var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap))", + "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))", ...style, } as React.CSSProperties } diff --git a/apps/web/src/navigationHistory.test.ts b/apps/web/src/navigationHistory.test.ts new file mode 100644 index 000000000000..0b0c0bf35b46 --- /dev/null +++ b/apps/web/src/navigationHistory.test.ts @@ -0,0 +1,59 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { describe, expect, it } from "vite-plus/test"; + +import { createNavigationHistory } from "./navigationHistory"; + +describe("createNavigationHistory", () => { + it("tracks back and forward availability through navigation", () => { + const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); + const history = createNavigationHistory(routerHistory); + + expect(history.getSnapshot()).toEqual({ canGoBack: false, canGoForward: false }); + + routerHistory.push("/thread-a"); + routerHistory.push("/thread-b"); + expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); + + history.back(); + expect(routerHistory.location.pathname).toBe("/thread-a"); + expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); + + history.forward(); + expect(routerHistory.location.pathname).toBe("/thread-b"); + expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); + }); + + it("drops the forward path after navigating somewhere new", () => { + const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); + const history = createNavigationHistory(routerHistory); + + routerHistory.push("/thread-a"); + routerHistory.push("/thread-b"); + history.back(); + expect(history.getSnapshot().canGoForward).toBe(true); + + routerHistory.push("/settings/general"); + expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); + history.forward(); + expect(routerHistory.location.pathname).toBe("/settings/general"); + }); + + it("notifies subscribers only when availability changes", () => { + const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); + const history = createNavigationHistory(routerHistory); + const snapshots: Array> = []; + const unsubscribe = history.subscribe(() => snapshots.push(history.getSnapshot())); + + routerHistory.replace("/?tab=all"); + routerHistory.push("/thread-a"); + routerHistory.push("/thread-b"); + history.back(); + + expect(snapshots).toEqual([ + { canGoBack: true, canGoForward: false }, + { canGoBack: true, canGoForward: true }, + ]); + + unsubscribe(); + }); +}); diff --git a/apps/web/src/navigationHistory.ts b/apps/web/src/navigationHistory.ts new file mode 100644 index 000000000000..96b6357883ff --- /dev/null +++ b/apps/web/src/navigationHistory.ts @@ -0,0 +1,93 @@ +import type { RouterHistory } from "@tanstack/react-router"; +import { useRouter } from "@tanstack/react-router"; +import { useSyncExternalStore } from "react"; + +export interface NavigationHistorySnapshot { + readonly canGoBack: boolean; + readonly canGoForward: boolean; +} + +export interface NavigationHistory { + readonly back: () => void; + readonly forward: () => void; + readonly getSnapshot: () => NavigationHistorySnapshot; + readonly subscribe: (listener: () => void) => () => void; +} + +function snapshotFor(history: RouterHistory, maximumIndex: number): NavigationHistorySnapshot { + return { + canGoBack: history.canGoBack(), + canGoForward: history.location.state.__TSR_index < maximumIndex, + }; +} + +export function createNavigationHistory(history: RouterHistory): NavigationHistory { + let maximumIndex = history.location.state.__TSR_index; + let snapshot = snapshotFor(history, maximumIndex); + const listeners = new Set<() => void>(); + + history.subscribe(({ action, location }) => { + if (action.type === "PUSH") { + maximumIndex = location.state.__TSR_index; + } else { + maximumIndex = Math.max(maximumIndex, location.state.__TSR_index); + } + + const nextSnapshot = snapshotFor(history, maximumIndex); + if ( + nextSnapshot.canGoBack === snapshot.canGoBack && + nextSnapshot.canGoForward === snapshot.canGoForward + ) { + return; + } + + snapshot = nextSnapshot; + listeners.forEach((listener) => listener()); + }); + + return { + back: () => { + if (snapshot.canGoBack) { + history.back(); + } + }, + forward: () => { + if (snapshot.canGoForward) { + history.forward(); + } + }, + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +const navigationHistoryByRouterHistory = new WeakMap(); + +function navigationHistoryFor(history: RouterHistory): NavigationHistory { + const existing = navigationHistoryByRouterHistory.get(history); + if (existing) { + return existing; + } + const navigationHistory = createNavigationHistory(history); + navigationHistoryByRouterHistory.set(history, navigationHistory); + return navigationHistory; +} + +export function useNavigationHistory(): NavigationHistorySnapshot & + Pick { + const router = useRouter(); + const history = navigationHistoryFor(router.history); + const snapshot = useSyncExternalStore( + history.subscribe, + history.getSnapshot, + history.getSnapshot, + ); + return { + ...snapshot, + back: history.back, + forward: history.forward, + }; +} diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 8e56a79a287d..828a2bb095ea 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -52,6 +52,11 @@ successful pick; its hover glow and badge preview the element and color family t `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, so add one in **Settings** → **Keybindings** if you want to use it. +`navigation.back` and `navigation.forward` move through the locations you visited in T3 Code. The +defaults are `mod+[` and `mod+]`. The same actions are available from the arrow buttons beside the +sidebar toggle and from the command palette. The defaults do not run while a terminal or browser +preview has focus. + The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while keeping the thread's project, branch, and machine context visible. Message search begins after two diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 71d8624a8aea..519f2e264208 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -36,6 +36,18 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle"); + const parsedNavigationBack = yield* decode(KeybindingRule, { + key: "mod+[", + command: "navigation.back", + }); + assert.strictEqual(parsedNavigationBack.command, "navigation.back"); + + const parsedNavigationForward = yield* decode(KeybindingRule, { + key: "mod+]", + command: "navigation.forward", + }); + assert.strictEqual(parsedNavigationForward.command, "navigation.forward"); + const parsedRightPanelToggle = yield* decode(KeybindingRule, { key: "mod+alt+b", command: "rightPanel.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 19276c41e7b6..5954c68d640f 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -49,6 +49,8 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA export const STATIC_KEYBINDING_COMMANDS = [ "sidebar.toggle", + "navigation.back", + "navigation.forward", "terminal.toggle", "terminal.split", "terminal.splitVertical", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 158a9ffb1ac9..3aa6191977ad 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -20,6 +20,8 @@ type WhenToken = export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+b", command: "sidebar.toggle" }, + { key: "mod+[", command: "navigation.back", when: "!terminalFocus && !previewFocus" }, + { key: "mod+]", command: "navigation.forward", when: "!terminalFocus && !previewFocus" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, From e5077eeb6018c5a847837db2070e887ff1b375be Mon Sep 17 00:00:00 2001 From: sethwebster Date: Thu, 20 Aug 2026 19:00:06 -0400 Subject: [PATCH 02/21] fix(clients): complete navigation history controls --- .../ios/T3KeyboardCommandsModule.swift | 3 + apps/mobile/src/Stack.tsx | 21 +++-- apps/mobile/src/features/home/HomeHeader.tsx | 29 ++++++ .../HardwareKeyboardCommandProvider.tsx | 17 +++- .../keyboard/hardwareKeyboardCommands.ts | 1 + .../MobileNavigationHistoryButtons.tsx | 32 +++++++ .../MobileNavigationHistoryProvider.tsx | 78 +++++++++++++++ .../mobile-navigation-history.test.ts | 40 ++++++++ .../navigation/mobile-navigation-history.ts | 76 +++++++++++++++ .../threads/ThreadNavigationSidebar.tsx | 10 +- .../features/threads/ThreadRouteScreen.tsx | 28 +++++- .../sidebar-native-header-items.test.ts | 22 +++++ .../threads/sidebar-native-header-items.ts | 20 ++++ apps/web/src/components/AppSidebarLayout.tsx | 2 + .../components/NavigationHistoryControls.tsx | 36 ++++--- apps/web/src/components/ui/sidebar.tsx | 2 +- apps/web/src/navigationHistory.test.ts | 6 +- apps/web/src/navigationHistory.ts | 79 +--------------- apps/web/src/navigationHistoryStore.ts | 94 +++++++++++++++++++ apps/web/src/router.ts | 2 + docs/user/keybindings.md | 7 +- 21 files changed, 496 insertions(+), 109 deletions(-) create mode 100644 apps/mobile/src/features/navigation/MobileNavigationHistoryButtons.tsx create mode 100644 apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx create mode 100644 apps/mobile/src/features/navigation/mobile-navigation-history.test.ts create mode 100644 apps/mobile/src/features/navigation/mobile-navigation-history.ts create mode 100644 apps/mobile/src/features/threads/sidebar-native-header-items.test.ts create mode 100644 apps/web/src/navigationHistoryStore.ts 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..e1222b8c6756 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -23,6 +23,7 @@ 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 { MobileNavigationHistoryProvider } from "./features/navigation/MobileNavigationHistoryProvider"; import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; import { ReviewSheet } from "./features/review/ReviewSheet"; import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; @@ -392,15 +393,17 @@ function RootStackLayout(props: { const workspacePathname = workspacePathFromState(props.state); return ( - - - - - - {props.children} - - - + + + + + + + {props.children} + + + + ); } diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e7ce41cb43bd..6eebcc155313 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -15,6 +15,8 @@ import { resolveMobileStageLabel } from "../../lib/mobileBranding"; import { useThemeColor } from "../../lib/useThemeColor"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; +import { MobileNavigationHistoryButtons } from "../navigation/MobileNavigationHistoryButtons"; +import { useMobileNavigationHistory } from "../navigation/MobileNavigationHistoryProvider"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem, @@ -231,6 +233,8 @@ function AndroidHomeHeader(props: HomeHeaderProps) { } /> + + (null); const iconColor = useThemeColor("--color-icon"); // Thread List v2 lays the list out in fixed creation order, so the @@ -317,6 +322,29 @@ function IosHomeHeader(props: HomeHeaderProps) { ...props, listOrganization: !threadListV2Enabled, }); + const navigationHeaderItems = useMemo( + () => [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Back", + disabled: !navigationHistory.canGoBack, + icon: { name: "chevron.left", type: "sfSymbol" } as const, + identifier: "home-navigation-back", + label: "", + onPress: navigationHistory.back, + type: "button" as const, + }), + withNativeGlassHeaderItem({ + accessibilityLabel: "Forward", + disabled: !navigationHistory.canGoForward, + icon: { name: "chevron.right", type: "sfSymbol" } as const, + identifier: "home-navigation-forward", + label: "", + onPress: navigationHistory.forward, + type: "button" as const, + }), + ], + [navigationHistory], + ); return ( <> @@ -329,6 +357,7 @@ function IosHomeHeader(props: HomeHeaderProps) { unstable_headerRightItems: Platform.OS === "ios" ? () => [ + ...navigationHeaderItems, withNativeGlassHeaderItem({ accessibilityLabel: "Open settings", icon: { name: "ellipsis", type: "sfSymbol" } as const, diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 96a4a63e9018..aac36b25e98b 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -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 (pathname !== "/" || 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,13 +45,17 @@ export function HardwareKeyboardCommandProvider({ return; } if (command === "back") { - if (navigation.canGoBack()) { - navigation.goBack(); + if (navigationHistory.canGoBack) { + navigationHistory.back(); } else { navigation.dispatch(StackActions.replace("Home")); } return; } + if (command === "forward") { + navigationHistory.forward(); + return; + } const thread = parseActiveThreadPath(pathname); if (!thread) return; @@ -62,7 +69,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..b5c61f35a03b --- /dev/null +++ b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx @@ -0,0 +1,78 @@ +import { useLinkTo, useNavigation } from "@react-navigation/native"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type PropsWithChildren, +} from "react"; + +import { + createMobileNavigationHistory, + type MobileNavigationHistory, + type MobileNavigationHistorySnapshot, +} from "./mobile-navigation-history"; + +interface MobileNavigationHistoryValue extends MobileNavigationHistorySnapshot { + readonly back: () => void; + readonly forward: () => void; +} + +const MobileNavigationHistoryContext = createContext(null); + +function useSyncVisitedPath(history: MobileNavigationHistory, pathname: string): void { + useEffect(() => { + history.visit(pathname); + }, [history, pathname]); +} + +export function MobileNavigationHistoryProvider({ + children, + pathname, +}: PropsWithChildren<{ readonly pathname: string }>) { + const navigation = useNavigation(); + const linkTo = useLinkTo(); + const [history] = useState(() => createMobileNavigationHistory(pathname)); + const snapshot = useSyncExternalStore( + history.subscribe, + history.getSnapshot, + history.getSnapshot, + ); + useSyncVisitedPath(history, pathname); + + const back = useCallback(() => { + const target = history.back(); + if (!target) { + return; + } + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + linkTo(target); + } + }, [history, linkTo, navigation]); + const forward = useCallback(() => { + const target = history.forward(); + if (target) { + linkTo(target); + } + }, [history, linkTo]); + const value = useMemo(() => ({ ...snapshot, back, forward }), [back, forward, snapshot]); + + return ( + + {children} + + ); +} + +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..82a9609f85ee --- /dev/null +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { createMobileNavigationHistory } from "./mobile-navigation-history"; + +describe("createMobileNavigationHistory", () => { + it("moves backward and forward through visited paths", () => { + const history = createMobileNavigationHistory("/"); + history.visit("/threads/env/thread-a"); + history.visit("/threads/env/thread-b"); + + expect(history.back()).toBe("/threads/env/thread-a"); + expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); + + expect(history.forward()).toBe("/threads/env/thread-b"); + expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); + }); + + it("drops forward paths after a new visit", () => { + const history = createMobileNavigationHistory("/"); + history.visit("/threads/env/thread-a"); + history.visit("/threads/env/thread-b"); + history.back(); + + history.visit("/settings"); + + expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); + expect(history.forward()).toBeNull(); + }); + + it("recognizes native back navigation", () => { + const history = createMobileNavigationHistory("/"); + history.visit("/threads/env/thread-a"); + history.visit("/settings"); + + history.visit("/threads/env/thread-a"); + + expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); + expect(history.forward()).toBe("/settings"); + }); +}); 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..f228481c2cde --- /dev/null +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.ts @@ -0,0 +1,76 @@ +export interface MobileNavigationHistorySnapshot { + readonly canGoBack: boolean; + readonly canGoForward: boolean; +} + +export interface MobileNavigationHistory { + readonly back: () => string | null; + readonly forward: () => string | null; + readonly getSnapshot: () => MobileNavigationHistorySnapshot; + readonly subscribe: (listener: () => void) => () => void; + readonly visit: (pathname: string) => void; +} + +function snapshotFor(cursor: number, entryCount: number): MobileNavigationHistorySnapshot { + return { + canGoBack: cursor > 0, + canGoForward: cursor < entryCount - 1, + }; +} + +export function createMobileNavigationHistory(initialPathname: string): MobileNavigationHistory { + let entries = [initialPathname]; + let cursor = 0; + let snapshot = snapshotFor(cursor, entries.length); + const listeners = new Set<() => void>(); + + const publish = () => { + const nextSnapshot = snapshotFor(cursor, entries.length); + if ( + nextSnapshot.canGoBack === snapshot.canGoBack && + nextSnapshot.canGoForward === snapshot.canGoForward + ) { + return; + } + snapshot = nextSnapshot; + listeners.forEach((listener) => listener()); + }; + + const move = (nextCursor: number): string | null => { + const pathname = entries[nextCursor]; + if (pathname === undefined) { + return null; + } + cursor = nextCursor; + publish(); + return pathname; + }; + + return { + back: () => move(cursor - 1), + forward: () => move(cursor + 1), + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + visit: (pathname) => { + if (pathname === entries[cursor]) { + return; + } + if (pathname === entries[cursor - 1]) { + cursor -= 1; + publish(); + return; + } + if (pathname === entries[cursor + 1]) { + cursor += 1; + publish(); + return; + } + entries = [...entries.slice(0, cursor + 1), pathname]; + cursor = entries.length - 1; + publish(); + }, + }; +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 2e8186fa8e25..3e7b97c23a6a 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -67,6 +67,8 @@ import { import { SidebarHeaderActions } from "./sidebar-header-actions"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; +import { MobileNavigationHistoryButtons } from "../navigation/MobileNavigationHistoryButtons"; +import { useMobileNavigationHistory } from "../navigation/MobileNavigationHistoryProvider"; import { SidebarNavigationShell } from "./sidebar-navigation-shell"; import { PendingTaskListRow, @@ -194,6 +196,7 @@ function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { const insets = useSafeAreaInsets(); + const navigationHistory = useMobileNavigationHistory(); const { themeAppearance: colorScheme } = useAppearancePreferences(); const projects = useProjects(); const threads = useThreadShells(); @@ -1194,11 +1197,15 @@ function ThreadNavigationSidebarPane( const nativeHeaderItems = useMemo( () => 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( } /> + ( + () => [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Forward", + disabled: !navigationHistory.canGoForward, + icon: { name: "chevron.right", type: "sfSymbol" as const }, + identifier: "thread-navigation-forward", + onPress: navigationHistory.forward, + type: "button" as const, + }), + ...compactRightHeaderItems, + ], + [compactRightHeaderItems, navigationHistory], + ); const splitLeftHeaderItems = useMemo( () => [ { @@ -682,6 +698,12 @@ function ThreadRouteContent( if (Platform.OS !== "android") return []; const actions: AndroidHeaderAction[] = []; + actions.push({ + accessibilityLabel: "Forward", + disabled: !navigationHistory.canGoForward, + icon: "chevron.right", + onPress: navigationHistory.forward, + }); if (props.onReturnToThread) { actions.push({ accessibilityLabel: "Return to chat", @@ -722,6 +744,7 @@ function ThreadRouteContent( handleOpenTerminal, handleOpenGitInspector, handleToggleInspector, + navigationHistory, props.onReturnToThread, selectedThreadCwd, selectedThreadProject?.workspaceRoot, @@ -845,7 +868,10 @@ function ThreadRouteContent( // reserved for future breadcrumbs/status). unstable_headerRightItems: Platform.OS === "ios" - ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) + ? () => + layout.usesSplitView + ? threadCenterHeaderItems + : compactRightHeaderItemsWithHistory : undefined, unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, }} diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.test.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.test.ts new file mode 100644 index 000000000000..617b91c9df3e --- /dev/null +++ b/apps/mobile/src/features/threads/sidebar-native-header-items.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "@effect/vitest"; + +import { createSidebarHeaderItems } from "./sidebar-native-header-items"; + +describe("createSidebarHeaderItems", () => { + it("adds independently disabled Back and Forward controls", () => { + const items = createSidebarHeaderItems({ + canGoBack: false, + canGoForward: true, + filterIcon: "line.3.horizontal.decrease", + filterMenu: { title: "Thread list options", items: [] }, + onBack: vi.fn(), + onForward: vi.fn(), + onOpenSettings: vi.fn(), + }); + + expect(items.slice(0, 2)).toEqual([ + expect.objectContaining({ accessibilityLabel: "Back", disabled: true }), + expect.objectContaining({ accessibilityLabel: "Forward", disabled: false }), + ]); + }); +}); 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..58cf69b90eee 100644 --- a/apps/mobile/src/features/threads/sidebar-native-header-items.ts +++ b/apps/mobile/src/features/threads/sidebar-native-header-items.ts @@ -37,11 +37,31 @@ function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHead * the Messages-style grouped header buttons. */ 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 [ + withNativeGlassHeaderItem({ + type: "button", + label: "", + accessibilityLabel: "Back", + disabled: !input.canGoBack, + icon: sfSymbolIcon("chevron.left"), + onPress: input.onBack, + }), + withNativeGlassHeaderItem({ + type: "button", + label: "", + accessibilityLabel: "Forward", + disabled: !input.canGoForward, + icon: sfSymbolIcon("chevron.right"), + onPress: input.onForward, + }), withNativeGlassHeaderItem({ type: "menu", label: "", diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index f8b4c2647ef3..77f848b614d3 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -171,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 } : {}), diff --git a/apps/web/src/components/NavigationHistoryControls.tsx b/apps/web/src/components/NavigationHistoryControls.tsx index e98f9e6adad0..9a82f8375001 100644 --- a/apps/web/src/components/NavigationHistoryControls.tsx +++ b/apps/web/src/components/NavigationHistoryControls.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { useEffect } from "react"; @@ -84,16 +85,11 @@ export function NavigationHistoryButtons(props: NavigationHistoryButtonsProps) { ); } -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"); - +function useNavigationHistoryShortcuts(input: { + readonly back: () => void; + readonly forward: () => void; + readonly keybindings: ResolvedKeybindingsConfig; +}): void { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.repeat) return; @@ -104,7 +100,7 @@ export function NavigationHistoryControls({ return; } - const command = resolveShortcutCommand(event, keybindings, { + const command = resolveShortcutCommand(event, input.keybindings, { context: { previewFocus: isPreviewFocused(), terminalFocus: isTerminalFocused(), @@ -117,15 +113,27 @@ export function NavigationHistoryControls({ event.preventDefault(); event.stopPropagation(); if (command === "navigation.back") { - back(); + input.back(); } else { - forward(); + input.forward(); } }; window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); - }, [back, forward, keybindings]); + }, [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 ( { it("tracks back and forward availability through navigation", () => { const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); const history = createNavigationHistory(routerHistory); + const unsubscribe = history.subscribe(() => undefined); expect(history.getSnapshot()).toEqual({ canGoBack: false, canGoForward: false }); @@ -21,11 +22,13 @@ describe("createNavigationHistory", () => { history.forward(); expect(routerHistory.location.pathname).toBe("/thread-b"); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); + unsubscribe(); }); it("drops the forward path after navigating somewhere new", () => { const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); const history = createNavigationHistory(routerHistory); + const unsubscribe = history.subscribe(() => undefined); routerHistory.push("/thread-a"); routerHistory.push("/thread-b"); @@ -36,6 +39,7 @@ describe("createNavigationHistory", () => { expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); history.forward(); expect(routerHistory.location.pathname).toBe("/settings/general"); + unsubscribe(); }); it("notifies subscribers only when availability changes", () => { diff --git a/apps/web/src/navigationHistory.ts b/apps/web/src/navigationHistory.ts index 96b6357883ff..68ca02086193 100644 --- a/apps/web/src/navigationHistory.ts +++ b/apps/web/src/navigationHistory.ts @@ -1,80 +1,11 @@ -import type { RouterHistory } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router"; import { useSyncExternalStore } from "react"; -export interface NavigationHistorySnapshot { - readonly canGoBack: boolean; - readonly canGoForward: boolean; -} - -export interface NavigationHistory { - readonly back: () => void; - readonly forward: () => void; - readonly getSnapshot: () => NavigationHistorySnapshot; - readonly subscribe: (listener: () => void) => () => void; -} - -function snapshotFor(history: RouterHistory, maximumIndex: number): NavigationHistorySnapshot { - return { - canGoBack: history.canGoBack(), - canGoForward: history.location.state.__TSR_index < maximumIndex, - }; -} - -export function createNavigationHistory(history: RouterHistory): NavigationHistory { - let maximumIndex = history.location.state.__TSR_index; - let snapshot = snapshotFor(history, maximumIndex); - const listeners = new Set<() => void>(); - - history.subscribe(({ action, location }) => { - if (action.type === "PUSH") { - maximumIndex = location.state.__TSR_index; - } else { - maximumIndex = Math.max(maximumIndex, location.state.__TSR_index); - } - - const nextSnapshot = snapshotFor(history, maximumIndex); - if ( - nextSnapshot.canGoBack === snapshot.canGoBack && - nextSnapshot.canGoForward === snapshot.canGoForward - ) { - return; - } - - snapshot = nextSnapshot; - listeners.forEach((listener) => listener()); - }); - - return { - back: () => { - if (snapshot.canGoBack) { - history.back(); - } - }, - forward: () => { - if (snapshot.canGoForward) { - history.forward(); - } - }, - getSnapshot: () => snapshot, - subscribe: (listener) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }; -} - -const navigationHistoryByRouterHistory = new WeakMap(); - -function navigationHistoryFor(history: RouterHistory): NavigationHistory { - const existing = navigationHistoryByRouterHistory.get(history); - if (existing) { - return existing; - } - const navigationHistory = createNavigationHistory(history); - navigationHistoryByRouterHistory.set(history, navigationHistory); - return navigationHistory; -} +import { + navigationHistoryFor, + type NavigationHistory, + type NavigationHistorySnapshot, +} from "./navigationHistoryStore"; export function useNavigationHistory(): NavigationHistorySnapshot & Pick { diff --git a/apps/web/src/navigationHistoryStore.ts b/apps/web/src/navigationHistoryStore.ts new file mode 100644 index 000000000000..c7fac7f52017 --- /dev/null +++ b/apps/web/src/navigationHistoryStore.ts @@ -0,0 +1,94 @@ +import type { RouterHistory } from "@tanstack/react-router"; + +export interface NavigationHistorySnapshot { + readonly canGoBack: boolean; + readonly canGoForward: boolean; +} + +export interface NavigationHistory { + readonly back: () => void; + readonly forward: () => void; + readonly getSnapshot: () => NavigationHistorySnapshot; + readonly subscribe: (listener: () => void) => () => void; +} + +function snapshotFor(history: RouterHistory, maximumIndex: number): NavigationHistorySnapshot { + return { + canGoBack: history.canGoBack(), + canGoForward: history.location.state.__TSR_index < maximumIndex, + }; +} + +export function createNavigationHistory(history: RouterHistory): NavigationHistory { + let maximumIndex = history.location.state.__TSR_index; + let snapshot = snapshotFor(history, maximumIndex); + let stopTracking: (() => void) | null = null; + const listeners = new Set<() => void>(); + + const update = ({ + action, + location, + }: Parameters[0]>[0]) => { + if (action.type === "PUSH") { + maximumIndex = location.state.__TSR_index; + } else { + maximumIndex = Math.max(maximumIndex, location.state.__TSR_index); + } + + const nextSnapshot = snapshotFor(history, maximumIndex); + if ( + nextSnapshot.canGoBack === snapshot.canGoBack && + nextSnapshot.canGoForward === snapshot.canGoForward + ) { + return; + } + + snapshot = nextSnapshot; + listeners.forEach((listener) => listener()); + }; + + return { + back: () => { + if (snapshot.canGoBack) { + history.back(); + } + }, + forward: () => { + if (snapshot.canGoForward) { + history.forward(); + } + }, + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + stopTracking ??= history.subscribe(update); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + stopTracking?.(); + stopTracking = null; + } + }; + }, + }; +} + +const navigationHistoryByRouterHistory = new WeakMap(); + +export function registerNavigationHistory(history: RouterHistory): NavigationHistory { + const existing = navigationHistoryByRouterHistory.get(history); + if (existing) { + return existing; + } + const navigationHistory = createNavigationHistory(history); + navigationHistoryByRouterHistory.set(history, navigationHistory); + return navigationHistory; +} + +export function navigationHistoryFor(history: RouterHistory): NavigationHistory { + const navigationHistory = navigationHistoryByRouterHistory.get(history); + if (!navigationHistory) { + throw new Error("Navigation history was not registered for this router"); + } + return navigationHistory; +} diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 86ba9d69a173..b4b461591b29 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 { routeTree } from "./routeTree.gen"; +import { registerNavigationHistory } from "./navigationHistoryStore"; export function getRouter(history: RouterHistory) { + registerNavigationHistory(history); return createRouter({ routeTree, history, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 828a2bb095ea..418080899410 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -53,9 +53,10 @@ successful pick; its hover glow and badge preview the element and color family t so add one in **Settings** → **Keybindings** if you want to use it. `navigation.back` and `navigation.forward` move through the locations you visited in T3 Code. The -defaults are `mod+[` and `mod+]`. The same actions are available from the arrow buttons beside the -sidebar toggle and from the command palette. The defaults do not run while a terminal or browser -preview has focus. +defaults are `mod+[` and `mod+]`. On web and desktop, the same actions are available from the arrow +buttons beside the sidebar toggle and from the command palette. Mobile shows the buttons in thread +navigation and supports `cmd+[` and `cmd+]` on an attached keyboard. The web and desktop defaults do +not run while a terminal or browser preview has focus. The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while From 9ef1e2b9feaba00ccf405b4fc8669448345bee43 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Thu, 20 Aug 2026 19:08:08 -0400 Subject: [PATCH 03/21] fix(clients): make navigation traversal exact --- apps/mobile/src/features/home/HomeHeader.tsx | 27 ++++-------- .../MobileNavigationHistoryProvider.tsx | 27 ++++-------- .../mobile-navigation-history.test.ts | 25 ++++++++--- .../navigation/mobile-navigation-history.ts | 30 +++++++------ .../native-navigation-history-items.ts | 43 +++++++++++++++++++ .../features/threads/ThreadRouteScreen.tsx | 15 ++++--- .../threads/sidebar-native-header-items.ts | 27 ++++-------- apps/web/src/components/AppSidebarLayout.tsx | 4 +- apps/web/src/navigationHistory.test.ts | 10 +++-- apps/web/src/navigationHistoryStore.ts | 24 +++++++---- 10 files changed, 136 insertions(+), 96 deletions(-) create mode 100644 apps/mobile/src/features/navigation/native-navigation-history-items.ts diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 6eebcc155313..919911a7eb8d 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -17,6 +17,7 @@ import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { MobileNavigationHistoryButtons } from "../navigation/MobileNavigationHistoryButtons"; import { useMobileNavigationHistory } from "../navigation/MobileNavigationHistoryProvider"; +import { createNativeNavigationHistoryItems } from "../navigation/native-navigation-history-items"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem, @@ -323,26 +324,14 @@ function IosHomeHeader(props: HomeHeaderProps) { listOrganization: !threadListV2Enabled, }); const navigationHeaderItems = useMemo( - () => [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Back", - disabled: !navigationHistory.canGoBack, - icon: { name: "chevron.left", type: "sfSymbol" } as const, - identifier: "home-navigation-back", - label: "", - onPress: navigationHistory.back, - type: "button" as const, - }), - withNativeGlassHeaderItem({ - accessibilityLabel: "Forward", - disabled: !navigationHistory.canGoForward, - icon: { name: "chevron.right", type: "sfSymbol" } as const, - identifier: "home-navigation-forward", - label: "", - onPress: navigationHistory.forward, - type: "button" as const, + () => + createNativeNavigationHistoryItems({ + canGoBack: navigationHistory.canGoBack, + canGoForward: navigationHistory.canGoForward, + identifierPrefix: "home-navigation", + onBack: navigationHistory.back, + onForward: navigationHistory.forward, }), - ], [navigationHistory], ); diff --git a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx index b5c61f35a03b..a433bcfa41a3 100644 --- a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx +++ b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx @@ -1,4 +1,4 @@ -import { useLinkTo, useNavigation } from "@react-navigation/native"; +import { useLinkTo } from "@react-navigation/native"; import { createContext, useCallback, @@ -12,7 +12,6 @@ import { import { createMobileNavigationHistory, - type MobileNavigationHistory, type MobileNavigationHistorySnapshot, } from "./mobile-navigation-history"; @@ -23,17 +22,10 @@ interface MobileNavigationHistoryValue extends MobileNavigationHistorySnapshot { const MobileNavigationHistoryContext = createContext(null); -function useSyncVisitedPath(history: MobileNavigationHistory, pathname: string): void { - useEffect(() => { - history.visit(pathname); - }, [history, pathname]); -} - export function MobileNavigationHistoryProvider({ children, pathname, }: PropsWithChildren<{ readonly pathname: string }>) { - const navigation = useNavigation(); const linkTo = useLinkTo(); const [history] = useState(() => createMobileNavigationHistory(pathname)); const snapshot = useSyncExternalStore( @@ -41,21 +33,18 @@ export function MobileNavigationHistoryProvider({ history.getSnapshot, history.getSnapshot, ); - useSyncVisitedPath(history, pathname); + useEffect(() => { + history.visit(pathname); + }, [history, pathname]); const back = useCallback(() => { - const target = history.back(); - if (!target) { - return; - } - if (navigation.canGoBack()) { - navigation.goBack(); - } else { + const target = history.backTarget(); + if (target) { linkTo(target); } - }, [history, linkTo, navigation]); + }, [history, linkTo]); const forward = useCallback(() => { - const target = history.forward(); + const target = history.forwardTarget(); if (target) { linkTo(target); } diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts index 82a9609f85ee..38724f3b7424 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts @@ -8,10 +8,14 @@ describe("createMobileNavigationHistory", () => { history.visit("/threads/env/thread-a"); history.visit("/threads/env/thread-b"); - expect(history.back()).toBe("/threads/env/thread-a"); + const backTarget = history.backTarget(); + expect(backTarget).toBe("/threads/env/thread-a"); + history.visit(backTarget!); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); - expect(history.forward()).toBe("/threads/env/thread-b"); + const forwardTarget = history.forwardTarget(); + expect(forwardTarget).toBe("/threads/env/thread-b"); + history.visit(forwardTarget!); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); }); @@ -19,12 +23,12 @@ describe("createMobileNavigationHistory", () => { const history = createMobileNavigationHistory("/"); history.visit("/threads/env/thread-a"); history.visit("/threads/env/thread-b"); - history.back(); + history.visit(history.backTarget()!); history.visit("/settings"); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); - expect(history.forward()).toBeNull(); + expect(history.forwardTarget()).toBeNull(); }); it("recognizes native back navigation", () => { @@ -35,6 +39,17 @@ describe("createMobileNavigationHistory", () => { history.visit("/threads/env/thread-a"); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); - expect(history.forward()).toBe("/settings"); + expect(history.forwardTarget()).toBe("/settings"); + }); + + it("reconciles non-adjacent native back navigation without adding a duplicate", () => { + const history = createMobileNavigationHistory("/"); + history.visit("/threads/env/thread-a"); + history.visit("/threads/env/thread-b"); + + history.visit("/"); + + expect(history.getSnapshot()).toEqual({ canGoBack: false, canGoForward: true }); + expect(history.forwardTarget()).toBe("/threads/env/thread-a"); }); }); diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.ts index f228481c2cde..f299648f68b8 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.ts @@ -4,8 +4,8 @@ export interface MobileNavigationHistorySnapshot { } export interface MobileNavigationHistory { - readonly back: () => string | null; - readonly forward: () => string | null; + readonly backTarget: () => string | null; + readonly forwardTarget: () => string | null; readonly getSnapshot: () => MobileNavigationHistorySnapshot; readonly subscribe: (listener: () => void) => () => void; readonly visit: (pathname: string) => void; @@ -36,19 +36,9 @@ export function createMobileNavigationHistory(initialPathname: string): MobileNa listeners.forEach((listener) => listener()); }; - const move = (nextCursor: number): string | null => { - const pathname = entries[nextCursor]; - if (pathname === undefined) { - return null; - } - cursor = nextCursor; - publish(); - return pathname; - }; - return { - back: () => move(cursor - 1), - forward: () => move(cursor + 1), + backTarget: () => entries[cursor - 1] ?? null, + forwardTarget: () => entries[cursor + 1] ?? null, getSnapshot: () => snapshot, subscribe: (listener) => { listeners.add(listener); @@ -68,6 +58,18 @@ export function createMobileNavigationHistory(initialPathname: string): MobileNa publish(); return; } + const priorIndex = entries.lastIndexOf(pathname, cursor - 1); + if (priorIndex >= 0) { + cursor = priorIndex; + publish(); + return; + } + const forwardIndex = entries.indexOf(pathname, cursor + 1); + if (forwardIndex >= 0) { + cursor = forwardIndex; + publish(); + return; + } entries = [...entries.slice(0, cursor + 1), pathname]; 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..7a6c197740de --- /dev/null +++ b/apps/mobile/src/features/navigation/native-navigation-history-items.ts @@ -0,0 +1,43 @@ +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 includeBack?: boolean; + readonly onBack: () => void; + readonly onForward: () => void; +}): NativeStackHeaderItem[] { + return [ + ...(input.includeBack === false + ? [] + : [ + 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/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6c4cbd82c9e3..fcaa264c5e05 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -30,6 +30,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { connectionTone } from "../connection/connectionTone"; import { useMobileNavigationHistory } from "../navigation/MobileNavigationHistoryProvider"; +import { createNativeNavigationHistoryItems } from "../navigation/native-navigation-history-items"; import { useRemoteConnections, @@ -641,13 +642,13 @@ function ThreadRouteContent( const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps); const compactRightHeaderItemsWithHistory = useMemo( () => [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Forward", - disabled: !navigationHistory.canGoForward, - icon: { name: "chevron.right", type: "sfSymbol" as const }, - identifier: "thread-navigation-forward", - onPress: navigationHistory.forward, - type: "button" as const, + ...createNativeNavigationHistoryItems({ + canGoBack: navigationHistory.canGoBack, + canGoForward: navigationHistory.canGoForward, + identifierPrefix: "thread-navigation", + includeBack: false, + onBack: navigationHistory.back, + onForward: navigationHistory.forward, }), ...compactRightHeaderItems, ], 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 58cf69b90eee..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,9 +33,8 @@ 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; @@ -46,21 +46,12 @@ export function createSidebarHeaderItems(input: { readonly onOpenSettings: () => void; }): NativeStackHeaderItem[] { return [ - withNativeGlassHeaderItem({ - type: "button", - label: "", - accessibilityLabel: "Back", - disabled: !input.canGoBack, - icon: sfSymbolIcon("chevron.left"), - onPress: input.onBack, - }), - withNativeGlassHeaderItem({ - type: "button", - label: "", - accessibilityLabel: "Forward", - disabled: !input.canGoForward, - icon: sfSymbolIcon("chevron.right"), - onPress: input.onForward, + ...createNativeNavigationHistoryItems({ + canGoBack: input.canGoBack, + canGoForward: input.canGoForward, + identifierPrefix: "sidebar-navigation", + onBack: input.onBack, + onForward: input.onForward, }), withNativeGlassHeaderItem({ type: "menu", diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 77f848b614d3..57b931950afc 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -65,7 +65,7 @@ function readInitialThreadSidebarWidth(): number { } } -function SidebarControl() { +function WorkspaceChromeControls() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); @@ -246,7 +246,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} - + ); } diff --git a/apps/web/src/navigationHistory.test.ts b/apps/web/src/navigationHistory.test.ts index c580b0b67fc6..4f499682e65b 100644 --- a/apps/web/src/navigationHistory.test.ts +++ b/apps/web/src/navigationHistory.test.ts @@ -7,7 +7,7 @@ describe("createNavigationHistory", () => { it("tracks back and forward availability through navigation", () => { const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); const history = createNavigationHistory(routerHistory); - const unsubscribe = history.subscribe(() => undefined); + history.start(); expect(history.getSnapshot()).toEqual({ canGoBack: false, canGoForward: false }); @@ -22,13 +22,13 @@ describe("createNavigationHistory", () => { history.forward(); expect(routerHistory.location.pathname).toBe("/thread-b"); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); - unsubscribe(); + history.dispose(); }); it("drops the forward path after navigating somewhere new", () => { const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); const history = createNavigationHistory(routerHistory); - const unsubscribe = history.subscribe(() => undefined); + history.start(); routerHistory.push("/thread-a"); routerHistory.push("/thread-b"); @@ -39,7 +39,7 @@ describe("createNavigationHistory", () => { expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); history.forward(); expect(routerHistory.location.pathname).toBe("/settings/general"); - unsubscribe(); + history.dispose(); }); it("notifies subscribers only when availability changes", () => { @@ -47,6 +47,7 @@ describe("createNavigationHistory", () => { const history = createNavigationHistory(routerHistory); const snapshots: Array> = []; const unsubscribe = history.subscribe(() => snapshots.push(history.getSnapshot())); + history.start(); routerHistory.replace("/?tab=all"); routerHistory.push("/thread-a"); @@ -59,5 +60,6 @@ describe("createNavigationHistory", () => { ]); unsubscribe(); + history.dispose(); }); }); diff --git a/apps/web/src/navigationHistoryStore.ts b/apps/web/src/navigationHistoryStore.ts index c7fac7f52017..8d5f7af4d6c3 100644 --- a/apps/web/src/navigationHistoryStore.ts +++ b/apps/web/src/navigationHistoryStore.ts @@ -7,8 +7,10 @@ export interface NavigationHistorySnapshot { export interface NavigationHistory { readonly back: () => void; + readonly dispose: () => void; readonly forward: () => void; readonly getSnapshot: () => NavigationHistorySnapshot; + readonly start: () => void; readonly subscribe: (listener: () => void) => () => void; } @@ -53,22 +55,27 @@ export function createNavigationHistory(history: RouterHistory): NavigationHisto history.back(); } }, + dispose: () => { + stopTracking?.(); + stopTracking = null; + }, forward: () => { if (snapshot.canGoForward) { history.forward(); } }, getSnapshot: () => snapshot, + start: () => { + if (stopTracking) { + return; + } + maximumIndex = Math.max(maximumIndex, history.location.state.__TSR_index); + snapshot = snapshotFor(history, maximumIndex); + stopTracking = history.subscribe(update); + }, subscribe: (listener) => { listeners.add(listener); - stopTracking ??= history.subscribe(update); - return () => { - listeners.delete(listener); - if (listeners.size === 0) { - stopTracking?.(); - stopTracking = null; - } - }; + return () => listeners.delete(listener); }, }; } @@ -81,6 +88,7 @@ export function registerNavigationHistory(history: RouterHistory): NavigationHis return existing; } const navigationHistory = createNavigationHistory(history); + navigationHistory.start(); navigationHistoryByRouterHistory.set(history, navigationHistory); return navigationHistory; } From 26a28f53fa5b8887f357cef0e491a194175eebff Mon Sep 17 00:00:00 2001 From: sethwebster Date: Thu, 20 Aug 2026 19:16:04 -0400 Subject: [PATCH 04/21] fix(mobile): align navigation history with routes --- apps/mobile/src/Stack.tsx | 12 ++- .../src/components/AndroidScreenHeader.tsx | 9 +- .../MobileNavigationHistoryProvider.tsx | 84 ++++++++++++++----- .../mobile-navigation-history.test.ts | 53 ++++++++---- .../navigation/mobile-navigation-history.ts | 83 +++++++++++------- .../features/threads/ThreadRouteScreen.tsx | 44 +++++----- 6 files changed, 196 insertions(+), 89 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index e1222b8c6756..dcd857a19779 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -350,6 +350,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 @@ -391,9 +400,10 @@ function RootStackLayout(props: { const path = getPathFromState(props.state, navigationPathConfig); const pathname = path.startsWith("/") ? path : `/${path}`; const workspacePathname = workspacePathFromState(props.state); + const transitionKey = activeNavigationTransitionKey(props.state); return ( - + 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 ? ( ) { - const linkTo = useLinkTo(); - const [history] = useState(() => createMobileNavigationHistory(pathname)); + transitionKey, +}: PropsWithChildren<{ readonly pathname: string; readonly transitionKey: string }>) { + const [history] = useState(() => createMobileNavigationHistory({ pathname, transitionKey })); const snapshot = useSyncExternalStore( history.subscribe, history.getSnapshot, history.getSnapshot, ); - useEffect(() => { - history.visit(pathname); - }, [history, pathname]); - - const back = useCallback(() => { - const target = history.backTarget(); - if (target) { - linkTo(target); - } - }, [history, linkTo]); - const forward = useCallback(() => { - const target = history.forwardTarget(); - if (target) { - linkTo(target); - } - }, [history, linkTo]); + const { back, forward } = useMobileNavigationHistoryCoordinator(history, pathname, transitionKey); const value = useMemo(() => ({ ...snapshot, back, forward }), [back, forward, snapshot]); return ( @@ -58,6 +44,66 @@ export function MobileNavigationHistoryProvider({ ); } +function useMobileNavigationHistoryCoordinator( + history: ReturnType, + pathname: string, + transitionKey: string, +) { + const linkTo = useLinkTo(); + const pendingTraversalPathRef = useRef(null); + const pendingTraversalTimeoutRef = useRef | null>(null); + + useEffect(() => { + const traversal = pendingTraversalPathRef.current === pathname; + if (traversal) { + pendingTraversalPathRef.current = null; + if (pendingTraversalTimeoutRef.current !== null) { + clearTimeout(pendingTraversalTimeoutRef.current); + pendingTraversalTimeoutRef.current = null; + } + } + history.visit({ pathname, transitionKey }, { traversal }); + }, [history, pathname, transitionKey]); + + useEffect( + () => () => { + if (pendingTraversalTimeoutRef.current !== null) { + clearTimeout(pendingTraversalTimeoutRef.current); + } + }, + [], + ); + + const requestTraversal = useCallback( + (target: string | null) => { + if (!target) { + return; + } + pendingTraversalPathRef.current = target; + if (pendingTraversalTimeoutRef.current !== null) { + clearTimeout(pendingTraversalTimeoutRef.current); + } + pendingTraversalTimeoutRef.current = setTimeout(() => { + if (pendingTraversalPathRef.current === target) { + pendingTraversalPathRef.current = null; + } + pendingTraversalTimeoutRef.current = null; + }, 1_000); + linkTo(target); + }, + [linkTo], + ); + + const back = useCallback(() => { + requestTraversal(history.backTarget()); + }, [history, requestTraversal]); + const forward = useCallback(() => { + requestTraversal(history.forwardTarget()); + }, [history, requestTraversal]); + + return { back, forward }; +} + export function useMobileNavigationHistory(): MobileNavigationHistoryValue { const value = useContext(MobileNavigationHistoryContext); if (!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 index 38724f3b7424..82a48e7a2b72 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts @@ -3,53 +3,70 @@ import { describe, expect, it } from "@effect/vitest"; import { createMobileNavigationHistory } 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("/"); - history.visit("/threads/env/thread-a"); - history.visit("/threads/env/thread-b"); + const history = createMobileNavigationHistory(location("/")); + history.visit(location("/threads/env/thread-a", "thread-a")); + history.visit(location("/threads/env/thread-b", "thread-b")); const backTarget = history.backTarget(); expect(backTarget).toBe("/threads/env/thread-a"); - history.visit(backTarget!); + history.visit(location(backTarget!, "thread-a"), { traversal: true }); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); const forwardTarget = history.forwardTarget(); expect(forwardTarget).toBe("/threads/env/thread-b"); - history.visit(forwardTarget!); + history.visit(location(forwardTarget!, "thread-b"), { traversal: true }); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); }); it("drops forward paths after a new visit", () => { - const history = createMobileNavigationHistory("/"); - history.visit("/threads/env/thread-a"); - history.visit("/threads/env/thread-b"); - history.visit(history.backTarget()!); + 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(history.backTarget()!, "thread-a"), { traversal: true }); - history.visit("/settings"); + history.visit(location("/settings")); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); expect(history.forwardTarget()).toBeNull(); }); it("recognizes native back navigation", () => { - const history = createMobileNavigationHistory("/"); - history.visit("/threads/env/thread-a"); - history.visit("/settings"); + const history = createMobileNavigationHistory(location("/")); + history.visit(location("/threads/env/thread-a", "thread-a")); + history.visit(location("/settings")); - history.visit("/threads/env/thread-a"); + history.visit(location("/threads/env/thread-a", "thread-a")); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); expect(history.forwardTarget()).toBe("/settings"); }); it("reconciles non-adjacent native back navigation without adding a duplicate", () => { - const history = createMobileNavigationHistory("/"); - history.visit("/threads/env/thread-a"); - history.visit("/threads/env/thread-b"); + 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.visit(location("/")); expect(history.getSnapshot()).toEqual({ canGoBack: false, canGoForward: true }); expect(history.forwardTarget()).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-a", "thread")); + history.visit(location("/threads/env/thread-b", "thread")); + history.visit(location("/threads/env/thread-c", "thread")); + + history.visit(location("/threads/env/thread-a", "thread")); + + expect(history.backTarget()).toBe("/threads/env/thread-c"); + expect(history.forwardTarget()).toBeNull(); + }); }); diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.ts index f299648f68b8..0cb90748b4ad 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.ts @@ -8,7 +8,15 @@ export interface MobileNavigationHistory { readonly forwardTarget: () => string | null; readonly getSnapshot: () => MobileNavigationHistorySnapshot; readonly subscribe: (listener: () => void) => () => void; - readonly visit: (pathname: string) => void; + readonly visit: ( + location: MobileNavigationLocation, + options?: { readonly traversal?: boolean }, + ) => void; +} + +export interface MobileNavigationLocation { + readonly pathname: string; + readonly transitionKey: string; } function snapshotFor(cursor: number, entryCount: number): MobileNavigationHistorySnapshot { @@ -18,8 +26,10 @@ function snapshotFor(cursor: number, entryCount: number): MobileNavigationHistor }; } -export function createMobileNavigationHistory(initialPathname: string): MobileNavigationHistory { - let entries = [initialPathname]; +export function createMobileNavigationHistory( + initialLocation: MobileNavigationLocation, +): MobileNavigationHistory { + let entries = [initialLocation]; let cursor = 0; let snapshot = snapshotFor(cursor, entries.length); const listeners = new Set<() => void>(); @@ -37,40 +47,55 @@ export function createMobileNavigationHistory(initialPathname: string): MobileNa }; return { - backTarget: () => entries[cursor - 1] ?? null, - forwardTarget: () => entries[cursor + 1] ?? null, + backTarget: () => entries[cursor - 1]?.pathname ?? null, + forwardTarget: () => entries[cursor + 1]?.pathname ?? null, getSnapshot: () => snapshot, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, - visit: (pathname) => { - if (pathname === entries[cursor]) { - return; - } - if (pathname === entries[cursor - 1]) { - cursor -= 1; - publish(); - return; - } - if (pathname === entries[cursor + 1]) { - cursor += 1; - publish(); + visit: (location, options) => { + const current = entries[cursor]; + if ( + location.pathname === current?.pathname && + location.transitionKey === current.transitionKey + ) { return; } - const priorIndex = entries.lastIndexOf(pathname, cursor - 1); - if (priorIndex >= 0) { - cursor = priorIndex; - publish(); - return; - } - const forwardIndex = entries.indexOf(pathname, cursor + 1); - if (forwardIndex >= 0) { - cursor = forwardIndex; - publish(); - return; + + if (options?.traversal) { + const adjacentIndex = + entries[cursor - 1]?.pathname === location.pathname + ? cursor - 1 + : entries[cursor + 1]?.pathname === location.pathname + ? cursor + 1 + : -1; + if (adjacentIndex >= 0) { + entries = entries.map((entry, index) => (index === adjacentIndex ? location : entry)); + cursor = adjacentIndex; + publish(); + return; + } + } else 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), pathname]; + + entries = [...entries.slice(0, cursor + 1), location]; cursor = entries.length - 1; publish(); }, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index fcaa264c5e05..f69553fa04f3 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -640,19 +640,16 @@ function ThreadRouteContent( }; const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps); const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps); - const compactRightHeaderItemsWithHistory = useMemo( - () => [ - ...createNativeNavigationHistoryItems({ + const compactNavigationHeaderItems = useMemo( + () => + createNativeNavigationHistoryItems({ canGoBack: navigationHistory.canGoBack, canGoForward: navigationHistory.canGoForward, identifierPrefix: "thread-navigation", - includeBack: false, onBack: navigationHistory.back, onForward: navigationHistory.forward, }), - ...compactRightHeaderItems, - ], - [compactRightHeaderItems, navigationHistory], + [navigationHistory], ); const splitLeftHeaderItems = useMemo( () => [ @@ -705,6 +702,13 @@ function ThreadRouteContent( 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", @@ -746,6 +750,7 @@ function ThreadRouteContent( handleOpenGitInspector, handleToggleInspector, navigationHistory, + navigation, props.onReturnToThread, selectedThreadCwd, selectedThreadProject?.workspaceRoot, @@ -754,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({ @@ -852,27 +856,26 @@ function ThreadRouteContent( } : undefined, title: selectedThread.title, - headerBackVisible: !layout.usesSplitView, - // Compact uses the NATIVE back button when a previous route exists; - // deep links / cold starts get an explicit Home button instead. - // Split view always uses its custom left items. + headerBackVisible: false, + // Compact uses the app history pair so Back and Forward share one + // cursor. Deep links also get an explicit Home escape. Split view + // keeps its workspace-specific left items because the sidebar owns + // the history pair there. unstable_headerLeftItems: Platform.OS === "ios" ? layout.usesSplitView ? () => 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 // reserved for future breadcrumbs/status). unstable_headerRightItems: Platform.OS === "ios" - ? () => - layout.usesSplitView - ? threadCenterHeaderItems - : compactRightHeaderItemsWithHistory + ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) : undefined, unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, }} @@ -880,9 +883,10 @@ function ThreadRouteContent( {Platform.OS === "android" ? ( navigation.goBack()} + onBack={layout.usesSplitView ? undefined : navigationHistory.back} actions={androidHeaderActions} /> ) : null} From adb0eeafefc926a72d3706f5a07c40288247f534 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Thu, 20 Aug 2026 19:21:41 -0400 Subject: [PATCH 05/21] fix(mobile): confirm exact navigation targets --- apps/mobile/src/Stack.tsx | 8 ++- .../MobileNavigationHistoryProvider.tsx | 55 ++++--------------- .../mobile-navigation-history.test.ts | 41 ++++++++++---- .../navigation/mobile-navigation-history.ts | 52 +++++++++++------- 4 files changed, 79 insertions(+), 77 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index dcd857a19779..bfb56d29a52b 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"; @@ -401,9 +401,13 @@ function RootStackLayout(props: { const pathname = path.startsWith("/") ? path : `/${path}`; const workspacePathname = workspacePathFromState(props.state); const transitionKey = activeNavigationTransitionKey(props.state); + const navigationLocation = useMemo( + () => ({ pathname, transitionKey }), + [pathname, transitionKey], + ); return ( - + diff --git a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx index daf8cd1bb2ce..6050e5dbd24d 100644 --- a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx +++ b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx @@ -5,7 +5,6 @@ import { useContext, useEffect, useMemo, - useRef, useState, useSyncExternalStore, type PropsWithChildren, @@ -13,6 +12,7 @@ import { import { createMobileNavigationHistory, + type MobileNavigationLocation, type MobileNavigationHistorySnapshot, } from "./mobile-navigation-history"; @@ -25,16 +25,15 @@ const MobileNavigationHistoryContext = createContext) { - const [history] = useState(() => createMobileNavigationHistory({ pathname, transitionKey })); + location, +}: PropsWithChildren<{ readonly location: MobileNavigationLocation }>) { + const [history] = useState(() => createMobileNavigationHistory(location)); const snapshot = useSyncExternalStore( history.subscribe, history.getSnapshot, history.getSnapshot, ); - const { back, forward } = useMobileNavigationHistoryCoordinator(history, pathname, transitionKey); + const { back, forward } = useMobileNavigationHistoryCoordinator(history, location); const value = useMemo(() => ({ ...snapshot, back, forward }), [back, forward, snapshot]); return ( @@ -46,59 +45,29 @@ export function MobileNavigationHistoryProvider({ function useMobileNavigationHistoryCoordinator( history: ReturnType, - pathname: string, - transitionKey: string, + location: MobileNavigationLocation, ) { const linkTo = useLinkTo(); - const pendingTraversalPathRef = useRef(null); - const pendingTraversalTimeoutRef = useRef | null>(null); useEffect(() => { - const traversal = pendingTraversalPathRef.current === pathname; - if (traversal) { - pendingTraversalPathRef.current = null; - if (pendingTraversalTimeoutRef.current !== null) { - clearTimeout(pendingTraversalTimeoutRef.current); - pendingTraversalTimeoutRef.current = null; - } - } - history.visit({ pathname, transitionKey }, { traversal }); - }, [history, pathname, transitionKey]); - - useEffect( - () => () => { - if (pendingTraversalTimeoutRef.current !== null) { - clearTimeout(pendingTraversalTimeoutRef.current); - } - }, - [], - ); + history.visit(location); + }, [history, location]); const requestTraversal = useCallback( - (target: string | null) => { + (target: ReturnType) => { if (!target) { return; } - pendingTraversalPathRef.current = target; - if (pendingTraversalTimeoutRef.current !== null) { - clearTimeout(pendingTraversalTimeoutRef.current); - } - pendingTraversalTimeoutRef.current = setTimeout(() => { - if (pendingTraversalPathRef.current === target) { - pendingTraversalPathRef.current = null; - } - pendingTraversalTimeoutRef.current = null; - }, 1_000); - linkTo(target); + linkTo(target.location.pathname); }, [linkTo], ); const back = useCallback(() => { - requestTraversal(history.backTarget()); + requestTraversal(history.requestBack()); }, [history, requestTraversal]); const forward = useCallback(() => { - requestTraversal(history.forwardTarget()); + requestTraversal(history.requestForward()); }, [history, requestTraversal]); return { back, forward }; diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts index 82a48e7a2b72..88cc2a277c00 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts @@ -13,14 +13,14 @@ describe("createMobileNavigationHistory", () => { history.visit(location("/threads/env/thread-a", "thread-a")); history.visit(location("/threads/env/thread-b", "thread-b")); - const backTarget = history.backTarget(); - expect(backTarget).toBe("/threads/env/thread-a"); - history.visit(location(backTarget!, "thread-a"), { traversal: true }); + const backTarget = history.requestBack(); + expect(backTarget?.location.pathname).toBe("/threads/env/thread-a"); + history.visit(location(backTarget!.location.pathname, "thread-a")); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); - const forwardTarget = history.forwardTarget(); - expect(forwardTarget).toBe("/threads/env/thread-b"); - history.visit(location(forwardTarget!, "thread-b"), { traversal: true }); + const forwardTarget = history.requestForward(); + expect(forwardTarget?.location.pathname).toBe("/threads/env/thread-b"); + history.visit(location(forwardTarget!.location.pathname, "thread-b")); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); }); @@ -28,12 +28,12 @@ describe("createMobileNavigationHistory", () => { 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(history.backTarget()!, "thread-a"), { traversal: true }); + history.visit(history.requestBack()!.location); history.visit(location("/settings")); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); - expect(history.forwardTarget()).toBeNull(); + expect(history.requestForward()).toBeNull(); }); it("recognizes native back navigation", () => { @@ -44,7 +44,7 @@ describe("createMobileNavigationHistory", () => { history.visit(location("/threads/env/thread-a", "thread-a")); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); - expect(history.forwardTarget()).toBe("/settings"); + expect(history.requestForward()?.location.pathname).toBe("/settings"); }); it("reconciles non-adjacent native back navigation without adding a duplicate", () => { @@ -55,7 +55,7 @@ describe("createMobileNavigationHistory", () => { history.visit(location("/")); expect(history.getSnapshot()).toEqual({ canGoBack: false, canGoForward: true }); - expect(history.forwardTarget()).toBe("/threads/env/thread-a"); + expect(history.requestForward()?.location.pathname).toBe("/threads/env/thread-a"); }); it("records a new visit when an old pathname is selected again", () => { @@ -66,7 +66,24 @@ describe("createMobileNavigationHistory", () => { history.visit(location("/threads/env/thread-a", "thread")); - expect(history.backTarget()).toBe("/threads/env/thread-c"); - expect(history.forwardTarget()).toBeNull(); + expect(history.requestBack()?.location.pathname).toBe("/threads/env/thread-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 }); }); }); diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.ts index 0cb90748b4ad..158b1a89dc22 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.ts @@ -4,14 +4,11 @@ export interface MobileNavigationHistorySnapshot { } export interface MobileNavigationHistory { - readonly backTarget: () => string | null; - readonly forwardTarget: () => string | null; readonly getSnapshot: () => MobileNavigationHistorySnapshot; + readonly requestBack: () => MobileNavigationTarget | null; + readonly requestForward: () => MobileNavigationTarget | null; readonly subscribe: (listener: () => void) => () => void; - readonly visit: ( - location: MobileNavigationLocation, - options?: { readonly traversal?: boolean }, - ) => void; + readonly visit: (location: MobileNavigationLocation) => void; } export interface MobileNavigationLocation { @@ -19,6 +16,12 @@ export interface MobileNavigationLocation { readonly transitionKey: string; } +export interface MobileNavigationTarget { + readonly direction: "back" | "forward"; + readonly index: number; + readonly location: MobileNavigationLocation; +} + function snapshotFor(cursor: number, entryCount: number): MobileNavigationHistorySnapshot { return { canGoBack: cursor > 0, @@ -32,6 +35,7 @@ export function createMobileNavigationHistory( let entries = [initialLocation]; let cursor = 0; let snapshot = snapshotFor(cursor, entries.length); + let pendingTarget: MobileNavigationTarget | null = null; const listeners = new Set<() => void>(); const publish = () => { @@ -47,14 +51,24 @@ export function createMobileNavigationHistory( }; return { - backTarget: () => entries[cursor - 1]?.pathname ?? null, - forwardTarget: () => entries[cursor + 1]?.pathname ?? null, getSnapshot: () => snapshot, + requestBack: () => { + const index = cursor - 1; + const location = entries[index]; + pendingTarget = location ? { direction: "back", index, location } : null; + return pendingTarget; + }, + requestForward: () => { + const index = cursor + 1; + const location = entries[index]; + pendingTarget = location ? { direction: "forward", index, location } : null; + return pendingTarget; + }, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, - visit: (location, options) => { + visit: (location) => { const current = entries[cursor]; if ( location.pathname === current?.pathname && @@ -63,20 +77,18 @@ export function createMobileNavigationHistory( return; } - if (options?.traversal) { - const adjacentIndex = - entries[cursor - 1]?.pathname === location.pathname - ? cursor - 1 - : entries[cursor + 1]?.pathname === location.pathname - ? cursor + 1 - : -1; - if (adjacentIndex >= 0) { - entries = entries.map((entry, index) => (index === adjacentIndex ? location : entry)); - cursor = adjacentIndex; + if (pendingTarget) { + const target = pendingTarget; + pendingTarget = null; + if (target.location.pathname === location.pathname) { + entries = entries.map((entry, index) => (index === target.index ? location : entry)); + cursor = target.index; publish(); return; } - } else if (location.transitionKey !== current?.transitionKey) { + } + + if (location.transitionKey !== current?.transitionKey) { const priorIndex = entries.findLastIndex( (entry, index) => index < cursor && entry.transitionKey === location.transitionKey, ); From bbbf00e7e1398b83e9448cd0335d30eac57c86c9 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Thu, 20 Aug 2026 19:27:08 -0400 Subject: [PATCH 06/21] fix(mobile): cancel blocked history traversal --- .../MobileNavigationHistoryProvider.tsx | 25 ++++++++++++++++++- .../mobile-navigation-history.test.ts | 13 ++++++++++ .../navigation/mobile-navigation-history.ts | 4 +++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx index 6050e5dbd24d..61f5cadd75c9 100644 --- a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx +++ b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx @@ -1,4 +1,4 @@ -import { useLinkTo } from "@react-navigation/native"; +import { useLinkTo, useNavigation } from "@react-navigation/native"; import { createContext, useCallback, @@ -48,6 +48,7 @@ function useMobileNavigationHistoryCoordinator( location: MobileNavigationLocation, ) { const linkTo = useLinkTo(); + useCancelBlockedTraversal(history); useEffect(() => { history.visit(location); @@ -73,6 +74,28 @@ function useMobileNavigationHistoryCoordinator( return { back, forward }; } +function useCancelBlockedTraversal( + history: ReturnType, +): void { + 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.cancelPending(); + } + }); + }, [history, navigation]); +} + export function useMobileNavigationHistory(): MobileNavigationHistoryValue { const value = useContext(MobileNavigationHistoryContext); if (!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 index 88cc2a277c00..e639fc909b33 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts @@ -86,4 +86,17 @@ describe("createMobileNavigationHistory", () => { 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.cancelPending(); + 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 index 158b1a89dc22..81a36d1dc237 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.ts @@ -4,6 +4,7 @@ export interface MobileNavigationHistorySnapshot { } export interface MobileNavigationHistory { + readonly cancelPending: () => void; readonly getSnapshot: () => MobileNavigationHistorySnapshot; readonly requestBack: () => MobileNavigationTarget | null; readonly requestForward: () => MobileNavigationTarget | null; @@ -51,6 +52,9 @@ export function createMobileNavigationHistory( }; return { + cancelPending: () => { + pendingTarget = null; + }, getSnapshot: () => snapshot, requestBack: () => { const index = cursor - 1; From 2769cbf3d0b06a2f0d2c4be5700f7bac272691d0 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Thu, 20 Aug 2026 19:31:38 -0400 Subject: [PATCH 07/21] refactor(navigation): tighten history implementation --- .../MobileNavigationHistoryProvider.tsx | 2 +- .../mobile-navigation-history.test.ts | 2 +- .../navigation/mobile-navigation-history.ts | 4 ++-- .../sidebar-native-header-items.test.ts | 22 ------------------- apps/web/src/navigationHistory.test.ts | 11 ---------- packages/contracts/src/keybindings.test.ts | 15 ++++--------- 6 files changed, 8 insertions(+), 48 deletions(-) delete mode 100644 apps/mobile/src/features/threads/sidebar-native-header-items.test.ts diff --git a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx index 61f5cadd75c9..7622fef94ca2 100644 --- a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx +++ b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx @@ -90,7 +90,7 @@ function useCancelBlockedTraversal( }; return actionEvents.addListener("__unsafe_action__", (event) => { if (event.data.noop) { - history.cancelPending(); + history.cancelPendingTraversal(); } }); }, [history, navigation]); diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts index e639fc909b33..4ec5e87026a8 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts @@ -93,7 +93,7 @@ describe("createMobileNavigationHistory", () => { history.visit(location("/threads/env/thread-c", "thread")); history.requestBack(); - history.cancelPending(); + history.cancelPendingTraversal(); history.visit(location("/threads/env/thread-b", "thread")); expect(history.requestBack()?.location.pathname).toBe("/threads/env/thread-c"); diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.ts index 81a36d1dc237..16770e632554 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.ts @@ -4,7 +4,7 @@ export interface MobileNavigationHistorySnapshot { } export interface MobileNavigationHistory { - readonly cancelPending: () => void; + readonly cancelPendingTraversal: () => void; readonly getSnapshot: () => MobileNavigationHistorySnapshot; readonly requestBack: () => MobileNavigationTarget | null; readonly requestForward: () => MobileNavigationTarget | null; @@ -52,7 +52,7 @@ export function createMobileNavigationHistory( }; return { - cancelPending: () => { + cancelPendingTraversal: () => { pendingTarget = null; }, getSnapshot: () => snapshot, diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.test.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.test.ts deleted file mode 100644 index 617b91c9df3e..000000000000 --- a/apps/mobile/src/features/threads/sidebar-native-header-items.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it, vi } from "@effect/vitest"; - -import { createSidebarHeaderItems } from "./sidebar-native-header-items"; - -describe("createSidebarHeaderItems", () => { - it("adds independently disabled Back and Forward controls", () => { - const items = createSidebarHeaderItems({ - canGoBack: false, - canGoForward: true, - filterIcon: "line.3.horizontal.decrease", - filterMenu: { title: "Thread list options", items: [] }, - onBack: vi.fn(), - onForward: vi.fn(), - onOpenSettings: vi.fn(), - }); - - expect(items.slice(0, 2)).toEqual([ - expect.objectContaining({ accessibilityLabel: "Back", disabled: true }), - expect.objectContaining({ accessibilityLabel: "Forward", disabled: false }), - ]); - }); -}); diff --git a/apps/web/src/navigationHistory.test.ts b/apps/web/src/navigationHistory.test.ts index 4f499682e65b..fb8a27f8c7da 100644 --- a/apps/web/src/navigationHistory.test.ts +++ b/apps/web/src/navigationHistory.test.ts @@ -22,19 +22,8 @@ describe("createNavigationHistory", () => { history.forward(); expect(routerHistory.location.pathname).toBe("/thread-b"); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); - history.dispose(); - }); - it("drops the forward path after navigating somewhere new", () => { - const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); - const history = createNavigationHistory(routerHistory); - history.start(); - - routerHistory.push("/thread-a"); - routerHistory.push("/thread-b"); history.back(); - expect(history.getSnapshot().canGoForward).toBe(true); - routerHistory.push("/settings/general"); expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); history.forward(); diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 519f2e264208..47ecf7b9f22a 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -36,17 +36,10 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle"); - const parsedNavigationBack = yield* decode(KeybindingRule, { - key: "mod+[", - command: "navigation.back", - }); - assert.strictEqual(parsedNavigationBack.command, "navigation.back"); - - const parsedNavigationForward = yield* decode(KeybindingRule, { - key: "mod+]", - command: "navigation.forward", - }); - assert.strictEqual(parsedNavigationForward.command, "navigation.forward"); + for (const command of ["navigation.back", "navigation.forward"] as const) { + const parsedNavigation = yield* decode(KeybindingRule, { key: "mod+bracket", command }); + assert.strictEqual(parsedNavigation.command, command); + } const parsedRightPanelToggle = yield* decode(KeybindingRule, { key: "mod+alt+b", From 9df1b7e55336008a2a0098fcd792b7037b3b0c7b Mon Sep 17 00:00:00 2001 From: sethwebster Date: Thu, 20 Aug 2026 23:55:16 -0400 Subject: [PATCH 08/21] fix(clients): sync live navigation history state --- apps/mobile/src/features/home/HomeHeader.tsx | 6 ++- .../features/threads/ThreadRouteScreen.tsx | 4 ++ apps/web/src/navigationHistory.test.ts | 20 +++++++++- apps/web/src/navigationHistoryStore.ts | 40 +++++++++++-------- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 919911a7eb8d..7ea25c04da99 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -338,7 +338,11 @@ function IosHomeHeader(props: HomeHeaderProps) { return ( <> {activeInspectorRenderer ? : null} + history.subscribe(({ action, location }) => + listener({ + action, + location: { ...location, state: {} as RouterHistory["location"]["state"] }, + }), + ), + }; +} + describe("createNavigationHistory", () => { it("tracks back and forward availability through navigation", () => { const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); - const history = createNavigationHistory(routerHistory); + const history = createNavigationHistory(withoutRouterLocationState(routerHistory)); history.start(); expect(history.getSnapshot()).toEqual({ canGoBack: false, canGoForward: false }); diff --git a/apps/web/src/navigationHistoryStore.ts b/apps/web/src/navigationHistoryStore.ts index 8d5f7af4d6c3..3547c6fbd692 100644 --- a/apps/web/src/navigationHistoryStore.ts +++ b/apps/web/src/navigationHistoryStore.ts @@ -14,30 +14,40 @@ export interface NavigationHistory { readonly subscribe: (listener: () => void) => () => void; } -function snapshotFor(history: RouterHistory, maximumIndex: number): NavigationHistorySnapshot { +function snapshotFor(currentPosition: number, maximumPosition: number): NavigationHistorySnapshot { return { - canGoBack: history.canGoBack(), - canGoForward: history.location.state.__TSR_index < maximumIndex, + canGoBack: currentPosition > 0, + canGoForward: currentPosition < maximumPosition, }; } export function createNavigationHistory(history: RouterHistory): NavigationHistory { - let maximumIndex = history.location.state.__TSR_index; - let snapshot = snapshotFor(history, maximumIndex); + let currentPosition = 0; + let maximumPosition = 0; + let snapshot = snapshotFor(currentPosition, maximumPosition); let stopTracking: (() => void) | null = null; const listeners = new Set<() => void>(); - const update = ({ - action, - location, - }: Parameters[0]>[0]) => { - if (action.type === "PUSH") { - maximumIndex = location.state.__TSR_index; - } else { - maximumIndex = Math.max(maximumIndex, location.state.__TSR_index); + const update = ({ action }: Parameters[0]>[0]) => { + switch (action.type) { + case "PUSH": + currentPosition += 1; + maximumPosition = currentPosition; + break; + case "BACK": + currentPosition = Math.max(0, currentPosition - 1); + break; + case "FORWARD": + currentPosition = Math.min(maximumPosition, currentPosition + 1); + break; + case "GO": + currentPosition = Math.max(0, Math.min(maximumPosition, currentPosition + action.index)); + break; + case "REPLACE": + break; } - const nextSnapshot = snapshotFor(history, maximumIndex); + const nextSnapshot = snapshotFor(currentPosition, maximumPosition); if ( nextSnapshot.canGoBack === snapshot.canGoBack && nextSnapshot.canGoForward === snapshot.canGoForward @@ -69,8 +79,6 @@ export function createNavigationHistory(history: RouterHistory): NavigationHisto if (stopTracking) { return; } - maximumIndex = Math.max(maximumIndex, history.location.state.__TSR_index); - snapshot = snapshotFor(history, maximumIndex); stopTracking = history.subscribe(update); }, subscribe: (listener) => { From 01ade4e58664ad06991e962aca3e21fd27243b0d Mon Sep 17 00:00:00 2001 From: sethwebster Date: Fri, 21 Aug 2026 00:14:04 -0400 Subject: [PATCH 09/21] fix(mobile): traverse modal navigation history --- apps/mobile/src/Stack.tsx | 5 ++- .../HardwareKeyboardCommandProvider.tsx | 10 ++---- .../MobileNavigationHistoryProvider.tsx | 10 +++--- .../mobile-navigation-history.test.ts | 2 +- .../navigation/mobile-navigation-history.ts | 34 ++++--------------- .../native-navigation-history-items.ts | 23 +++++-------- .../features/settings/SettingsRouteScreen.tsx | 6 +++- apps/web/src/navigationHistory.test.ts | 22 +++--------- apps/web/src/navigationHistoryStore.ts | 15 +++----- docs/user/keybindings.md | 8 ++--- 10 files changed, 47 insertions(+), 88 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index bfb56d29a52b..6ce57282b0fa 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -23,6 +23,7 @@ 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 { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; import { ReviewSheet } from "./features/review/ReviewSheet"; @@ -88,6 +89,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, @@ -103,6 +105,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, @@ -397,7 +400,7 @@ 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 = getPathFromState(props.state, navigationPathConfig).split("?")[0] ?? "/"; const pathname = path.startsWith("/") ? path : `/${path}`; const workspacePathname = workspacePathFromState(props.state); const transitionKey = activeNavigationTransitionKey(props.state); diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index aac36b25e98b..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"; @@ -26,7 +26,7 @@ export function HardwareKeyboardCommandProvider({ const enabledCommands = useMemo(() => { const commands = new Set(getRegisteredHardwareKeyboardCommands()); commands.add("newTask"); - if (pathname !== "/" || navigationHistory.canGoBack) commands.add("back"); + if (navigationHistory.canGoBack) commands.add("back"); if (navigationHistory.canGoForward) commands.add("forward"); if (parseActiveThreadPath(pathname)) { commands.add("files"); @@ -45,11 +45,7 @@ export function HardwareKeyboardCommandProvider({ return; } if (command === "back") { - if (navigationHistory.canGoBack) { - navigationHistory.back(); - } else { - navigation.dispatch(StackActions.replace("Home")); - } + navigationHistory.back(); return; } if (command === "forward") { diff --git a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx index 7622fef94ca2..4bbbae4a5932 100644 --- a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx +++ b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx @@ -1,4 +1,4 @@ -import { useLinkTo, useNavigation } from "@react-navigation/native"; +import { useLinkBuilder, useNavigation } from "@react-navigation/native"; import { createContext, useCallback, @@ -47,7 +47,8 @@ function useMobileNavigationHistoryCoordinator( history: ReturnType, location: MobileNavigationLocation, ) { - const linkTo = useLinkTo(); + const navigation = useNavigation(); + const { buildAction } = useLinkBuilder(); useCancelBlockedTraversal(history); useEffect(() => { @@ -59,9 +60,10 @@ function useMobileNavigationHistoryCoordinator( if (!target) { return; } - linkTo(target.location.pathname); + const action = buildAction(target.location.pathname); + navigation.dispatch({ ...action, type: "REPLACE" }); }, - [linkTo], + [buildAction, history, navigation], ); const back = useCallback(() => { diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts index 4ec5e87026a8..6c3ec7c3b96a 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts @@ -12,6 +12,7 @@ describe("createMobileNavigationHistory", () => { 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"); @@ -78,7 +79,6 @@ describe("createMobileNavigationHistory", () => { const forward = history.requestForward(); expect(forward).toEqual({ - direction: "forward", index: 2, location: location("/threads/env/thread-a", "a-2"), }); diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.ts index 16770e632554..06280799e742 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.ts @@ -3,26 +3,11 @@ export interface MobileNavigationHistorySnapshot { readonly canGoForward: boolean; } -export interface MobileNavigationHistory { - readonly cancelPendingTraversal: () => void; - readonly getSnapshot: () => MobileNavigationHistorySnapshot; - readonly requestBack: () => MobileNavigationTarget | null; - readonly requestForward: () => MobileNavigationTarget | null; - readonly subscribe: (listener: () => void) => () => void; - readonly visit: (location: MobileNavigationLocation) => void; -} - export interface MobileNavigationLocation { readonly pathname: string; readonly transitionKey: string; } -export interface MobileNavigationTarget { - readonly direction: "back" | "forward"; - readonly index: number; - readonly location: MobileNavigationLocation; -} - function snapshotFor(cursor: number, entryCount: number): MobileNavigationHistorySnapshot { return { canGoBack: cursor > 0, @@ -30,13 +15,11 @@ function snapshotFor(cursor: number, entryCount: number): MobileNavigationHistor }; } -export function createMobileNavigationHistory( - initialLocation: MobileNavigationLocation, -): MobileNavigationHistory { +export function createMobileNavigationHistory(initialLocation: MobileNavigationLocation) { let entries = [initialLocation]; let cursor = 0; let snapshot = snapshotFor(cursor, entries.length); - let pendingTarget: MobileNavigationTarget | null = null; + let pendingTarget: { index: number; location: MobileNavigationLocation } | null = null; const listeners = new Set<() => void>(); const publish = () => { @@ -59,25 +42,22 @@ export function createMobileNavigationHistory( requestBack: () => { const index = cursor - 1; const location = entries[index]; - pendingTarget = location ? { direction: "back", index, location } : null; + pendingTarget = location ? { index, location } : null; return pendingTarget; }, requestForward: () => { const index = cursor + 1; const location = entries[index]; - pendingTarget = location ? { direction: "forward", index, location } : null; + pendingTarget = location ? { index, location } : null; return pendingTarget; }, - subscribe: (listener) => { + subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }, - visit: (location) => { + visit: (location: MobileNavigationLocation) => { const current = entries[cursor]; - if ( - location.pathname === current?.pathname && - location.transitionKey === current.transitionKey - ) { + if (location.pathname === current?.pathname) { return; } diff --git a/apps/mobile/src/features/navigation/native-navigation-history-items.ts b/apps/mobile/src/features/navigation/native-navigation-history-items.ts index 7a6c197740de..d5fa2b0ee3cd 100644 --- a/apps/mobile/src/features/navigation/native-navigation-history-items.ts +++ b/apps/mobile/src/features/navigation/native-navigation-history-items.ts @@ -12,24 +12,19 @@ export function createNativeNavigationHistoryItems(input: { readonly canGoBack: boolean; readonly canGoForward: boolean; readonly identifierPrefix: string; - readonly includeBack?: boolean; readonly onBack: () => void; readonly onForward: () => void; }): NativeStackHeaderItem[] { return [ - ...(input.includeBack === false - ? [] - : [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Back", - disabled: !input.canGoBack, - icon: navigationIcon("chevron.left"), - identifier: `${input.identifierPrefix}-back`, - label: "", - onPress: input.onBack, - type: "button" as const, - }), - ]), + 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, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index b0e851b59d88..6f4fe7ba93ad 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,10 @@ export function SettingsRouteScreen() { <> {/* Android renders its own in-screen header instead of the native bar. */} - navigation.goBack()} /> + } + /> ) : ( { it("tracks back and forward availability through navigation", () => { const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); const history = createNavigationHistory(withoutRouterLocationState(routerHistory)); + const snapshots: Array> = []; + history.subscribe(() => snapshots.push(history.getSnapshot())); history.start(); expect(history.getSnapshot()).toEqual({ canGoBack: false, canGoForward: false }); @@ -44,27 +46,13 @@ describe("createNavigationHistory", () => { expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: false }); history.forward(); expect(routerHistory.location.pathname).toBe("/settings/general"); - history.dispose(); - }); - - it("notifies subscribers only when availability changes", () => { - const routerHistory = createMemoryHistory({ initialEntries: ["/"] }); - const history = createNavigationHistory(routerHistory); - const snapshots: Array> = []; - const unsubscribe = history.subscribe(() => snapshots.push(history.getSnapshot())); - history.start(); - - routerHistory.replace("/?tab=all"); - routerHistory.push("/thread-a"); - routerHistory.push("/thread-b"); - history.back(); - expect(snapshots).toEqual([ { canGoBack: true, canGoForward: false }, { canGoBack: true, canGoForward: true }, + { canGoBack: true, canGoForward: false }, + { canGoBack: true, canGoForward: true }, + { canGoBack: true, canGoForward: false }, ]); - - unsubscribe(); history.dispose(); }); }); diff --git a/apps/web/src/navigationHistoryStore.ts b/apps/web/src/navigationHistoryStore.ts index 3547c6fbd692..0b6c8046dd3b 100644 --- a/apps/web/src/navigationHistoryStore.ts +++ b/apps/web/src/navigationHistoryStore.ts @@ -5,15 +5,6 @@ export interface NavigationHistorySnapshot { readonly canGoForward: boolean; } -export interface NavigationHistory { - readonly back: () => void; - readonly dispose: () => void; - readonly forward: () => void; - readonly getSnapshot: () => NavigationHistorySnapshot; - readonly start: () => void; - readonly subscribe: (listener: () => void) => () => void; -} - function snapshotFor(currentPosition: number, maximumPosition: number): NavigationHistorySnapshot { return { canGoBack: currentPosition > 0, @@ -21,7 +12,7 @@ function snapshotFor(currentPosition: number, maximumPosition: number): Navigati }; } -export function createNavigationHistory(history: RouterHistory): NavigationHistory { +export function createNavigationHistory(history: RouterHistory) { let currentPosition = 0; let maximumPosition = 0; let snapshot = snapshotFor(currentPosition, maximumPosition); @@ -81,13 +72,15 @@ export function createNavigationHistory(history: RouterHistory): NavigationHisto } stopTracking = history.subscribe(update); }, - subscribe: (listener) => { + subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }, }; } +export type NavigationHistory = ReturnType; + const navigationHistoryByRouterHistory = new WeakMap(); export function registerNavigationHistory(history: RouterHistory): NavigationHistory { diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 418080899410..90aee2ad4fdf 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -52,11 +52,9 @@ successful pick; its hover glow and badge preview the element and color family t `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, so add one in **Settings** → **Keybindings** if you want to use it. -`navigation.back` and `navigation.forward` move through the locations you visited in T3 Code. The -defaults are `mod+[` and `mod+]`. On web and desktop, the same actions are available from the arrow -buttons beside the sidebar toggle and from the command palette. Mobile shows the buttons in thread -navigation and supports `cmd+[` and `cmd+]` on an attached keyboard. The web and desktop defaults do -not run while a terminal or browser preview has focus. +`navigation.back` and `navigation.forward` move through visited T3 Code locations. Their defaults are +`mod+[` and `mod+]`. Web and desktop also show arrow buttons beside the sidebar toggle and commands +in the palette. Mobile shows the buttons in app headers and supports the shortcuts on a keyboard. The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while From a38db3e72103a8f9024a1e296776e328577f87bb Mon Sep 17 00:00:00 2001 From: sethwebster Date: Fri, 21 Aug 2026 00:22:02 -0400 Subject: [PATCH 10/21] fix(mobile): pop existing history routes --- apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx | 1 + .../features/navigation/MobileNavigationHistoryProvider.tsx | 3 ++- docs/user/keybindings.md | 4 +--- 3 files changed, 4 insertions(+), 4 deletions(-) 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/navigation/MobileNavigationHistoryProvider.tsx b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx index 4bbbae4a5932..86f97ecca0ae 100644 --- a/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx +++ b/apps/mobile/src/features/navigation/MobileNavigationHistoryProvider.tsx @@ -61,7 +61,8 @@ function useMobileNavigationHistoryCoordinator( return; } const action = buildAction(target.location.pathname); - navigation.dispatch({ ...action, type: "REPLACE" }); + if (!("payload" in action)) return; + navigation.dispatch({ ...action, payload: { ...action.payload, pop: true } }); }, [buildAction, history, navigation], ); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 90aee2ad4fdf..e29140f87572 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -52,9 +52,7 @@ successful pick; its hover glow and badge preview the element and color family t `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, so add one in **Settings** → **Keybindings** if you want to use it. -`navigation.back` and `navigation.forward` move through visited T3 Code locations. Their defaults are -`mod+[` and `mod+]`. Web and desktop also show arrow buttons beside the sidebar toggle and commands -in the palette. Mobile shows the buttons in app headers and supports the shortcuts on a keyboard. +`navigation.back` and `navigation.forward` move through visited locations using `mod+[` and `mod+]`. The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while From 69f582f2e35ca79c7c253f3ee8717d82fd0daf40 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Fri, 21 Aug 2026 00:25:26 -0400 Subject: [PATCH 11/21] fix(mobile): preserve navigation query state --- apps/mobile/src/Stack.tsx | 3 ++- .../navigation/mobile-navigation-history.test.ts | 10 +++++----- apps/web/src/navigationHistory.test.ts | 1 - apps/web/src/navigationHistoryStore.ts | 4 ---- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 6ce57282b0fa..7ad7bad0dc93 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -400,7 +400,8 @@ 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).split("?")[0] ?? "/"; + const rawPath = getPathFromState(props.state, navigationPathConfig); + const path = rawPath.includes("%5Bobject%20Object%5D") ? rawPath.split("?")[0]! : rawPath; const pathname = path.startsWith("/") ? path : `/${path}`; const workspacePathname = workspacePathFromState(props.state); const transitionKey = activeNavigationTransitionKey(props.state); diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts index 6c3ec7c3b96a..389882bbff5e 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts @@ -61,13 +61,13 @@ describe("createMobileNavigationHistory", () => { it("records a new visit when an old pathname is selected again", () => { const history = createMobileNavigationHistory(location("/")); - history.visit(location("/threads/env/thread-a", "thread")); - history.visit(location("/threads/env/thread-b", "thread")); - history.visit(location("/threads/env/thread-c", "thread")); + 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-a", "thread")); + history.visit(location("/threads/env/thread/terminal?terminalId=a", "thread")); - expect(history.requestBack()?.location.pathname).toBe("/threads/env/thread-c"); + expect(history.requestBack()?.location.pathname).toContain("terminalId=c"); expect(history.requestForward()).toBeNull(); }); diff --git a/apps/web/src/navigationHistory.test.ts b/apps/web/src/navigationHistory.test.ts index fd22b5ba8e9c..b2375eff2575 100644 --- a/apps/web/src/navigationHistory.test.ts +++ b/apps/web/src/navigationHistory.test.ts @@ -53,6 +53,5 @@ describe("createNavigationHistory", () => { { canGoBack: true, canGoForward: true }, { canGoBack: true, canGoForward: false }, ]); - history.dispose(); }); }); diff --git a/apps/web/src/navigationHistoryStore.ts b/apps/web/src/navigationHistoryStore.ts index 0b6c8046dd3b..d14728b956c5 100644 --- a/apps/web/src/navigationHistoryStore.ts +++ b/apps/web/src/navigationHistoryStore.ts @@ -56,10 +56,6 @@ export function createNavigationHistory(history: RouterHistory) { history.back(); } }, - dispose: () => { - stopTracking?.(); - stopTracking = null; - }, forward: () => { if (snapshot.canGoForward) { history.forward(); From 9e7a46a6d0381e156f4e65d01b05850e41f0408e Mon Sep 17 00:00:00 2001 From: sethwebster Date: Fri, 21 Aug 2026 00:27:22 -0400 Subject: [PATCH 12/21] refactor(navigation): name transient route state --- apps/mobile/src/Stack.tsx | 4 +++- apps/web/src/navigationHistoryStore.ts | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7ad7bad0dc93..08de140749a0 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -77,6 +77,8 @@ import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); +// React Navigation serializes incomplete nested params with this placeholder. +const TRANSIENT_NESTED_STATE_PARAM = "%5Bobject%20Object%5D"; type AppScreenOptions = NativeStackNavigationOptions & { readonly unstable_navigationItemStyle?: "editor"; @@ -401,7 +403,7 @@ function RootStackLayout(props: { // Full pathname (sheets included) for keyboard-command scoping; the // workspace layout only reacts to the underlying non-overlay route. const rawPath = getPathFromState(props.state, navigationPathConfig); - const path = rawPath.includes("%5Bobject%20Object%5D") ? rawPath.split("?")[0]! : rawPath; + const path = rawPath.includes(TRANSIENT_NESTED_STATE_PARAM) ? rawPath.split("?")[0]! : rawPath; const pathname = path.startsWith("/") ? path : `/${path}`; const workspacePathname = workspacePathFromState(props.state); const transitionKey = activeNavigationTransitionKey(props.state); diff --git a/apps/web/src/navigationHistoryStore.ts b/apps/web/src/navigationHistoryStore.ts index d14728b956c5..285dd5bae941 100644 --- a/apps/web/src/navigationHistoryStore.ts +++ b/apps/web/src/navigationHistoryStore.ts @@ -16,7 +16,7 @@ export function createNavigationHistory(history: RouterHistory) { let currentPosition = 0; let maximumPosition = 0; let snapshot = snapshotFor(currentPosition, maximumPosition); - let stopTracking: (() => void) | null = null; + let started = false; const listeners = new Set<() => void>(); const update = ({ action }: Parameters[0]>[0]) => { @@ -63,10 +63,11 @@ export function createNavigationHistory(history: RouterHistory) { }, getSnapshot: () => snapshot, start: () => { - if (stopTracking) { + if (started) { return; } - stopTracking = history.subscribe(update); + started = true; + history.subscribe(update); }, subscribe: (listener: () => void) => { listeners.add(listener); From b5b295e6c7364b4c181ef46b78d3bd550bffc42a Mon Sep 17 00:00:00 2001 From: sethwebster Date: Fri, 21 Aug 2026 09:15:37 -0400 Subject: [PATCH 13/21] fix(navigation): address automated review findings --- .../mobile-navigation-history.test.ts | 13 ++----- .../navigation/mobile-navigation-history.ts | 3 ++ apps/web/src/components/AppSidebarLayout.tsx | 2 +- .../NavigationHistoryControls.test.tsx | 34 ++++++++----------- .../components/NavigationHistoryControls.tsx | 12 +++---- apps/web/src/components/ui/sidebar.tsx | 8 ++--- apps/web/src/navigationHistory.test.ts | 7 ++-- apps/web/src/navigationHistoryStore.ts | 4 +-- 8 files changed, 38 insertions(+), 45 deletions(-) diff --git a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts index 389882bbff5e..216dbbc3f069 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.test.ts @@ -13,14 +13,15 @@ describe("createMobileNavigationHistory", () => { 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 }); }); @@ -30,9 +31,7 @@ describe("createMobileNavigationHistory", () => { 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(); }); @@ -41,9 +40,7 @@ describe("createMobileNavigationHistory", () => { const history = createMobileNavigationHistory(location("/")); history.visit(location("/threads/env/thread-a", "thread-a")); history.visit(location("/settings")); - history.visit(location("/threads/env/thread-a", "thread-a")); - expect(history.getSnapshot()).toEqual({ canGoBack: true, canGoForward: true }); expect(history.requestForward()?.location.pathname).toBe("/settings"); }); @@ -52,9 +49,7 @@ describe("createMobileNavigationHistory", () => { 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"); }); @@ -64,9 +59,7 @@ describe("createMobileNavigationHistory", () => { 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(history.requestBack()?.location.pathname).toContain("terminalId=c"); expect(history.requestForward()).toBeNull(); }); @@ -76,7 +69,6 @@ describe("createMobileNavigationHistory", () => { 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({ index: 2, @@ -95,7 +87,6 @@ describe("createMobileNavigationHistory", () => { 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 index 06280799e742..0d4019543710 100644 --- a/apps/mobile/src/features/navigation/mobile-navigation-history.ts +++ b/apps/mobile/src/features/navigation/mobile-navigation-history.ts @@ -40,12 +40,14 @@ export function createMobileNavigationHistory(initialLocation: MobileNavigationL }, getSnapshot: () => snapshot, requestBack: () => { + if (pendingTarget) return null; const index = cursor - 1; const location = entries[index]; pendingTarget = location ? { index, location } : null; return pendingTarget; }, requestForward: () => { + if (pendingTarget) return null; const index = cursor + 1; const location = entries[index]; pendingTarget = location ? { index, location } : null; @@ -58,6 +60,7 @@ export function createMobileNavigationHistory(initialLocation: MobileNavigationL visit: (location: MobileNavigationLocation) => { const current = entries[cursor]; if (location.pathname === current?.pathname) { + entries = entries.map((entry, index) => (index === cursor ? location : entry)); return; } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 57b931950afc..93aa8b81ab1e 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -124,7 +124,7 @@ function WorkspaceChromeControls() { Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} -
+
diff --git a/apps/web/src/components/NavigationHistoryControls.test.tsx b/apps/web/src/components/NavigationHistoryControls.test.tsx index 5192b31f31b4..4aa2c7cd31d1 100644 --- a/apps/web/src/components/NavigationHistoryControls.test.tsx +++ b/apps/web/src/components/NavigationHistoryControls.test.tsx @@ -1,25 +1,21 @@ import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { NavigationHistoryButtons } from "./NavigationHistoryControls"; -describe("NavigationHistoryButtons", () => { - it("exposes named back and forward buttons with independent disabled states", () => { - const markup = renderToStaticMarkup( - , - ); +it("exposes named back and forward buttons with independent disabled states", () => { + const markup = renderToStaticMarkup( + , + ); - expect(markup).toContain('aria-label="Back"'); - expect(markup).toContain('aria-label="Forward"'); - 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"'); - }); + 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 index 9a82f8375001..ad28a2aab04a 100644 --- a/apps/web/src/components/NavigationHistoryControls.tsx +++ b/apps/web/src/components/NavigationHistoryControls.tsx @@ -10,6 +10,7 @@ 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 { @@ -22,10 +23,6 @@ interface NavigationHistoryButtonsProps { readonly onForward: () => void; } -function tooltipLabel(label: string, shortcut: string | null): string { - return shortcut ? `${label} (${shortcut})` : label; -} - function NavigationButton(props: { readonly available: boolean; readonly className?: string; @@ -42,7 +39,8 @@ function NavigationButton(props: { aria-disabled={!props.available} aria-label={props.label} className={cn( - "size-[var(--workspace-titlebar-control-size)]! aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:hover:bg-transparent [-webkit-app-region:no-drag]", + WORKSPACE_TITLEBAR_CONTROL_CLASS, + "aria-disabled:cursor-default aria-disabled:opacity-64 aria-disabled:hover:bg-transparent", props.className, )} onClick={() => { @@ -57,7 +55,9 @@ function NavigationButton(props: { } /> - {tooltipLabel(props.label, props.shortcut)} + + {props.shortcut ? `${props.label} (${props.shortcut})` : props.label} + ); } 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 (