From 274e06ca3d6ff3159e06028e51ea755c4512d8b2 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 15 Oct 2026 03:26:41 +0000 Subject: [PATCH 1/2] fix(mobile): Android split view opens threads while Settings is open Selecting a thread reset the stack through whichever navigator had focus. With Settings open that was the Settings stack, which has no Home or Thread routes, so the reset was dropped. Target the root stack instead. Co-Authored-By: Claude Opus 5.5 --- .../workspace/useAndroidWorkspaceSelection.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts index 46f49e9c071e5..187e1bf88c63f 100644 --- a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts +++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts @@ -1,5 +1,5 @@ -import { CommonActions, useNavigation } from "@react-navigation/native"; -import { useCallback } from "react"; +import { CommonActions, NavigationContainerRefContext } from "@react-navigation/native"; +import { use, useCallback } from "react"; import { Keyboard } from "react-native"; import { androidWorkspaceNavigationState, @@ -8,22 +8,28 @@ import { import { useWorkspaceTabs } from "./WorkspaceTabsContext"; export function useAndroidWorkspaceSelection() { - const navigation = useNavigation(); + // Resets the root stack even while a nested navigator such as Settings has + // focus; a plain dispatch from the workspace layout reaches that navigator, + // which has no Home or Thread routes. + const root = use(NavigationContainerRefContext); const dispatch = useWorkspaceTabs()?.dispatch; return useCallback( ( destination: AndroidWorkspaceDestination, reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], ) => { + const state = root?.getRootState(); + if (!root || !state) return; Keyboard.dismiss(); dispatch?.({ type: "select", tab: destination.name === "Thread" ? "threads" : "pullRequests", }); - navigation.dispatch((state) => - CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)), - ); + root.dispatch({ + ...CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)), + target: state.key, + }); }, - [navigation, dispatch], + [root, dispatch], ); } From 3170d3ab079c3842631113149b3b3183461ec4b1 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 15 Oct 2026 03:42:21 +0000 Subject: [PATCH 2/2] test(mobile): cover Android thread selection while Settings has focus Co-Authored-By: Claude Opus 5.5 --- .../useAndroidWorkspaceSelection.test.tsx | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.test.tsx diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.test.tsx b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.test.tsx new file mode 100644 index 0000000000000..6e6f736e0c4d4 --- /dev/null +++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import { act, useEffect, type ComponentProps, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +// React Navigation's core runs in jsdom; the native package also loads React Native. +const navigation = await vi.hoisted(async () => { + const NodeModule = await import("node:module"); + const require = NodeModule.createRequire(import.meta.url); + const nativePackage = require.resolve("@react-navigation/native/package.json"); + const core = NodeModule.createRequire(nativePackage).resolve("@react-navigation/core"); + return (await import(core)) as typeof import("@react-navigation/native"); +}); +vi.mock("@react-navigation/native", () => navigation); +vi.mock("react-native", () => ({ Keyboard: { dismiss: vi.fn() } })); + +const { useAndroidWorkspaceSelection } = await import("./useAndroidWorkspaceSelection"); + +function TestStackNavigator( + props: { + readonly children: ReactNode; + readonly initialRouteName?: string; + } & Pick[1], "layout">, +) { + const { state, descriptors, NavigationContent } = navigation.useNavigationBuilder( + navigation.StackRouter, + props, + ); + const focused = state.routes[state.index]; + return ( + {focused ? descriptors[focused.key]?.render() : null} + ); +} + +const Stack = navigation.createNavigatorFactory(TestStackNavigator)(); + +function SettingsStack() { + return ( + + null} /> + null} /> + + ); +} + +type SelectWorkspace = ReturnType; + +function Sidebar(props: { readonly onReady: (select: SelectWorkspace) => void }) { + const select = useAndroidWorkspaceSelection(); + const { onReady } = props; + useEffect(() => onReady(select), [onReady, select]); + return null; +} + +let root: Root | null = null; + +afterEach(() => { + act(() => root?.unmount()); + root = null; +}); + +describe("useAndroidWorkspaceSelection", () => { + it("opens a thread from the workspace layout while nested Settings has focus", () => { + const container = navigation.createNavigationContainerRef(); + let select: SelectWorkspace | undefined; + const onReady = (next: SelectWorkspace) => { + select = next; + }; + // The real sidebar renders in the root navigator's layout, outside any screen. + const layout = ({ children }: { readonly children: ReactNode }) => ( + <> + + {children} + + ); + const initialState = { + index: 2, + routes: [ + { name: "Home" }, + { name: "Thread", params: { environmentId: "environment-a", threadId: "thread-a" } }, + { + name: "SettingsSheet", + state: { index: 1, routes: [{ name: "Settings" }, { name: "SettingsFollowUps" }] }, + }, + ], + } satisfies ComponentProps["initialState"]; + root = createRoot(document.createElement("div")); + act(() => + root?.render( + + + null} /> + null} /> + + + , + ), + ); + + act(() => + select?.({ + name: "Thread", + params: { environmentId: "environment-a", threadId: "thread-b" }, + }), + ); + + const state = container.getRootState(); + expect(state?.routes.map((route) => route.name)).toEqual(["Home", "Thread"]); + expect(state?.routes[state.index]?.params).toEqual({ + environmentId: "environment-a", + threadId: "thread-b", + }); + }); +});