Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -59,6 +59,7 @@ import {
collapsedSplitTransition,
nativeWorkspacePopAction,
projectWorkspaceStack,
workspaceDetailHidesTabs,
partitionStackPresentations,
reconcileStackScreens,
type CollapsedSplitColumn,
Expand Down Expand Up @@ -443,9 +444,9 @@ function WorkspaceColumns(
function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean }) {
const { layout } = useAdaptiveWorkspaceLayout();
const workspace = useWorkspaceTabs()!;
// Native dismiss commits the route pop after the transition. Restore the dock
// when the primary screen starts appearing, including interactive back gestures.
const [primaryVisible, setPrimaryVisible] = useState(false);
// Native Back reveals Home before the router pops. Scope that exception to its
// outgoing route so a new selection hides the dock before native callbacks arrive.
const [returningFrom, setReturningFrom] = useState<Route | null>(null);
const metrics = useNativeLayoutMetrics();
const projection = projectWorkspaceStack(props.state, (route) => {
const presentation = props.descriptors[route.key]?.options.presentation;
Expand All @@ -469,8 +470,7 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean
<WorkspaceNativeTabs
hidden={
!workspacePage &&
projection.detail.length > 0 &&
!primaryVisible &&
workspaceDetailHidesTabs(projection.detail, returningFrom) &&
!layout.usesSplitView &&
(metrics?.verticalBarEdge ?? "none") === "none"
}
Expand All @@ -485,8 +485,8 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean
) : (
<V5CardStackView
{...props}
onRootVisibilityChange={setPrimaryVisible}
onNativePopChange={setNativelyPoppingWorkspaceRoute}
onRootReturnChange={setReturningFrom}
descriptors={descriptors}
state={{
...props.state,
Expand Down
20 changes: 18 additions & 2 deletions apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import {
NAVIGATION_TAB_ICONS,
useEnabledNavigationTabs,
} from "./navigation-tabs";
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
use,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { View, useWindowDimensions, type NativeSyntheticEvent } from "react-native";
import { Tabs, type TabSelectedEvent, type TabSelectionRejectedEvent } from "react-native-screens";

Expand All @@ -16,6 +24,7 @@ import {
} from "./workspace-tabs-state";
import {
NativeWorkspaceTabActivityContext,
NativeWorkspaceModeContext,
WorkspaceSlotOptionsContext,
} from "../../native/v5-workspace-context";
import { useCompactWorkspaceTabs, useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";
Expand Down Expand Up @@ -86,12 +95,18 @@ export function WorkspaceNativeTabs(props: {
const [provenance, setProvenance] = useState(0);
const systemTabBar = useSystemWorkspaceTabBar();
const deviceType = useDeviceType();
const nativeColumns = use(NativeWorkspaceModeContext);
const compactTabs = useCompactWorkspaceTabs();
const { layout, startNewTask } = useAdaptiveWorkspaceLayout();
const compactSidebarTabs = deviceType === "phone" || !layout.usesSplitView;
const { width } = useWindowDimensions();
const { colors } = useControlTheme();
const selectedTab = workspace.state.selectedTab;
// The primary screen carries its picker through UIKit collapse and Back transitions.
const primaryColumnTabs =
deviceType === "pad" &&
nativeColumns &&
workspaceUsesColumns(selectedTab, workspace.settingsOpen);
const selectedSlot = workspace.settingsOpen ? -1 : primaryTabs.indexOf(selectedTab);
const request = useMemo(
() => ({
Expand Down Expand Up @@ -157,7 +172,8 @@ export function WorkspaceNativeTabs(props: {
);
})}
</Tabs.Host>
{(deviceType === "pad" ||
{!primaryColumnTabs &&
(deviceType === "pad" ||
compactTabs ||
(layout.primarySidebarOverlaysContent &&
!workspaceUsesColumns(selectedTab, workspace.settingsOpen))) &&
Expand Down
14 changes: 12 additions & 2 deletions apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ function WorkspaceRootContent() {
const enabled = useEnabledNavigationTabs();
const sidebarTabs = useSidebarWorkspaceTabs();
const sidebarPicker = sidebarTabs === "picker" || (deviceType === "pad" && layout.usesSplitView);
const primaryColumnTabs = deviceType === "pad" && primaryColumn !== null;
const compactPrimaryTabs = primaryColumnTabs && !layout.usesSplitView;
const sidebarNavigation =
primaryColumn !== null &&
(layout.usesSplitView ||
Expand Down Expand Up @@ -215,8 +217,16 @@ function WorkspaceRootContent() {
<WorkspaceExtraTab tab={state.selectedTab} />
)}
</SafeAreaInsetsContext>
{deviceType !== "pad" && deviceType !== "phone" && sidebarPicker ? (
<WorkspaceSidebarTabs />
{primaryColumnTabs || (deviceType !== "pad" && deviceType !== "phone" && sidebarPicker) ? (
<WorkspaceSidebarTabs
compact={compactPrimaryTabs}
reserveComposeSpace={compactPrimaryTabs}
onNewTask={
compactPrimaryTabs && !settingsOpen && state.selectedTab === "threads"
? startNewTask
: undefined
}
/>
) : null}
{/* Android's Settings list carries its own search in its app bar. */}
{enabled.length === 0 && !settingsOpen ? null : (
Expand Down
12 changes: 9 additions & 3 deletions apps/mobile/src/native/createV5StackNavigator.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from "./workspace-stack-projection";

export type V5StackViewProps = ComponentProps<typeof NativeStackView>;
type Route = V5StackViewProps["state"]["routes"][number];

export function modalEnvelopeOptions(options: NativeStackNavigationOptions) {
return {
Expand All @@ -55,7 +56,7 @@ export function modalEnvelopeOptions(options: NativeStackNavigationOptions) {
/** Keep outgoing screens until UIKit completes its pop, as required by v5. */
export function V5CardStackView(
props: V5StackViewProps & {
readonly onRootVisibilityChange?: (visible: boolean) => void;
readonly onRootReturnChange?: (source: Route | null) => void;
/** The card native Back is popping, which the router still shows until the pop finishes. */
readonly onNativePopChange?: (key: string | null) => void;
},
Expand Down Expand Up @@ -179,7 +180,12 @@ export function V5CardStackView(
if (preventedRoutes[route.key]?.preventRemove) descriptor.navigation.goBack();
}}
onWillAppear={() => {
if (index === 0) props.onRootVisibilityChange?.(true);
if (index === 0) {
const source = props.navigation
.getState()
.routes.find((current) => current.key === nativePopSource.current);
props.onRootReturnChange?.(source ?? null);
}
props.navigation.emit({
type: "transitionStart",
target: route.key,
Expand Down Expand Up @@ -217,7 +223,7 @@ export function V5CardStackView(
});
}}
onWillDisappear={() => {
if (index === 0) props.onRootVisibilityChange?.(false);
if (index === 0) props.onRootReturnChange?.(null);
const state = props.navigation.getState();
// Only native Back leaves the disappearing card active; JS navigation already changed history.
if (state.index > 0 && state.routes[state.index]?.key === route.key) {
Expand Down
44 changes: 44 additions & 0 deletions apps/mobile/src/native/workspace-stack-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
nativeWorkspacePopAction,
nativeWorkspacePopCount,
projectWorkspaceStack,
workspaceDetailHidesTabs,
partitionStackPresentations,
reconcileStackScreens,
type CollapsedSplitColumn,
Expand Down Expand Up @@ -85,6 +86,49 @@ describe("workspace router projection", () => {
});
});

describe("compact workspace tab visibility", () => {
it("hides tabs on thread entry before Home's native disappearance arrives", () => {
const primaryVisibleRoute = home;
expect(workspaceDetailHidesTabs([], primaryVisibleRoute)).toBe(false);
const opened = projectWorkspaceStack(history([home, thread]), () => false);
expect(workspaceDetailHidesTabs(opened.detail, primaryVisibleRoute)).toBe(true);
});

it("shows tabs during native Back to Home and hides them when the swipe cancels", () => {
const state = history([home, thread]);
expect(workspaceDetailHidesTabs([thread], thread)).toBe(false);
expect(workspaceDetailHidesTabs([thread], null)).toBe(true);
expect(nativeStackPopAction(state, thread.key, thread.key)).toBeNull();
expect(state.routes).toEqual([home, thread]);
});

it("does not carry native Back visibility into a new route or a same-key selection", () => {
const reopened = { ...thread, key: "reopened-thread" };
const reselected = { ...thread, params: { threadId: "another-thread" } };
expect(workspaceDetailHidesTabs([thread, reopened], thread)).toBe(true);
expect(workspaceDetailHidesTabs([reopened], thread)).toBe(true);
expect(workspaceDetailHidesTabs([reselected], thread)).toBe(true);
});

it("keeps detail pages hidden and returns tabs after the native pop commits", () => {
expect(workspaceDetailHidesTabs([thread, files], null)).toBe(true);
expect(workspaceDetailHidesTabs([thread, files], thread)).toBe(true);
expect(workspaceDetailHidesTabs([thread], null)).toBe(true);
expect(workspaceDetailHidesTabs([], thread)).toBe(false);
});

it("uses the workspace below a modal and hides tabs on a cold-linked detail", () => {
const modal = { key: "connect", name: "ConnectOnboarding" };
const projected = projectWorkspaceStack(
history([home, thread, modal]),
(route) => route.name === modal.name,
);
expect(workspaceDetailHidesTabs(projected.detail, null)).toBe(true);
expect(workspaceDetailHidesTabs(projected.detail, thread)).toBe(false);
expect(workspaceDetailHidesTabs([thread], null)).toBe(true);
});
});

describe("native workspace dismissal", () => {
it("dismisses a sheet and its pushed pages without removing the underlying draft", () => {
const state = history([home, thread, settings, legal]);
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/native/workspace-stack-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export function projectWorkspaceStack(
return { primary, detail, overlays };
}

export function workspaceDetailHidesTabs(detail: readonly Route[], returningFrom: Route | null) {
// Params can select another thread without changing its route key.
return detail.length > 0 && detail.at(-1) !== returningFrom;
}

/** Native callbacks can arrive after a JS pop or replace; those must not pop the next screen. */
export function nativeWorkspacePopCount(
state: Pick<NavigationState, "index" | "routes">,
Expand Down
Loading