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
@@ -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"