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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user