Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3859147
feat(web): add back and forward navigation
sethwebster Aug 20, 2026
e5077ee
fix(clients): complete navigation history controls
sethwebster Aug 20, 2026
9ef1e2b
fix(clients): make navigation traversal exact
sethwebster Aug 20, 2026
26a28f5
fix(mobile): align navigation history with routes
sethwebster Aug 20, 2026
adb0eea
fix(mobile): confirm exact navigation targets
sethwebster Aug 20, 2026
bbbf00e
fix(mobile): cancel blocked history traversal
sethwebster Aug 20, 2026
2769cbf
refactor(navigation): tighten history implementation
sethwebster Aug 20, 2026
9df1b7e
fix(clients): sync live navigation history state
sethwebster Aug 21, 2026
01ade4e
fix(mobile): traverse modal navigation history
sethwebster Aug 21, 2026
a38db3e
fix(mobile): pop existing history routes
sethwebster Aug 21, 2026
69f582f
fix(mobile): preserve navigation query state
sethwebster Aug 21, 2026
9e7a46a
refactor(navigation): name transient route state
sethwebster Aug 21, 2026
b5b295e
fix(navigation): address automated review findings
sethwebster Aug 21, 2026
37482fd
fix(mobile): preserve mixed navigation queries
sethwebster Aug 21, 2026
7ee52c2
fix(mobile): reconcile pending same-path traversal
sethwebster Aug 21, 2026
3eb5dbd
fix(clients): align navigation interaction states
sethwebster Aug 21, 2026
7d4b9d1
fix(web): preserve titlebar hover state
sethwebster Aug 21, 2026
109b482
fix(mobile): keep navigation recovery paths
sethwebster Aug 21, 2026
c6c778a
fix(mobile): preserve navigation route identity
sethwebster Aug 21, 2026
5d830ff
fix(mobile): refresh recreated navigation hosts
sethwebster Aug 21, 2026
3ac1009
fix(mobile): guard navigation host remapping
sethwebster Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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") }
Expand Down
43 changes: 32 additions & 11 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -23,6 +23,9 @@ import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardi
import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
import { MobileNavigationHistoryButtons } from "./features/navigation/MobileNavigationHistoryButtons";
import { MobileNavigationHistoryProvider } from "./features/navigation/MobileNavigationHistoryProvider";
import { normalizeMobileNavigationPath } from "./features/navigation/mobile-navigation-history";
import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
import { ReviewSheet } from "./features/review/ReviewSheet";
import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
Expand Down Expand Up @@ -87,6 +90,7 @@ type AppScreenOptions = NativeStackNavigationOptions & {
// iOS versions. Pre-glass iOS gets the same solid material as internal-scroll
// surfaces so content is laid out below the bar instead of underlapping it.
const GLASS_HEADER_OPTIONS: AppScreenOptions = {
headerLeft: () => <MobileNavigationHistoryButtons grouped />,
headerBackButtonDisplayMode: "minimal",
headerBackTitle: "",
headerLargeTitle: false,
Expand All @@ -102,6 +106,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
// SOLID: opaque sheet-colored header for surfaces whose content scrolls internally
// (file viewer, terminal, review) — there is nothing for glass to sample there.
const SOLID_HEADER_OPTIONS: AppScreenOptions = {
headerLeft: () => <MobileNavigationHistoryButtons grouped />,
headerBackButtonDisplayMode: "minimal",
headerBackTitle: "",
headerLargeTitle: false,
Expand Down Expand Up @@ -349,6 +354,15 @@ function workspacePathFromState(state: NavigationState): string {
return path.startsWith("/") ? path : `/${path}`;
}

function activeNavigationTransitionKey(state: NavigationState): string {
const route = state.routes[state.index];
if (!route) {
return "empty";
}
const nestedState = route.state as NavigationState | undefined;
return nestedState ? `${route.key}/${activeNavigationTransitionKey(nestedState)}` : route.key;
}

// The drain hook subscribes to the outbox, all thread shells, projects, and
// connection statuses. Hosting it in a null-rendering leaf keeps those
// updates from re-rendering RootStackLayout (and with it every screen) on
Expand Down Expand Up @@ -387,20 +401,27 @@ function RootStackLayout(props: {
}, [navigation, pendingShare, props.state]);
// Full pathname (sheets included) for keyboard-command scoping; the
// workspace layout only reacts to the underlying non-overlay route.
const path = getPathFromState(props.state, navigationPathConfig);
const path = normalizeMobileNavigationPath(getPathFromState(props.state, navigationPathConfig));
const pathname = path.startsWith("/") ? path : `/${path}`;
const workspacePathname = workspacePathFromState(props.state);
const transitionKey = activeNavigationTransitionKey(props.state);
const navigationLocation = useMemo(
() => ({ pathname, transitionKey }),
[pathname, transitionKey],
);

return (
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
<ShowcaseCaptureCoordinator pathname={pathname} />
<ExistingThreadSettingsRouteProvider>
<AdaptiveWorkspaceLayout pathname={workspacePathname}>
{props.children}
</AdaptiveWorkspaceLayout>
</ExistingThreadSettingsRouteProvider>
</HardwareKeyboardCommandProvider>
<MobileNavigationHistoryProvider location={navigationLocation}>
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
<ShowcaseCaptureCoordinator pathname={pathname} />
<ExistingThreadSettingsRouteProvider>
<AdaptiveWorkspaceLayout pathname={workspacePathname}>
{props.children}
</AdaptiveWorkspaceLayout>
</ExistingThreadSettingsRouteProvider>
</HardwareKeyboardCommandProvider>
</MobileNavigationHistoryProvider>
);
}

Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/components/AndroidScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function AndroidScreenHeader(props: {
readonly actions?: ReadonlyArray<AndroidHeaderAction>;
readonly trailing?: ReactNode;
readonly onBack?: () => void;
readonly backDisabled?: boolean;
readonly embedded?: boolean;
}) {
const insets = useSafeAreaInsets();
Expand All @@ -66,11 +67,15 @@ export function AndroidScreenHeader(props: {
<View className="min-h-12 flex-row items-center gap-2">
{props.onBack ? (
<Pressable
accessibilityLabel="Navigate up"
accessibilityLabel="Back"
accessibilityRole="button"
disabled={props.backDisabled}
hitSlop={8}
onPress={props.onBack}
className="-mr-2 size-11 items-center justify-center"
className={cn(
"-mr-2 size-11 items-center justify-center",
props.backDisabled && "opacity-55",
)}
>
<SymbolView
name="chevron.left"
Expand Down
24 changes: 23 additions & 1 deletion apps/mobile/src/features/home/HomeHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ 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 { createNativeNavigationHistoryItems } from "../navigation/native-navigation-history-items";
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
import {
createNativeMailSearchToolbarItem,
Expand Down Expand Up @@ -231,6 +234,8 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
}
/>

<MobileNavigationHistoryButtons />

<ControlPillMenu
actions={menuActions}
isAnchoredToRight
Expand Down Expand Up @@ -299,6 +304,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
}

function IosHomeHeader(props: HomeHeaderProps) {
const navigationHistory = useMobileNavigationHistory();
const searchBarRef = useRef<SearchBarCommands>(null);
const iconColor = useThemeColor("--color-icon");
// Thread List v2 lays the list out in fixed creation order, so the
Expand All @@ -317,18 +323,34 @@ function IosHomeHeader(props: HomeHeaderProps) {
...props,
listOrganization: !threadListV2Enabled,
});
const navigationHeaderItems = useMemo(
() =>
createNativeNavigationHistoryItems({
canGoBack: navigationHistory.canGoBack,
canGoForward: navigationHistory.canGoForward,
identifierPrefix: "home-navigation",
onBack: navigationHistory.back,
onForward: navigationHistory.forward,
}),
[navigationHistory],
);

return (
<>
<NativeStackScreenOptions
optionsVersion={filterMenu.items}
optionsVersion={{
canGoBack: navigationHistory.canGoBack,
canGoForward: navigationHistory.canGoForward,
filterMenuItems: filterMenu.items,
}}
options={{
// Static header config (glass, title, fonts) lives in Stack.tsx
// (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
headerTintColor: iconColor,
unstable_headerRightItems:
Platform.OS === "ios"
? () => [
...navigationHeaderItems,
withNativeGlassHeaderItem({
accessibilityLabel: "Open settings",
icon: { name: "ellipsis", type: "sfSymbol" } as const,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export function getConnectionAwareBrandHeaderOptions(opts: {
}

return {
headerLeft: () => null,
headerTitle: () => (
<WorkspaceConnectionTitle
brand={<CompactBrandTitle />}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -24,14 +26,15 @@ export function HardwareKeyboardCommandProvider({
const enabledCommands = useMemo(() => {
const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
commands.add("newTask");
if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
if (navigationHistory.canGoBack) commands.add("back");
if (navigationHistory.canGoForward) commands.add("forward");
if (parseActiveThreadPath(pathname)) {
commands.add("files");
commands.add("terminal");
commands.add("review");
}
return [...commands];
}, [pathname, registrationVersion, navigation]);
}, [navigationHistory.canGoBack, navigationHistory.canGoForward, pathname, registrationVersion]);

const onCommand = useCallback(
(command: HardwareKeyboardCommand) => {
Expand All @@ -42,11 +45,11 @@ export function HardwareKeyboardCommandProvider({
return;
}
if (command === "back") {
if (navigation.canGoBack()) {
navigation.goBack();
} else {
navigation.dispatch(StackActions.replace("Home"));
}
navigationHistory.back();
return;
}
if (command === "forward") {
navigationHistory.forward();
return;
}

Expand All @@ -62,7 +65,7 @@ export function HardwareKeyboardCommandProvider({
navigation.navigate("ThreadReview", thread);
}
},
[pathname, navigation],
[navigation, navigationHistory, pathname],
);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type HardwareKeyboardCommand =
| "newTask"
| "focusSearch"
| "back"
| "forward"
| "files"
| "terminal"
| "review"
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<View className="flex-row items-center gap-0.5">
<ControlPill
accessibilityLabel="Back"
{...(groupedClassName ? { className: groupedClassName } : {})}
disabled={!history.canGoBack}
icon="chevron.left"
onPress={history.back}
/>
<ControlPill
accessibilityLabel="Forward"
{...(groupedClassName ? { className: groupedClassName } : {})}
disabled={!history.canGoForward}
icon="chevron.right"
onPress={history.forward}
/>
</View>
);
}
Loading
Loading