Skip to content

Commit efe0d16

Browse files
fix(mobile): preserve Android Usage across layout changes (#25231)
Co-authored-by: Alex <[email protected]>
1 parent 52b0ab2 commit efe0d16

3 files changed

Lines changed: 135 additions & 50 deletions

File tree

‎apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx‎

Lines changed: 42 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -33,64 +33,45 @@ export function WorkspaceRootScreen() {
3333
const floating = floatingTabsMetrics(enabled.length + 1, scale, fabSize, window.width);
3434
const context = {
3535
...workspace,
36-
bottomClearance: floatingTabsEnabled
37-
? insets.bottom +
38-
FLOATING_TABS.bottom +
39-
FLOATING_TABS.edge +
40-
Math.max(
41-
floating.height,
42-
workspace.state.selectedTab === "threads" ? floating.fabOffset + fabSize : 0,
43-
)
44-
: workspace.state.selectedTab === "threads"
45-
? fabClearance + 16
46-
: 16,
36+
bottomClearance: layout.usesSplitView
37+
? workspace.bottomClearance
38+
: floatingTabsEnabled
39+
? insets.bottom +
40+
FLOATING_TABS.bottom +
41+
FLOATING_TABS.edge +
42+
Math.max(
43+
floating.height,
44+
workspace.state.selectedTab === "threads" ? floating.fabOffset + fabSize : 0,
45+
)
46+
: workspace.state.selectedTab === "threads"
47+
? fabClearance + 16
48+
: 16,
4749
};
4850
const tab = workspace.state.selectedTab;
4951

50-
if (layout.usesSplitView) {
51-
if (enabled.length === 0) return <View className="flex-1 bg-screen" />;
52-
return (
53-
<WorkspaceTabPanes
54-
tabs={TABS}
55-
selected={tab}
56-
renderTab={(tab) =>
57-
tab === "threads" ? (
58-
<HomeRouteScreen />
59-
) : tab !== "pullRequests" ? (
60-
<WorkspaceExtraTab tab={tab} />
61-
) : (
62-
<>
63-
<NativeStackScreenOptions options={{ headerShown: false }} />
64-
<AndroidScreenHeader title="Pull requests" />
65-
<View className="flex-1 items-center justify-center px-10">
66-
<EmptyState
67-
title="Select a pull request"
68-
detail="Choose a pull request from the sidebar to review it."
69-
variant="plain"
70-
/>
71-
</View>
72-
</>
73-
)
74-
}
75-
/>
76-
);
77-
}
78-
7952
return (
8053
<WorkspaceTabsContext value={context}>
81-
<View className="flex-1 bg-header">
82-
<NativeStackScreenOptions options={{ headerShown: false }} />
54+
<View className={layout.usesSplitView ? "flex-1 bg-screen" : "flex-1 bg-header"}>
55+
{!layout.usesSplitView ? (
56+
<NativeStackScreenOptions options={{ headerShown: false }} />
57+
) : null}
8358
<SafeAreaInsetsContext
8459
value={
85-
floatingTabsEnabled
86-
? tab === "usage"
87-
? { ...insets, bottom: context.bottomClearance }
88-
: insets
89-
: { ...insets, bottom: 0 }
60+
layout.usesSplitView
61+
? insets
62+
: floatingTabsEnabled
63+
? tab === "usage"
64+
? { ...insets, bottom: context.bottomClearance }
65+
: insets
66+
: { ...insets, bottom: 0 }
9067
}
9168
>
9269
{enabled.length === 0 ? (
93-
<WorkspaceNoTabs />
70+
layout.usesSplitView ? (
71+
<View className="flex-1 bg-screen" />
72+
) : (
73+
<WorkspaceNoTabs />
74+
)
9475
) : (
9576
<WorkspaceTabPanes
9677
tabs={TABS}
@@ -100,6 +81,18 @@ export function WorkspaceRootScreen() {
10081
<HomeRouteScreen />
10182
) : tab !== "pullRequests" ? (
10283
<WorkspaceExtraTab tab={tab} />
84+
) : layout.usesSplitView ? (
85+
<>
86+
<NativeStackScreenOptions options={{ headerShown: false }} />
87+
<AndroidScreenHeader title="Pull requests" />
88+
<View className="flex-1 items-center justify-center px-10">
89+
<EmptyState
90+
title="Select a pull request"
91+
detail="Choose a pull request from the sidebar to review it."
92+
variant="plain"
93+
/>
94+
</View>
95+
</>
10396
) : (
10497
<PullRequestsScreen
10598
renderFrame={(filters, list) => (
@@ -114,7 +107,7 @@ export function WorkspaceRootScreen() {
114107
/>
115108
)}
116109
</SafeAreaInsetsContext>
117-
{floatingTabsEnabled ? (
110+
{layout.usesSplitView ? null : floatingTabsEnabled ? (
118111
<FloatingWorkspaceTabs fillWidth onNewThread={startNewTask} />
119112
) : (
120113
<AndroidWorkspaceNavigation onNewThread={startNewTask} />

‎apps/mobile/src/features/workspace/workspace-tabs-state.test.ts‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { describe, expect, it } from "vite-plus/test";
1+
import { describe, expect, it, vi } from "vite-plus/test";
2+
import { act, createElement, createContext, useState, type ReactNode } from "react";
3+
import { createRoot } from "react-dom/client";
4+
import { WorkspaceRootScreen } from "./WorkspaceRootScreen.android";
25
import {
36
initialWorkspaceTabsState,
47
workspaceTabsReducer,
@@ -161,3 +164,91 @@ describe("workspace root navigation", () => {
161164
expect(workspaceTabForNativeSlot(order, "removed")).toBeNull();
162165
});
163166
});
167+
168+
const mocks = vi.hoisted(() => ({ split: false, floating: false }));
169+
170+
vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({
171+
useAndroidFloatingTabs: () => mocks.floating,
172+
}));
173+
vi.mock("react-native", () => ({
174+
useWindowDimensions: () => ({ width: 400, height: 800 }),
175+
View: ({ children }: { children: ReactNode }) => createElement("div", null, children),
176+
}));
177+
vi.mock("react-native-safe-area-context", () => ({
178+
SafeAreaInsetsContext: createContext({ top: 0, right: 0, bottom: 24, left: 0 }),
179+
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 24, left: 0 }),
180+
}));
181+
vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({
182+
useAdaptiveWorkspaceLayout: () => ({
183+
layout: { usesSplitView: mocks.split },
184+
startNewTask: () => undefined,
185+
}),
186+
}));
187+
vi.mock("./navigation-tabs", () => ({
188+
useEnabledNavigationTabs: () => ["threads", "pullRequests", "usage"],
189+
}));
190+
vi.mock("./WorkspaceTabsContext", () => ({
191+
WorkspaceTabsContext: createContext(null),
192+
useWorkspaceTabs: () => ({ state: { selectedTab: "usage" } }),
193+
}));
194+
vi.mock("./WorkspaceExtraTab", () => ({
195+
WorkspaceExtraTab: function UsageProbe() {
196+
const [tab, setTab] = useState("Limits");
197+
return createElement("button", { onClick: () => setTab("Usage") }, tab);
198+
},
199+
}));
200+
vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null }));
201+
vi.mock("../../components/ui/AndroidScreenHeader", () => ({ AndroidScreenHeader: () => null }));
202+
vi.mock("../../components/ui/EmptyState", () => ({ EmptyState: () => null }));
203+
vi.mock("../../components/ui/ScreenContent", () => ({
204+
ScreenContent: ({ children }: { children: ReactNode }) => createElement("div", null, children),
205+
}));
206+
vi.mock("../../components/ui/useAndroidControlSizing", () => ({
207+
useAndroidControlSizing: () => ({ fabClearance: 64, scale: 1, fabSize: 56 }),
208+
}));
209+
vi.mock("../home/HomeRouteScreen", () => ({ HomeRouteScreen: () => null }));
210+
vi.mock("../pull-requests/PullRequestFilterMenuContext", () => ({
211+
PullRequestFilterMenuProvider: ({ children }: { children: ReactNode }) =>
212+
createElement("div", null, children),
213+
}));
214+
vi.mock("../pull-requests/PullRequestsScreen", () => ({ PullRequestsScreen: () => null }));
215+
vi.mock("../pull-requests/PullRequestToolbar.android", () => ({ PullRequestToolbar: () => null }));
216+
vi.mock("./AndroidWorkspaceNavigation", () => ({
217+
AndroidWorkspaceNavigation: () => null,
218+
FloatingWorkspaceTabs: () => null,
219+
}));
220+
vi.mock("./WorkspaceNoTabs", () => ({ WorkspaceNoTabs: () => null }));
221+
222+
function setup(split: boolean, floating: boolean) {
223+
mocks.split = split;
224+
mocks.floating = floating;
225+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
226+
return { [Symbol.dispose]: () => vi.unstubAllGlobals() };
227+
}
228+
229+
it.each([
230+
[false, false],
231+
[true, false],
232+
[false, true],
233+
[true, true],
234+
])(
235+
"keeps the Usage page mounted through layout transitions (split: %s, floating: %s)",
236+
async (split, floating) => {
237+
using _actEnvironment = setup(split, floating);
238+
const container = document.createElement("div");
239+
const root = createRoot(container);
240+
using _root = { [Symbol.dispose]: () => act(() => root.unmount()) };
241+
await act(async () => root.render(createElement(WorkspaceRootScreen)));
242+
243+
const button = container.querySelector("button")!;
244+
await act(async () => button.click());
245+
expect(button.textContent).toBe("Usage");
246+
247+
for (const nextSplit of [!split, split, !split, split]) {
248+
mocks.split = nextSplit;
249+
await act(async () => root.render(createElement(WorkspaceRootScreen)));
250+
expect(container.querySelector("button")?.textContent).toBe("Usage");
251+
expect(container.querySelector("button")).toBe(button);
252+
}
253+
},
254+
);

‎apps/mobile/vite.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { defineConfig, normalizePath } from "vite-plus";
44
import "vite-plus/test/config";
55

66
const reactTests = [
7+
"src/features/workspace/workspace-tabs-state.test.ts",
78
"src/features/threads/git/GitBranchesSheet.occupancy.test.tsx",
89
"src/features/files/useFileTreeEntries.test.tsx",
910
"src/features/projects/AddProjectScreen.test.ts",

0 commit comments

Comments
 (0)