From c8e81fc639d054ad4156e34f0b4f6c0df5d87570 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 17 Oct 2026 16:37:38 -0700 Subject: [PATCH] fix(android): sidebar threads open while Settings or New task is open Co-Authored-By: Claude Opus 5.5 --- .../AndroidWorkspaceNavigation.android.tsx | 8 ++--- .../useAndroidWorkspaceNavigation.ts | 11 ++---- .../workspace/useAndroidWorkspaceSelection.ts | 8 ++--- .../lib/android-workspace-navigation.test.ts | 27 ++++++++++++++ .../src/lib/android-workspace-navigation.ts | 35 ++++++++++++++++++- apps/mobile/src/native/useOpenPullRequest.ts | 9 ++--- 6 files changed, 72 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx index addf9718b09d..b643b257c33f 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx @@ -1,6 +1,6 @@ import { requireNativeView } from "expo"; -import { CommonActions, useNavigation } from "@react-navigation/native"; -import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; +import { useNavigation } from "@react-navigation/native"; +import { resetAndroidWorkspace } from "../../lib/android-workspace-navigation"; import { Keyboard, processColor, @@ -81,9 +81,7 @@ export function AndroidWorkspaceNavigation(props: { const tab = event.nativeEvent.tab; if (tab !== workspace.state.selectedTab) { workspace.dispatch({ type: "select", tab }); - navigation.dispatch((state) => - CommonActions.reset(androidWorkspaceNavigationState(state)), - ); + resetAndroidWorkspace(navigation); } props.onSelect?.(); }} diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts b/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts index 803b571b135a..a2f30627b8d9 100644 --- a/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts +++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts @@ -1,12 +1,7 @@ -import { - CommonActions, - StackActions, - useFocusEffect, - useNavigation, -} from "@react-navigation/native"; +import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import { useCallback } from "react"; import { BackHandler, Keyboard, Platform } from "react-native"; -import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; +import { resetAndroidWorkspace } from "../../lib/android-workspace-navigation"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import type { WorkspaceTab } from "./workspace-tabs-state"; @@ -16,7 +11,7 @@ export function useWorkspaceListNavigation(tab: WorkspaceTab) { return useCallback(() => { if (Platform.OS === "android") { dispatch?.({ type: "select", tab }); - navigation.dispatch((state) => CommonActions.reset(androidWorkspaceNavigationState(state))); + resetAndroidWorkspace(navigation); } else if (navigation.canGoBack()) navigation.goBack(); else navigation.dispatch(StackActions.replace("Home")); }, [navigation, dispatch, tab]); diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts index 46f49e9c071e..fc536eedf3f0 100644 --- a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts +++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts @@ -1,8 +1,8 @@ -import { CommonActions, useNavigation } from "@react-navigation/native"; +import { useNavigation } from "@react-navigation/native"; import { useCallback } from "react"; import { Keyboard } from "react-native"; import { - androidWorkspaceNavigationState, + resetAndroidWorkspace, type AndroidWorkspaceDestination, } from "../../lib/android-workspace-navigation"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; @@ -20,9 +20,7 @@ export function useAndroidWorkspaceSelection() { type: "select", tab: destination.name === "Thread" ? "threads" : "pullRequests", }); - navigation.dispatch((state) => - CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)), - ); + resetAndroidWorkspace(navigation, destination, reviewers); }, [navigation, dispatch], ); diff --git a/apps/mobile/src/lib/android-workspace-navigation.test.ts b/apps/mobile/src/lib/android-workspace-navigation.test.ts index a9bdef84e607..3b63f0e67ec0 100644 --- a/apps/mobile/src/lib/android-workspace-navigation.test.ts +++ b/apps/mobile/src/lib/android-workspace-navigation.test.ts @@ -2,7 +2,9 @@ import * as NodeModule from "node:module"; import { describe, expect, it } from "vite-plus/test"; import { androidWorkspaceNavigationState, + resetAndroidWorkspace, type AndroidWorkspaceDestination, + type AndroidWorkspaceResetAction, } from "./android-workspace-navigation"; const require = NodeModule.createRequire(import.meta.url); @@ -183,4 +185,29 @@ describe("Android workspace navigation", () => { expect(state.routes[0]?.key).toBe(previous.routes[0]?.key); expect(state.index).toBe(0); }); + + it("resets the root stack when a nested Settings stack has focus", () => { + const root = apply(router.getInitialState(options), StackActions.push("SettingsSheet")); + const settings = router.getInitialState({ + ...options, + routeNames: ["SettingsContent", "SettingsEnvironments"], + }); + const actions: AndroidWorkspaceResetAction[] = []; + const rootNavigation = { + dispatch: () => undefined, + getParent: () => undefined, + getState: () => root, + }; + resetAndroidWorkspace( + { + dispatch: (action) => actions.push(action), + getParent: () => rootNavigation, + getState: () => settings, + }, + thread, + ); + const [action] = actions; + expect(action?.target).toBe(root.key); + expect(names(apply(root, action!))).toEqual(["Home", "Thread"]); + }); }); diff --git a/apps/mobile/src/lib/android-workspace-navigation.ts b/apps/mobile/src/lib/android-workspace-navigation.ts index 2a26e5aceef0..9e246aef8939 100644 --- a/apps/mobile/src/lib/android-workspace-navigation.ts +++ b/apps/mobile/src/lib/android-workspace-navigation.ts @@ -1,9 +1,19 @@ -import type { NavigationState } from "@react-navigation/native"; +import type { CommonActions, NavigationState } from "@react-navigation/native"; export type AndroidWorkspaceDestination = | { readonly name: "Thread"; readonly params: ReactNavigation.RootParamList["Thread"] } | { readonly name: "PullRequest"; readonly params: ReactNavigation.RootParamList["PullRequest"] }; +export type AndroidWorkspaceResetAction = ReturnType & { + readonly target: string; +}; + +interface WorkspaceNavigation { + readonly dispatch: (action: AndroidWorkspaceResetAction) => void; + readonly getParent: () => WorkspaceNavigation | undefined; + readonly getState: () => NavigationState | undefined; +} + export function androidWorkspaceNavigationState( state: NavigationState, destination?: AndroidWorkspaceDestination, @@ -31,3 +41,26 @@ export function androidWorkspaceNavigationState( ], }; } + +/** + * Resets the root stack to a workspace list or detail. Always reads and targets the root + * navigator: the sidebar and rail render in the root layout, where `useNavigation()` is the + * container, and a container dispatch runs on the deepest focused navigator. With Settings + * or New task open, that is their nested stack, which rejects the reset. + */ +export function resetAndroidWorkspace( + navigation: WorkspaceNavigation, + destination?: AndroidWorkspaceDestination, + reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], +) { + let root = navigation; + for (let parent = root.getParent(); parent; parent = root.getParent()) root = parent; + const state = root.getState(); + if (!state) return; + // Same shape as `CommonActions.reset`, which would pull React Native into node tests. + navigation.dispatch({ + type: "RESET", + payload: androidWorkspaceNavigationState(state, destination, reviewers), + target: state.key, + }); +} diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index c133a57d8a25..fdf9061747e3 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -2,8 +2,7 @@ import { useNavigation, useRoute, StackActions } from "@react-navigation/native" import { nativePullRequestDestination } from "../lib/pullRequestNavigation"; import { useCallback } from "react"; import { Platform } from "react-native"; -import { CommonActions } from "@react-navigation/native"; -import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation"; +import { resetAndroidWorkspace } from "../lib/android-workspace-navigation"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useThreadShell } from "../state/entities"; import { tryOpenExternalUrl } from "../lib/openExternalUrl"; @@ -45,11 +44,7 @@ export function useOpenPullRequest(options?: { ); if (!params) return tryOpenExternalUrl(url, "pull-request"); if (Platform.OS === "android") - navigation.dispatch((state) => - CommonActions.reset( - androidWorkspaceNavigationState(state, { name: "PullRequest", params }), - ), - ); + resetAndroidWorkspace(navigation, { name: "PullRequest", params }); else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params)); else navigation.navigate("PullRequest", params); return true;