feat(mobile): drill into the OpenCode settings pages from a phone

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.
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:41:14 +03:00
parent 625a16028b
commit fd416a3678
14 changed files with 47 additions and 37 deletions
+17
View File
@@ -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(
@@ -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<string, unknown> {
}
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
export const SettingsView: React.FC<SettingsViewProps> = ({ 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<SettingsViewProps> = ({ 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<SettingsViewProps> = ({ 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<SettingsViewProps> = ({ 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<SettingsViewProps> = ({ 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<SettingsViewProps> = ({ onClose, forceMobile
: (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))}
</div>
{showOpenPageSidebarButton && (
<button
type="button"
onClick={handleOpenPageSidebar}
aria-label={t('settings.view.actions.openSectionList')}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="list-unordered" className="h-5 w-5" />
</button>
)}
{onClose && (
<button
type="button"
@@ -55,7 +55,6 @@ export const settingsDict = {
'settings.view.pendingRestart.confirm.cancel': 'Abbrechen',
'settings.view.actions.backToSettings': 'Zurück zu Einstellungen',
'settings.view.actions.closeSettings': 'Einstellungen schließen',
'settings.view.actions.openSectionList': 'Abschnittsliste öffnen',
'settings.view.actions.closeSettingsWithShortcut': 'Einstellungen schließen ({shortcut}+,)',
'settings.view.actions.back': 'Zurück',
'settings.view.actions.resizeNavigation': 'Einstellungsnavigation skalieren',
@@ -60,7 +60,6 @@ export const settingsDict = {
'settings.view.pendingRestart.confirm.cancel': 'Cancel',
'settings.view.actions.backToSettings': 'Back to Settings',
'settings.view.actions.closeSettings': 'Close settings',
'settings.view.actions.openSectionList': 'Open section list',
'settings.view.actions.closeSettingsWithShortcut': 'Close Settings ({shortcut}+,)',
'settings.view.actions.back': 'Back',
'settings.view.actions.resizeNavigation': 'Resize settings navigation',
@@ -61,7 +61,6 @@ export const settingsDict = {
"settings.view.pendingRestart.confirm.dontShowAgain": "No volver a mostrar",
"settings.view.pendingRestart.confirm.cancel": "Cancelar", "settings.view.actions.backToSettings": "Volver a Configuración",
"settings.view.actions.closeSettings": "Cerrar configuración",
"settings.view.actions.openSectionList": "Abrir lista de secciones",
"settings.view.actions.closeSettingsWithShortcut": "Cerrar configuración ({shortcut}+,)",
"settings.view.actions.back": "Atrás",
"settings.view.actions.resizeNavigation": "Ajustar tamaño de la navegación",
@@ -61,7 +61,6 @@ export const settingsDict = {
'settings.view.pendingRestart.confirm.dontShowAgain': 'Ne plus afficher',
'settings.view.pendingRestart.confirm.cancel': 'Annuler', 'settings.view.actions.backToSettings': 'Retour aux paramètres',
'settings.view.actions.closeSettings': 'Fermer les paramètres',
'settings.view.actions.openSectionList': 'Ouvrir la liste des sections',
'settings.view.actions.closeSettingsWithShortcut': 'Fermer les paramètres ({shortcut}+,)',
'settings.view.actions.back': 'Retour',
'settings.view.actions.resizeNavigation': 'Redimensionner la navigation dans les paramètres',
@@ -61,7 +61,6 @@ export const settingsDict = {
'settings.view.pendingRestart.confirm.dontShowAgain': '今後表示しない',
'settings.view.pendingRestart.confirm.cancel': 'キャンセル', 'settings.view.actions.backToSettings': '設定に戻る',
'settings.view.actions.closeSettings': '設定を閉じる',
'settings.view.actions.openSectionList': 'セクション一覧を開く',
'settings.view.actions.closeSettingsWithShortcut': '設定を閉じる ({shortcut}+,)',
'settings.view.actions.back': '戻る',
'settings.view.actions.resizeNavigation': '設定ナビゲーションのサイズ変更',
@@ -61,7 +61,6 @@ export const settingsDict = {
'settings.view.pendingRestart.confirm.dontShowAgain': '다시 표시하지 않음',
'settings.view.pendingRestart.confirm.cancel': '취소', 'settings.view.actions.backToSettings': '설정으로 돌아가기',
'settings.view.actions.closeSettings': '설정 닫기',
'settings.view.actions.openSectionList': '섹션 목록 열기',
'settings.view.actions.closeSettingsWithShortcut': '설정 닫기 ({shortcut}+,)',
'settings.view.actions.back': '뒤로',
'settings.view.actions.resizeNavigation': '설정 내비게이션 크기 조정',
@@ -2123,7 +2123,6 @@ export const settingsDict = {
'settings.view.actions.backToSettings': 'Powrót do ustawień',
'settings.view.actions.closeSettings': 'Zamknij ustawienia',
'settings.view.actions.closeSettingsWithShortcut': 'Zamknij ustawienia ({shortcut}+,)',
'settings.view.actions.openSectionList': 'Otwórz listę sekcji',
'settings.view.actions.reloadOpenCode': 'Przeładuj OpenCode',
'settings.view.actions.reloadOpenCodeTooltip': 'Uruchom ponownie OpenCode i przeładuj jego konfigurację.',
@@ -61,7 +61,6 @@ export const settingsDict = {
"settings.view.pendingRestart.confirm.dontShowAgain": "Não mostrar novamente",
"settings.view.pendingRestart.confirm.cancel": "Cancelar", "settings.view.actions.backToSettings": "Voltar às configurações",
"settings.view.actions.closeSettings": "Fechar configurações",
"settings.view.actions.openSectionList": "Abrir lista de seções",
"settings.view.actions.closeSettingsWithShortcut": "Fechar configurações ({shortcut}+,)",
"settings.view.actions.back": "Voltar",
"settings.view.actions.resizeNavigation": "Ajustar tamanho da navegação",
@@ -60,7 +60,6 @@ export const settingsDict = {
'settings.view.pendingRestart.confirm.cancel': 'İptal',
'settings.view.actions.backToSettings': 'Ayarlar\'a geri dön',
'settings.view.actions.closeSettings': 'Ayarları kapat',
'settings.view.actions.openSectionList': 'Bölüm listesini aç',
'settings.view.actions.closeSettingsWithShortcut': 'Ayarları kapat ({shortcut}+,)',
'settings.view.actions.back': 'Geri',
'settings.view.actions.resizeNavigation': 'Ayar gezinmesini yeniden boyutlandır',
@@ -61,7 +61,6 @@ export const settingsDict = {
"settings.view.pendingRestart.confirm.dontShowAgain": "Більше не показувати",
"settings.view.pendingRestart.confirm.cancel": "Скасувати", "settings.view.actions.backToSettings": "Назад до налаштувань",
"settings.view.actions.closeSettings": "Закрити налаштування",
"settings.view.actions.openSectionList": "Відкрити список розділів",
"settings.view.actions.closeSettingsWithShortcut": "Закрити налаштування ({shortcut}+,)",
"settings.view.actions.back": "Назад",
"settings.view.actions.resizeNavigation": "Змінити розмір навігації налаштувань",
@@ -61,7 +61,6 @@ export const settingsDict = {
'settings.view.pendingRestart.confirm.dontShowAgain': '不再显示',
'settings.view.pendingRestart.confirm.cancel': '取消', 'settings.view.actions.backToSettings': '返回设置',
'settings.view.actions.closeSettings': '关闭设置',
'settings.view.actions.openSectionList': '打开分组列表',
'settings.view.actions.closeSettingsWithShortcut': '关闭设置({shortcut}+,',
'settings.view.actions.back': '返回',
'settings.view.actions.resizeNavigation': '调整设置导航宽度',
@@ -59,7 +59,6 @@ export const settingsDict = {
'settings.view.pendingRestart.confirm.dontShowAgain': '不要再顯示',
'settings.view.pendingRestart.confirm.cancel': '取消', 'settings.view.actions.backToSettings': '返回設定頁',
'settings.view.actions.closeSettings': '關閉設定',
'settings.view.actions.openSectionList': '開啟群組清單',
'settings.view.actions.closeSettingsWithShortcut': '關閉設定({shortcut}+,',
'settings.view.actions.back': '返回',
'settings.view.actions.resizeNavigation': '調整設定導覽寬度',