From 462b8bb013f3c8d686f4d1900b0df1d63280ce0f Mon Sep 17 00:00:00 2001 From: ChengShuai Date: Fri, 6 Nov 2026 15:22:02 +0900 Subject: [PATCH 1/4] fix(mobile): keep legacy iPad navigation in its sidebar --- .../layout/AdaptiveWorkspaceLayout.tsx | 20 +++++++++++++++---- .../createWorkspaceStackNavigator.ios.tsx | 19 +++++++++++++----- .../workspace/WorkspaceNativeTabs.ios.tsx | 16 ++++++++++++--- .../workspace/WorkspaceSidebarColumn.tsx | 9 +++++++++ .../workspace-tab-navigation.test.ts | 12 +++++++++++ .../workspace/workspace-tab-navigation.ts | 5 +++++ .../workspace/workspace-tabs-state.test.ts | 8 ++++++++ .../layout/workspace/workspace-tabs-state.ts | 11 ++++++++-- .../threads/ThreadNavigationSidebar.tsx | 14 ++++++++++++- 9 files changed, 99 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index bf8b3ad9b08d..da572d7bc714 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -113,7 +113,7 @@ import { workspaceTabColumns, workspaceTabNavigationState, } from "./workspace/workspace-tab-navigation"; -import type { WorkspaceTab } from "./workspace/workspace-tabs-state"; +import { workspaceUsesColumns, type WorkspaceTab } from "./workspace/workspace-tabs-state"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; import { WorkspaceThreadSidebar } from "./workspace/WorkspaceThreadSidebar"; @@ -236,11 +236,18 @@ function AdaptiveWorkspaceLayoutContent( const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; const pathname = props.pathname; const workspace = useWorkspaceTabs(); - // Settings and Usage occupy the workspace rather than a list/detail pair. + // Settings and Usage occupy the workspace; the legacy iPad sidebar used to stay over those pages. + // + // 设置和用量页面占用整个工作区,修正兼容 iPad 侧栏此前仍覆盖这些页面的问题。 const androidSettingsOpen = useAndroidWorkspaceRoot()?.settingsOpen ?? false; const safeAreaInsets = useSafeAreaInsets(); const workspacePage = - Platform.OS === "android" && (androidSettingsOpen || workspace?.state.selectedTab === "usage"); + (Platform.OS === "android" && + (androidSettingsOpen || workspace?.state.selectedTab === "usage")) || + (Platform.OS === "ios" && + !nativeWorkspace && + workspace !== null && + !workspaceUsesColumns(workspace.state.selectedTab, workspace.settingsOpen, nativeWorkspace)); const navigation = useNavigation(); const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); @@ -587,7 +594,12 @@ function AdaptiveWorkspaceLayoutContent( tab, currentWorkspaceSelections(), { - columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), + columns: workspaceTabColumns( + Platform.OS, + tab, + options?.columns ?? layout.usesSplitView, + nativeWorkspace, + ), check: workspaceSelectionCheck, overlays, seedPullRequest: withKnownPullRequestSummary, diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 14ffb11abf22..cf884db23aee 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -523,7 +523,11 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean const descriptors = props.descriptors; const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; const visibleRoute = projection.detail.at(-1) ?? primaryRoute; - const workspacePage = !workspaceUsesColumns(workspace.state.selectedTab, workspace.settingsOpen); + const workspacePage = !workspaceUsesColumns( + workspace.state.selectedTab, + workspace.settingsOpen, + props.nativeColumns, + ); const baseDescriptor: Descriptor = { ...primaryDescriptor, navigation: props.descriptors[visibleRoute.key]!.navigation, @@ -650,8 +654,13 @@ function WorkspaceStackNavigator({ ); return ( - - + {/* The fallback sidebar is rendered by NavigationContent's layout, outside its children. + Share tab state above that layout so sidebar actions and detail tabs use one owner. + + 兼容侧栏由 NavigationContent 的布局绘制,位于其 children 外部; + 将标签状态提升到布局之上,使侧栏入口与详情导航共用同一份状态。 */} + + - - + + ); } diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx index 2ede0bcddee4..ffb1670ed49a 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx @@ -3,7 +3,7 @@ import { NAVIGATION_TAB_ICONS, useEnabledNavigationTabs, } from "./navigation-tabs"; -import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { use, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { View, useWindowDimensions, type NativeSyntheticEvent } from "react-native"; import { Tabs, type TabSelectedEvent, type TabSelectionRejectedEvent } from "react-native-screens"; @@ -16,6 +16,7 @@ import { } from "./workspace-tabs-state"; import { NativeWorkspaceTabActivityContext, + NativeWorkspaceModeContext, WorkspaceSlotOptionsContext, } from "../../../native/v5-workspace-context"; import { @@ -83,6 +84,7 @@ export function WorkspaceNativeTabs(props: { const selectTab = useSelectWorkspaceTab(); const [provenance, setProvenance] = useState(0); const systemTabBar = useSystemWorkspaceTabBar(); + const nativeColumns = use(NativeWorkspaceModeContext); const deviceType = useDeviceType(); const compactTabs = useCompactWorkspaceTabs(); const { layout, panes } = useAdaptiveWorkspaceLayout(); @@ -90,7 +92,7 @@ export function WorkspaceNativeTabs(props: { const hiddenWithSidebar = deviceType === "pad" && layout.usesSplitView && - workspaceUsesColumns(workspace.state.selectedTab, workspace.settingsOpen) && + workspaceUsesColumns(workspace.state.selectedTab, workspace.settingsOpen, nativeColumns) && !panes.primarySidebarVisible; const floatingTabs = useFloatingWorkspaceTabs(); const { width } = useWindowDimensions(); @@ -160,9 +162,17 @@ export function WorkspaceNativeTabs(props: { {(deviceType === "pad" || compactTabs || (layout.variant === "medium" && - !workspaceUsesColumns(selectedTab, workspace.settingsOpen))) && + !workspaceUsesColumns(selectedTab, workspace.settingsOpen, nativeColumns))) && !props.hidden && !props.tabsInSidebarColumn && + // The legacy split sidebar owns its own tabs; this host sits in the detail column. + // + // 兼容分栏的侧栏自行绘制导航,此容器位于右侧详情,不能重复绘制。 + !( + !nativeColumns && + layout.usesSplitView && + workspaceUsesColumns(selectedTab, workspace.settingsOpen, nativeColumns) + ) && !hiddenWithSidebar ? ( {props.children} + {/* Keep legacy iPad navigation in its sidebar; the detail host previously covered the composer. + + 旧版 iPad 的导航跟随侧栏,避免此前挂在详情容器上遮挡输入框。 */} + {deviceType === "pad" && workspace !== null ? : null} ); } diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts index 3f217fd70c8b..4ee593240f29 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts @@ -51,6 +51,18 @@ describe("workspace tab selections", () => { describe("opening a workspace tab", () => { const columns = { columns: true, check: available }; + it("opens the Settings list on iPads without native columns", () => { + const next = workspaceTabNavigationState( + { routes: [home, thread] }, + "settings", + { settings: "SettingsAppearance" }, + { columns: workspaceTabColumns("ios", "settings", true, false), check: available }, + ); + expect(names(next)).toEqual(["Home", "Settings"]); + expect(workspaceTabColumns("ios", "threads", true, false)).toBe(true); + expect(workspaceTabColumns("ios", "settings", true, true)).toBe(true); + }); + it("opens Android Settings beside its default page only in a wide window", () => { const open = (wide: boolean) => workspaceTabNavigationState( diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts index 6a44e617e53e..ca7537131253 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts @@ -21,7 +21,12 @@ export function workspaceTabColumns( platform: string, tab: WorkspaceTab | "settings", columns: boolean, + nativeColumns = true, ) { + // The fallback iPad sidebar only hosts chats; opening a Settings detail used to skip its list. + // + // 兼容分栏的侧栏只承载聊天;此前直接打开设置子页会跳过设置列表,导致入口不可用。 + if (platform === "ios" && tab === "settings" && !nativeColumns) return false; return columns && (platform === "ios" || (platform === "android" && tab === "settings")); } diff --git a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts index f1c839630eb3..1ef11ef10f2c 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts @@ -53,6 +53,14 @@ describe("Duo list clearance", () => { }); describe("workspace root navigation", () => { + it("keeps legacy iPad Settings full-width and native iPad Settings in columns", () => { + expect(workspaceUsesColumns("threads", true, false)).toBe(false); + expect(workspaceUsesColumns("threads", true, true)).toBe(true); + expect(workspaceUsesColumns("usage", true, false)).toBe(false); + expect(workspaceUsesColumns("usage", true, true)).toBe(true); + expect(workspaceUsesColumns("threads", false, false)).toBe(true); + expect(workspaceUsesColumns("usage", false, false)).toBe(false); + }); it("pairs Settings, chats and pull requests with a detail column, and keeps Usage one page", () => { expect(workspaceUsesColumns("usage")).toBe(false); expect(workspaceUsesColumns("usage", true)).toBe(true); diff --git a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts index 3598894115dc..3f0558829d37 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts @@ -17,8 +17,15 @@ export type WorkspaceSearchTab = WorkspaceTab | "settings"; export type WorkspaceSearchMode = "resting" | "active" | "expanded"; /** Settings, Chats and pull requests pair a list with a detail column; Usage is one page. */ -export function workspaceUsesColumns(tab: WorkspaceTab, settingsOpen = false) { - return settingsOpen || tab === "threads" || tab === "pullRequests"; +export function workspaceUsesColumns( + tab: WorkspaceTab, + settingsOpen = false, + nativeColumns = true, +) { + // Legacy sidebars host chats only; Settings needs the whole page to show its list and navigation. + // + // 兼容侧栏只承载聊天,设置需占用整页,才能显示设置列表及导航入口。 + return settingsOpen ? nativeColumns : tab === "threads" || tab === "pullRequests"; } export interface WorkspaceTabState { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index dce389c83666..6449370adcf9 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -49,6 +49,8 @@ import { useThreadListActions } from "../home/useThreadListActions"; import { useFabScroll } from "../home/FabScrollContext"; import { WorkspaceListHeader } from "../layout/workspace/WorkspaceListHeader"; import { WORKSPACE_LIST_SCROLL_INSETS } from "../layout/workspace/workspace-list-insets"; +import { useDeviceType } from "../../native/native-layout-metrics"; +import { workspaceDockBottomClearance } from "../layout/workspace/workspace-tabs-state"; import { WorkspaceSidebarColumn } from "../layout/workspace/WorkspaceSidebarColumn"; import { ThreadListV2PendingRow, @@ -111,6 +113,7 @@ export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) { function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { const floatingTabsEnabled = useAndroidFloatingTabs(); + const deviceType = useDeviceType(); const { themeVariables: materialTheme } = useAppearancePreferences(); const drawerColor = materialTheme["--color-drawer"]; @@ -596,7 +599,16 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { contentContainerStyle={[ styles.threadListContent, { - paddingBottom: Math.max(insets.bottom, 16) + 16, + // The iPad dock now belongs to this column; its last row must scroll above it. + // + // iPad 导航现归属此侧栏,末行需能滚动到导航上方,避免底部继续被遮挡。 + paddingBottom: + deviceType === "pad" + ? workspaceDockBottomClearance({ + bottomInset: insets.bottom, + keyboardHeight: 0, + }) + : Math.max(insets.bottom, 16) + 16, paddingTop: 6, }, ]} From 5d46a050cdd1ea35bc4acabc5001d8af46d4dd4f Mon Sep 17 00:00:00 2001 From: ChengShuai Date: Fri, 6 Nov 2026 15:22:22 +0900 Subject: [PATCH 2/4] fix(mobile): keep sidebar rows below native search --- apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 6449370adcf9..ddc92bf4db97 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -596,6 +596,10 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { keyExtractor={(item) => item.key} renderItem={renderListItem} {...WORKSPACE_LIST_SCROLL_INSETS} + // A stacked native search bar can overlap the list on iOS 18 even with a solid header. + // + // iOS 18 的原生搜索栏即使搭配实色标题栏也会覆盖列表,交由系统计算实际避让。 + contentInsetAdjustmentBehavior="automatic" contentContainerStyle={[ styles.threadListContent, { From 72aa9cbb3138a157e7fb7990ecdbbd6c6e0f824f Mon Sep 17 00:00:00 2001 From: ChengShuai Date: Mon, 9 Nov 2026 18:02:54 +0900 Subject: [PATCH 3/4] fix(mobile): keep legacy iPad tabs in a stable split layout --- .../layout/AdaptiveWorkspaceLayout.tsx | 42 +++++++++++-------- .../createWorkspaceStackNavigator.ios.tsx | 1 + .../workspace/WorkspaceNativeTabs.ios.tsx | 7 +++- .../workspace-tab-navigation.test.ts | 10 ++--- .../workspace/workspace-tab-navigation.ts | 6 +-- .../workspace/workspace-tabs-state.test.ts | 10 +++-- .../layout/workspace/workspace-tabs-state.ts | 14 +++++-- .../features/settings/SettingsRouteScreen.tsx | 36 ++++++++++++++-- .../settings/components/SettingsScreen.tsx | 8 +++- .../features/settings/useSettingsPageBack.ts | 16 +++---- 10 files changed, 103 insertions(+), 47 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index da572d7bc714..d175bdbe2160 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -117,6 +117,7 @@ import { workspaceUsesColumns, type WorkspaceTab } from "./workspace/workspace-t import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; import { WorkspaceThreadSidebar } from "./workspace/WorkspaceThreadSidebar"; +import { WorkspaceSettingsSidebar } from "../settings/SettingsRouteScreen"; export { useAdaptiveWorkspaceLayout }; @@ -236,18 +237,11 @@ function AdaptiveWorkspaceLayoutContent( const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; const pathname = props.pathname; const workspace = useWorkspaceTabs(); - // Settings and Usage occupy the workspace; the legacy iPad sidebar used to stay over those pages. + // Android workspace pages fill the viewport; legacy iPad pages retain their sidebar navigation. // - // 设置和用量页面占用整个工作区,修正兼容 iPad 侧栏此前仍覆盖这些页面的问题。 + // Android 工作区页面铺满窗口;旧版 iPad 保留侧栏导航,避免全屏内容与局部导航错位。 const androidSettingsOpen = useAndroidWorkspaceRoot()?.settingsOpen ?? false; const safeAreaInsets = useSafeAreaInsets(); - const workspacePage = - (Platform.OS === "android" && - (androidSettingsOpen || workspace?.state.selectedTab === "usage")) || - (Platform.OS === "ios" && - !nativeWorkspace && - workspace !== null && - !workspaceUsesColumns(workspace.state.selectedTab, workspace.settingsOpen, nativeWorkspace)); const navigation = useNavigation(); const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); @@ -273,6 +267,18 @@ function AdaptiveWorkspaceLayoutContent( () => deriveLayout({ width: viewportWidth, height, nativeMetrics, nativeWorkspace }), [height, viewportWidth, nativeMetrics, nativeWorkspace], ); + const workspacePage = + (Platform.OS === "android" && + (androidSettingsOpen || workspace?.state.selectedTab === "usage")) || + (Platform.OS === "ios" && + !nativeWorkspace && + workspace !== null && + !workspaceUsesColumns( + workspace.state.selectedTab, + workspace.settingsOpen, + nativeWorkspace, + baseLayout.usesSplitView, + )); // The overlay sidebar closes when a page opens from it, and stays open on a list or on the // page a tab switch opened (`keepOpenFor`, set by that switch). const [overlaySidebar, setOverlaySidebar] = useState({ @@ -594,12 +600,7 @@ function AdaptiveWorkspaceLayoutContent( tab, currentWorkspaceSelections(), { - columns: workspaceTabColumns( - Platform.OS, - tab, - options?.columns ?? layout.usesSplitView, - nativeWorkspace, - ), + columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), check: workspaceSelectionCheck, overlays, seedPullRequest: withKnownPullRequestSummary, @@ -1100,8 +1101,15 @@ function AdaptiveWorkspaceLayoutContent( /> )} > - {/* Without a sidebar route (iPadOS before 26) the columns draw the thread list. */} - {sidebar ?? } + {/* The legacy sidebar owns its list while the root navigator owns detail pages. + + 兼容侧栏承载目录,详情仍由根导航管理,切换设置时不再隐藏整列。 */} + {sidebar ?? + (Platform.OS === "ios" && workspace?.settingsOpen ? ( + + ) : ( + + ))} {/* Keep the pill under the content throughout the sidebar's clipping animation. */} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index cf884db23aee..adb7fac56beb 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -527,6 +527,7 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean workspace.state.selectedTab, workspace.settingsOpen, props.nativeColumns, + layout.usesSplitView, ); const baseDescriptor: Descriptor = { ...primaryDescriptor, diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx index ffb1670ed49a..3dca1a37ace2 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx @@ -92,7 +92,12 @@ export function WorkspaceNativeTabs(props: { const hiddenWithSidebar = deviceType === "pad" && layout.usesSplitView && - workspaceUsesColumns(workspace.state.selectedTab, workspace.settingsOpen, nativeColumns) && + workspaceUsesColumns( + workspace.state.selectedTab, + workspace.settingsOpen, + nativeColumns, + layout.usesSplitView, + ) && !panes.primarySidebarVisible; const floatingTabs = useFloatingWorkspaceTabs(); const { width } = useWindowDimensions(); diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts index 4ee593240f29..fea6688a690b 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts @@ -51,16 +51,16 @@ describe("workspace tab selections", () => { describe("opening a workspace tab", () => { const columns = { columns: true, check: available }; - it("opens the Settings list on iPads without native columns", () => { + it("opens the remembered Settings detail beside its list on legacy iPads", () => { const next = workspaceTabNavigationState( { routes: [home, thread] }, "settings", { settings: "SettingsAppearance" }, - { columns: workspaceTabColumns("ios", "settings", true, false), check: available }, + { columns: workspaceTabColumns("ios", "settings", true), check: available }, ); - expect(names(next)).toEqual(["Home", "Settings"]); - expect(workspaceTabColumns("ios", "threads", true, false)).toBe(true); - expect(workspaceTabColumns("ios", "settings", true, true)).toBe(true); + expect(names(next)).toEqual(["Home", "Settings", "SettingsAppearance"]); + expect(workspaceTabColumns("ios", "threads", true)).toBe(true); + expect(workspaceTabColumns("ios", "settings", false)).toBe(false); }); it("opens Android Settings beside its default page only in a wide window", () => { diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts index ca7537131253..082fbb50a700 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts @@ -21,12 +21,10 @@ export function workspaceTabColumns( platform: string, tab: WorkspaceTab | "settings", columns: boolean, - nativeColumns = true, ) { - // The fallback iPad sidebar only hosts chats; opening a Settings detail used to skip its list. + // Both iPad column implementations host Settings lists, so restore the detail beside either one. // - // 兼容分栏的侧栏只承载聊天;此前直接打开设置子页会跳过设置列表,导致入口不可用。 - if (platform === "ios" && tab === "settings" && !nativeColumns) return false; + // 两种 iPad 分栏都承载设置目录,恢复右侧设置页,避免兼容布局只显示全屏目录。 return columns && (platform === "ios" || (platform === "android" && tab === "settings")); } diff --git a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts index 1ef11ef10f2c..c1e10fcd72e3 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts @@ -53,13 +53,15 @@ describe("Duo list clearance", () => { }); describe("workspace root navigation", () => { - it("keeps legacy iPad Settings full-width and native iPad Settings in columns", () => { - expect(workspaceUsesColumns("threads", true, false)).toBe(false); + it("keeps every legacy iPad tab in columns while retaining native Usage layout", () => { + expect(workspaceUsesColumns("threads", true, false)).toBe(true); expect(workspaceUsesColumns("threads", true, true)).toBe(true); - expect(workspaceUsesColumns("usage", true, false)).toBe(false); + expect(workspaceUsesColumns("usage", true, false)).toBe(true); expect(workspaceUsesColumns("usage", true, true)).toBe(true); expect(workspaceUsesColumns("threads", false, false)).toBe(true); - expect(workspaceUsesColumns("usage", false, false)).toBe(false); + expect(workspaceUsesColumns("usage", false, false)).toBe(true); + expect(workspaceUsesColumns("usage", false, false, false)).toBe(false); + expect(workspaceUsesColumns("threads", true, false, false)).toBe(false); }); it("pairs Settings, chats and pull requests with a detail column, and keeps Usage one page", () => { expect(workspaceUsesColumns("usage")).toBe(false); diff --git a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts index 3f0558829d37..5a9c188443d5 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts @@ -16,16 +16,22 @@ export type WorkspaceTab = NavigationTab; export type WorkspaceSearchTab = WorkspaceTab | "settings"; export type WorkspaceSearchMode = "resting" | "active" | "expanded"; -/** Settings, Chats and pull requests pair a list with a detail column; Usage is one page. */ +/** Native Usage is one page; the legacy workspace keeps its sidebar for every tab. + * + * 原生分栏的用量为单页,兼容工作区的每个标签均保留侧栏。 + */ export function workspaceUsesColumns( tab: WorkspaceTab, settingsOpen = false, nativeColumns = true, + splitView = true, ) { - // Legacy sidebars host chats only; Settings needs the whole page to show its list and navigation. + // Legacy iPad tabs retain their sidebar; full-page Settings and Usage stranded its navigation. // - // 兼容侧栏只承载聊天,设置需占用整页,才能显示设置列表及导航入口。 - return settingsOpen ? nativeColumns : tab === "threads" || tab === "pullRequests"; + // 旧版 iPad 各标签保留侧栏,避免设置和用量铺满页面后导航仍滞留在左下角。 + return settingsOpen + ? nativeColumns || splitView + : (!nativeColumns && splitView) || tab === "threads" || tab === "pullRequests"; } export interface WorkspaceTabState { diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index a0e24692e1e8..3e7701ae6aef 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -15,8 +15,13 @@ import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { EmptyState } from "../../components/ui/EmptyState"; import { useAuth, useUser } from "@clerk/expo"; -import { useNavigation, useNavigationState } from "@react-navigation/native"; -import { use, type ComponentProps } from "react"; +import { + NavigationContext, + NavigationRouteContext, + useNavigation, + useNavigationState, +} from "@react-navigation/native"; +import { use, type ComponentProps, type ReactNode } from "react"; import { Platform, View } from "react-native"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; @@ -33,6 +38,30 @@ import { useSettingsEnvironmentMenu } from "./components/SettingsEnvironmentFilt import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { filterSettingsSections } from "./settings-search"; import { usePaneRole } from "../../components/ui/Pane"; +import { WorkspaceSidebarColumn } from "../layout/workspace/WorkspaceSidebarColumn"; + +export function WorkspaceSettingsSidebar(props: { readonly width: number }) { + const navigation = use(NavigationContext); + const route = useNavigationState((state) => + state.routes.find((route) => route.name === "Settings"), + ); + const list = useSettingsListPlacement(); + if (!route) return null; + return ( + + + {/* The column stack owns search; list actions must reach the workspace's detail stack. + + 列内导航负责原生搜索,目录操作回到工作区根导航,避免将详情推入独立侧栏。 */} + + + + + + + + ); +} export function SettingsRouteScreen() { const list = useSettingsListPlacement(); @@ -72,6 +101,7 @@ function SettingsListContent() { export function SettingsListScreen(props: { readonly sidebar: boolean; readonly selected: string | null; + readonly children?: ReactNode; }) { const workspace = useWorkspaceTabs(); const environmentMenu = useSettingsEnvironmentMenu(); @@ -93,7 +123,7 @@ export function SettingsListScreen(props: { : undefined } > - + {props.children ?? } diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index 240ac009bdc5..2f74d0250e60 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -45,8 +45,12 @@ export function SettingsScreen( search={props.search} trailing={props.trailing} menus={props.menus} - onBack={workspaceRoot ? undefined : back} - guardBack={props.guardBack} + // Hide system Back as well as the callback, so the first detail cannot reveal a second list. + // + // 首个详情页同时隐藏系统返回并禁用回滑,避免右列退回后重复显示设置目录。 + onBack={workspaceRoot || back.columnRoot ? undefined : back.onBack} + onBackToList={!workspaceRoot && back.columnRoot ? back.onBack : undefined} + guardBack={props.guardBack || back.columnRoot} hideBottomBorder /> diff --git a/apps/mobile/src/features/settings/useSettingsPageBack.ts b/apps/mobile/src/features/settings/useSettingsPageBack.ts index 6ce909183410..e8df55db4809 100644 --- a/apps/mobile/src/features/settings/useSettingsPageBack.ts +++ b/apps/mobile/src/features/settings/useSettingsPageBack.ts @@ -5,9 +5,9 @@ import { resetAndroidWorkspace } from "../../lib/android-workspace-navigation"; import { useHardwareBack } from "../../lib/backHandler"; import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; -/** - * The page's Back action, or undefined for the first page beside the Settings list on wide - * Android: that page roots the detail column, so Back leaves Settings rather than emptying it. +/** The first Settings detail roots its column; nested pages can navigate back within it. + * + * 设置首个详情页为右列根页,后续子页才可在列内返回。 */ export function useSettingsPageBack() { const navigation = useNavigation(); @@ -15,11 +15,13 @@ export function useSettingsPageBack() { const { layout } = useAdaptiveWorkspaceLayout(); const routes = navigation.getState()?.routes ?? []; const below = routes[routes.findIndex((candidate) => candidate.key === route.key) - 1]; - const columnRoot = - Platform.OS === "android" && layout.usesSplitView && below?.name === "Settings"; - useHardwareBack(columnRoot, () => { + // The first detail page roots either split layout; Back must not duplicate the sidebar list. + // + // 两种分栏的首个设置页均为详情根页,避免返回后在右列重复显示左侧目录。 + const columnRoot = layout.usesSplitView && below?.name === "Settings"; + useHardwareBack(Platform.OS === "android" && columnRoot, () => { resetAndroidWorkspace(navigation); return true; }); - return columnRoot ? undefined : () => navigation.goBack(); + return { columnRoot, onBack: () => navigation.goBack() }; } From 59229e6bd5eab5a106561ea2e48799b2d87c1fe2 Mon Sep 17 00:00:00 2001 From: ChengShuai Date: Mon, 9 Nov 2026 18:18:39 +0900 Subject: [PATCH 4/4] fix(mobile): route iPad settings through the workspace container --- apps/mobile/src/components/ui/AppBar.tsx | 2 +- .../layout/AdaptiveWorkspaceLayout.tsx | 2 +- .../features/settings/SettingsRouteScreen.tsx | 33 +--- .../WorkspaceSettingsSidebar.test.tsx | 145 ++++++++++++++++++ .../settings/WorkspaceSettingsSidebar.tsx | 35 +++++ apps/mobile/vite.config.ts | 1 + 6 files changed, 185 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/src/features/settings/WorkspaceSettingsSidebar.test.tsx create mode 100644 apps/mobile/src/features/settings/WorkspaceSettingsSidebar.tsx diff --git a/apps/mobile/src/components/ui/AppBar.tsx b/apps/mobile/src/components/ui/AppBar.tsx index d837ed32e1ba..7c5584f85526 100644 --- a/apps/mobile/src/components/ui/AppBar.tsx +++ b/apps/mobile/src/components/ui/AppBar.tsx @@ -10,7 +10,7 @@ import { NativeStackScreenOptions, useScreenOptionsWriter } from "../../native/S import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns"; import { useDeviceType } from "../../native/native-layout-metrics"; import { isDuoDevice } from "../../lib/device-type"; -import { useAdaptiveWorkspaceLayout } from "../../features/layout/AdaptiveWorkspaceLayout"; +import { useAdaptiveWorkspaceLayout } from "../../features/layout/adaptive-workspace-context"; import { useSidebarToggle } from "../../features/layout/useSidebarToggle"; import { InspectorCloseContext } from "../../features/layout/inspector-bar-store"; import { diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index d175bdbe2160..848fdee8f3bc 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -117,7 +117,7 @@ import { workspaceUsesColumns, type WorkspaceTab } from "./workspace/workspace-t import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; import { WorkspaceThreadSidebar } from "./workspace/WorkspaceThreadSidebar"; -import { WorkspaceSettingsSidebar } from "../settings/SettingsRouteScreen"; +import { WorkspaceSettingsSidebar } from "../settings/WorkspaceSettingsSidebar"; export { useAdaptiveWorkspaceLayout }; diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 3e7701ae6aef..cf7b66528217 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -15,12 +15,7 @@ import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { EmptyState } from "../../components/ui/EmptyState"; import { useAuth, useUser } from "@clerk/expo"; -import { - NavigationContext, - NavigationRouteContext, - useNavigation, - useNavigationState, -} from "@react-navigation/native"; +import { useNavigation, useNavigationState } from "@react-navigation/native"; import { use, type ComponentProps, type ReactNode } from "react"; import { Platform, View } from "react-native"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; @@ -38,30 +33,6 @@ import { useSettingsEnvironmentMenu } from "./components/SettingsEnvironmentFilt import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { filterSettingsSections } from "./settings-search"; import { usePaneRole } from "../../components/ui/Pane"; -import { WorkspaceSidebarColumn } from "../layout/workspace/WorkspaceSidebarColumn"; - -export function WorkspaceSettingsSidebar(props: { readonly width: number }) { - const navigation = use(NavigationContext); - const route = useNavigationState((state) => - state.routes.find((route) => route.name === "Settings"), - ); - const list = useSettingsListPlacement(); - if (!route) return null; - return ( - - - {/* The column stack owns search; list actions must reach the workspace's detail stack. - - 列内导航负责原生搜索,目录操作回到工作区根导航,避免将详情推入独立侧栏。 */} - - - - - - - - ); -} export function SettingsRouteScreen() { const list = useSettingsListPlacement(); @@ -94,7 +65,7 @@ function useSettingsListPlacement() { return { sidebar, selected }; } -function SettingsListContent() { +export function SettingsListContent() { return hasCloudPublicConfig() ? : ; } diff --git a/apps/mobile/src/features/settings/WorkspaceSettingsSidebar.test.tsx b/apps/mobile/src/features/settings/WorkspaceSettingsSidebar.test.tsx new file mode 100644 index 000000000000..3efb2b040c1e --- /dev/null +++ b/apps/mobile/src/features/settings/WorkspaceSettingsSidebar.test.tsx @@ -0,0 +1,145 @@ +import { act, type ContextType, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { + NavigationContainerRefContext, + NavigationContext, + type NavigationState, +} from "@react-navigation/native"; + +vi.mock("@react-navigation/native", async () => { + // Resolve native's own core so the test exercises its real navigation-context fallback. + // + // 使用 native 实际依赖的 core,验证真实的导航上下文回退行为。 + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const core = createRequire(require.resolve("@react-navigation/native")).resolve( + "@react-navigation/core", + ); + const actual = await import(core); + return { + ...actual, + useNavigationState: (select: (state: NavigationState) => unknown) => + select(actual.useNavigation().getState()), + }; +}); +vi.mock("react-native", () => ({ + Platform: { + OS: "ios", + select: (values: { ios?: unknown; default?: unknown }) => values.ios ?? values.default, + }, + I18nManager: { isRTL: false }, + Linking: {}, +})); +vi.mock("../../components/ui/ListRow", () => ({ + ListRow: ({ label, onPress }: { label: string; onPress: () => void }) => ( + + ), +})); +vi.mock("../../components/ui/ListRowLayout", () => ({ ListRowLayout: () => null })); +vi.mock("../../components/ui/RowPressable", () => ({ RowPressable: () => null })); +vi.mock("./components/SettingsSection", async () => { + const { createContext } = await import("react"); + return { SettingsSidebarContext: createContext(null) }; +}); +vi.mock("./SettingsRouteScreen", async () => { + const { SettingsRow } = await import("./components/SettingsRow"); + return { + SettingsListScreen: ({ children }: { children: ReactNode }) => children, + SettingsListContent: () => ( + + ), + }; +}); +vi.mock("../layout/workspace/WorkspaceSidebarColumn", async () => { + const { NavigationContainerRefContext, NavigationContext } = + await import("@react-navigation/native"); + return { + WorkspaceSidebarColumn: ({ children }: { children: ReactNode }) => { + const standalone = { + getState: () => ({ + key: "standalone", + routes: [{ key: "standalone-route", name: "Standalone" }], + index: 0, + }), + getParent: () => undefined, + dispatch: () => {}, + }; + return ( + } + > + {children} + + ); + }, + }; +}); + +import { WorkspaceSettingsSidebar } from "./WorkspaceSettingsSidebar"; + +describe("legacy Settings sidebar navigation", () => { + it.each([false, true])( + "opens General in the workspace with a screen navigation context of %s", + (screenContext) => { + const state = { + key: "workspace", + type: "stack", + stale: false, + index: 2, + routeNames: ["Home", "Settings", "SettingsEnvironments", "SettingsOrganization"], + routes: [ + { key: "home", name: "Home" }, + { key: "settings", name: "Settings" }, + { key: "environments", name: "SettingsEnvironments" }, + ], + }; + const dispatch = vi.fn(); + const navigation = { + getState: () => state, + getRootState: () => state, + getParent: () => undefined, + addListener: () => () => {}, + dispatch, + }; + const container = document.createElement("div"); + const root = createRoot(container); + try { + act(() => + root.render( + } + > + ) + : undefined + } + > + + + , + ), + ); + act(() => container.querySelector("button")!.click()); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "RESET", + target: "workspace", + payload: expect.objectContaining({ + routeNames: state.routeNames, + routes: [ + state.routes[0], + state.routes[1], + { name: "SettingsOrganization", params: undefined }, + ], + }), + }), + ); + } finally { + act(() => root.unmount()); + } + }, + ); +}); diff --git a/apps/mobile/src/features/settings/WorkspaceSettingsSidebar.tsx b/apps/mobile/src/features/settings/WorkspaceSettingsSidebar.tsx new file mode 100644 index 000000000000..8d830965c428 --- /dev/null +++ b/apps/mobile/src/features/settings/WorkspaceSettingsSidebar.tsx @@ -0,0 +1,35 @@ +import { use } from "react"; +import { + NavigationContainerRefContext, + NavigationContext, + NavigationRouteContext, + useNavigationState, +} from "@react-navigation/native"; + +import { WorkspaceSidebarColumn } from "../layout/workspace/WorkspaceSidebarColumn"; +import { SettingsListContent, SettingsListScreen } from "./SettingsRouteScreen"; + +export function WorkspaceSettingsSidebar(props: { readonly width: number }) { + const navigation = use(NavigationContext); + const container = use(NavigationContainerRefContext); + const state = useNavigationState((state) => state); + const index = state.routes.findIndex((route) => route.name === "Settings"); + const route = state.routes[index]; + if (!route) return null; + return ( + + + {/* Root layouts can lack a screen navigation object; preserve the container fallback too. + + 根布局可能没有页面导航对象;同时恢复容器回退,避免目录操作落到独立侧栏。 */} + + + + + + + + + + ); +} diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 5aab6e4a1cd3..6d59494bffb8 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig, normalizePath } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/settings/WorkspaceSettingsSidebar.test.tsx", "src/features/threads/git/GitBranchesSheet.test.tsx", "src/features/threads/new-task-flow-provider.test.tsx", "src/features/layout/workspace/workspace-tabs-state.test.ts",