From f40df47a07a747e1d3e9837f4a4f8f7d777e8e16 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 02:14:24 +0000 Subject: [PATCH] fix(mobile): preserve Android Usage across layout changes Co-authored-by: Alex <5401039+PixPMusic@users.noreply.github.com> --- .../workspace/WorkspaceRootScreen.android.tsx | 65 +++++++-------- .../workspace/workspace-tabs-state.test.ts | 80 ++++++++++++++++++- apps/mobile/vite.config.ts | 1 + 3 files changed, 109 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx index 1173a08e867e9..b156f1fcafbe5 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx @@ -31,48 +31,29 @@ export function WorkspaceRootScreen() { const context = useMemo( () => ({ ...workspace, - bottomClearance: workspace.state.selectedTab === "threads" ? fabClearance + 16 : 16, + bottomClearance: layout.usesSplitView + ? workspace.bottomClearance + : workspace.state.selectedTab === "threads" + ? fabClearance + 16 + : 16, }), - [workspace, fabClearance], + [workspace, fabClearance, layout.usesSplitView], ); const tab = workspace.state.selectedTab; - if (layout.usesSplitView) { - if (enabled.length === 0) return ; - return ( - - tab === "threads" ? ( - - ) : tab !== "pullRequests" ? ( - - ) : ( - <> - - - - - - - ) - } - /> - ); - } - return ( - - - + + {!layout.usesSplitView ? ( + + ) : null} + {enabled.length === 0 ? ( - + layout.usesSplitView ? ( + + ) : ( + + ) ) : ( ) : tab !== "pullRequests" ? ( + ) : layout.usesSplitView ? ( + <> + + + + + + ) : ( @@ -94,7 +87,7 @@ export function WorkspaceRootScreen() { /> )} - + {!layout.usesSplitView ? : null} ); diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts index d0f72faa07727..9071ac8c4ac79 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { act, createElement, createContext, useState, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { WorkspaceRootScreen } from "./WorkspaceRootScreen.android"; import { initialWorkspaceTabsState, workspaceTabsReducer, @@ -161,3 +164,78 @@ describe("workspace root navigation", () => { expect(workspaceTabForNativeSlot(order, "removed")).toBeNull(); }); }); + +const mocks = vi.hoisted(() => ({ split: false })); + +vi.mock("react-native", () => ({ + View: ({ children }: { children: ReactNode }) => createElement("div", null, children), +})); +vi.mock("react-native-safe-area-context", () => ({ + SafeAreaInsetsContext: createContext({ top: 0, right: 0, bottom: 24, left: 0 }), + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 24, left: 0 }), +})); +vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({ + useAdaptiveWorkspaceLayout: () => ({ + layout: { usesSplitView: mocks.split }, + startNewTask: () => undefined, + }), +})); +vi.mock("./navigation-tabs", () => ({ + useEnabledNavigationTabs: () => ["threads", "pullRequests", "usage"], +})); +vi.mock("./WorkspaceTabsContext", () => ({ + WorkspaceTabsContext: createContext(null), + useWorkspaceTabs: () => ({ state: { selectedTab: "usage" } }), +})); +vi.mock("./WorkspaceExtraTab", () => ({ + WorkspaceExtraTab: function UsageProbe() { + const [tab, setTab] = useState("Limits"); + return createElement("button", { onClick: () => setTab("Usage") }, tab); + }, +})); +vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../components/ui/AndroidScreenHeader", () => ({ AndroidScreenHeader: () => null })); +vi.mock("../../components/ui/EmptyState", () => ({ EmptyState: () => null })); +vi.mock("../../components/ui/ScreenContent", () => ({ + ScreenContent: ({ children }: { children: ReactNode }) => createElement("div", null, children), +})); +vi.mock("../../components/ui/useAndroidControlSizing", () => ({ + useAndroidControlSizing: () => ({ fabClearance: 64 }), +})); +vi.mock("../home/HomeRouteScreen", () => ({ HomeRouteScreen: () => null })); +vi.mock("../pull-requests/PullRequestFilterMenuContext", () => ({ + PullRequestFilterMenuProvider: ({ children }: { children: ReactNode }) => + createElement("div", null, children), +})); +vi.mock("../pull-requests/PullRequestsScreen", () => ({ PullRequestsScreen: () => null })); +vi.mock("../pull-requests/PullRequestToolbar.android", () => ({ PullRequestToolbar: () => null })); +vi.mock("./AndroidWorkspaceNavigation", () => ({ AndroidWorkspaceNavigation: () => null })); +vi.mock("./WorkspaceNoTabs", () => ({ WorkspaceNoTabs: () => null })); + +function setup(split: boolean) { + mocks.split = split; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + return { [Symbol.dispose]: () => vi.unstubAllGlobals() }; +} + +it.each([false, true])( + "keeps the Usage page mounted through layout transitions (split: %s)", + async (split) => { + using _actEnvironment = setup(split); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + await act(async () => root.render(createElement(WorkspaceRootScreen))); + + const button = container.querySelector("button")!; + await act(async () => button.click()); + expect(button.textContent).toBe("Usage"); + + for (const nextSplit of [!split, split, !split, split]) { + mocks.split = nextSplit; + await act(async () => root.render(createElement(WorkspaceRootScreen))); + expect(container.querySelector("button")?.textContent).toBe("Usage"); + expect(container.querySelector("button")).toBe(button); + } + }, +); diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 77fffb6cb6117..d3ab07d5b58be 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/workspace/workspace-tabs-state.test.ts", "src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx", "src/lib/filePreview.test.ts", "src/features/threads/thread-inspector-navigation.test.tsx",