From fd416a36783687af9de4971c7e3c4fdf569179d5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 9 Sep 2026 09:01:18 +0300 Subject: [PATCH] feat(mobile): drill into the OpenCode settings pages from a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snippets, Agents, Commands, Plugins and Skills were desktop-only, even though the composer already reads snippets and slash commands and the chat lets you pick an agent: you could use those features from a phone but not manage them. They render through the same page and sidebar components, so the mobile settings surface only had to stop filtering them out. Making them usable needed the drill-down to be real. A split page opens its own list on mobile, but back from an item jumped all the way out to the settings root, and a separate header button was the only way back to the list — except on Skills, which had a hand-rolled exception. Turn that exception into the rule: back walks nav → page list → item in reverse on every split page, browser and system history follow the same path, and the extra header button goes away because back now does its job. The Android hardware button asks Settings to step up before the shell closes it. MCP, Providers, Usage and Magic Prompts inherit the same navigation. Testing: package type-check, lint, settings suites under the isolated runner; walked all six pages plus MCP in a browser at 390px (list → item → back to list → back to root, no horizontal overflow). The hardware back button needs a device check. --- packages/ui/src/apps/MobileApp.tsx | 17 ++++++ .../ui/src/components/views/SettingsView.tsx | 55 ++++++++++--------- .../ui/src/lib/i18n/messages/de.settings.ts | 1 - .../ui/src/lib/i18n/messages/en.settings.ts | 1 - .../ui/src/lib/i18n/messages/es.settings.ts | 1 - .../ui/src/lib/i18n/messages/fr.settings.ts | 1 - .../ui/src/lib/i18n/messages/ja.settings.ts | 1 - .../ui/src/lib/i18n/messages/ko.settings.ts | 1 - .../ui/src/lib/i18n/messages/pl.settings.ts | 1 - .../src/lib/i18n/messages/pt-BR.settings.ts | 1 - .../ui/src/lib/i18n/messages/tr.settings.ts | 1 - .../ui/src/lib/i18n/messages/uk.settings.ts | 1 - .../src/lib/i18n/messages/zh-CN.settings.ts | 1 - .../src/lib/i18n/messages/zh-TW.settings.ts | 1 - 14 files changed, 47 insertions(+), 37 deletions(-) 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 && (