diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 6b6a5ec3..4ecf947c 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -84,8 +84,14 @@ const MOBILE_SETTINGS_PAGES = [ 'sessions', 'git', 'magic-prompts', + 'snippets', 'behavior', + 'agents', + 'commands', 'mcp', + 'plugins', + 'skills.installed', + 'skills.catalog', 'providers', 'usage', 'voice', @@ -296,6 +302,13 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc onRightEdgeSwipe: () => setWorkspaceOpen(true), }); + // Settings owns a drill-down of its own (nav → page list → item), so the + // hardware back button asks it to step up before the shell closes it. + const settingsBackRef = React.useRef<(() => boolean) | null>(null); + const registerSettingsBackHandler = React.useCallback((handler: (() => boolean) | null) => { + settingsBackRef.current = handler; + }, []); + // Top-most layer first: a plan or fullscreen surface can sit ABOVE a drawer // (opened from the drawer footer / workspace tabs), so they close before the // drawers underneath. @@ -304,6 +317,9 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc setOpenPlan(null); return true; } + if (activeSurface === 'settings' && settingsBackRef.current?.()) { + return true; + } if (activeSurface) { closeSurface(); return true; @@ -590,6 +606,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc forceMobile isWindowed initialMobileStage={settingsInitialMobileStage} + registerBackHandler={registerSettingsBackHandler} // About exists for server updates — meaningful in a browser // (hosted mobile), not in the Capacitor shell (store updates). visiblePageSlugs={MOBILE_SETTINGS_PAGES.filter( diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index db9e80c9..c3e7da5e 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -89,6 +89,9 @@ interface SettingsViewProps { isWindowed?: boolean; /** Restrict top-level settings navigation to a specific product surface. */ visiblePageSlugs?: SettingsPageSlug[]; + /** Lets a native shell hand its hardware back button to the mobile stages: + the handler steps one level up and reports whether it consumed the press. */ + registerBackHandler?: (handler: (() => boolean) | null) => void; initialMobileStage?: MobileStage; } @@ -183,7 +186,7 @@ function getCurrentHistoryState(): Record { } -export const SettingsView: React.FC = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => { +export const SettingsView: React.FC = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav', registerBackHandler }) => { const { t } = useI18n(); const deviceInfo = useDeviceInfo(); const isMobile = forceMobile ?? deviceInfo.isMobile; @@ -703,10 +706,12 @@ export const SettingsView: React.FC = ({ onClose, forceMobile }, [isMobile, mobileStage, settingsSlug]); const showBackButton = isMobile && mobileStage !== 'nav'; - const backButtonTargetsPageSidebar = isMobile && mobileStage === 'page-content' && settingsSlug === 'skills.installed'; - const showOpenPageSidebarButton = mobileStage === 'page-content' - && activePageMeta?.kind === 'split' - && !backButtonTargetsPageSidebar; + // Split pages drill down on mobile: nav → the page's own list → the item. + // Back walks that path in reverse, so it takes one tap to reach the next + // item instead of a round trip through the settings root. + const backButtonTargetsPageSidebar = isMobile + && mobileStage === 'page-content' + && activePageMeta?.kind === 'split'; const mobileBackButtonLabel = backButtonTargetsPageSidebar ? t('settings.view.actions.back') : showBackButton @@ -745,9 +750,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const handleMobilePageSidebarItemSelect = React.useCallback(() => { shouldFocusMobilePageContentRef.current = true; setMobileStage('page-content'); - if (settingsSlug === 'skills.installed') { - pushMobileSplitDetailHistory(settingsSlug); - } + pushMobileSplitDetailHistory(settingsSlug); }, [pushMobileSplitDetailHistory, settingsSlug]); React.useEffect(() => { @@ -783,18 +786,35 @@ export const SettingsView: React.FC = ({ onClose, forceMobile setMobileStage('nav'); }, [backButtonTargetsPageSidebar, runtimeCtx.isVSCode, settingsSlug]); + // The Android hardware back button belongs to the same ladder as the header's + // back arrow: one level up per press, and only the press at the root falls + // through to the shell, which closes Settings. + React.useEffect(() => { + if (!registerBackHandler) { + return; + } + registerBackHandler(() => { + if (!isMobile || mobileStage === 'nav') { + return false; + } + handleBack(); + return true; + }); + return () => registerBackHandler(null); + }, [handleBack, isMobile, mobileStage, registerBackHandler]); + React.useEffect(() => { if (!isMobile || runtimeCtx.isVSCode) { return; } const handlePopState = (event: PopStateEvent) => { - if (settingsSlug !== 'skills.installed') { + if (getSettingsPageMeta(settingsSlug)?.kind !== 'split') { return; } const detail = getSettingsDetailHistoryEntry(event.state); - if (detail?.page === 'skills.installed') { + if (detail?.page === settingsSlug) { setMobileStage('page-content'); return; } @@ -808,10 +828,6 @@ export const SettingsView: React.FC = ({ onClose, forceMobile }; }, [isMobile, runtimeCtx.isVSCode, settingsSlug]); - const handleOpenPageSidebar = React.useCallback(() => { - setMobileStage('page-sidebar'); - }, []); - const renderSettingsNav = () => { const hasSearchQuery = settingsSearchQuery.trim().length > 0; @@ -1071,17 +1087,6 @@ export const SettingsView: React.FC = ({ onClose, forceMobile : (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))} - {showOpenPageSidebarButton && ( - - )} - {onClose && (