fix(agents): open duplicate editor on mobile

This commit is contained in:
bashrusakh
2026-08-25 07:37:02 +11:00
parent 97edd033f9
commit a894a80c0d
5 changed files with 688 additions and 2 deletions
@@ -0,0 +1,228 @@
import React from 'react';
import { describe, expect, mock, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
type ClickEvent = { stopPropagation: () => void };
type ClickHandler = (event: ClickEvent) => void;
type ChildrenProps = { children?: React.ReactNode };
type ClickableProps = ChildrenProps & { onClick?: ClickHandler };
type TriggerProps = ChildrenProps & { render?: React.ReactNode };
interface AgentDraftSnapshot {
name: string;
scope: string;
description?: string;
model?: string | null;
variant?: string;
temperature?: number;
top_p?: number;
prompt?: string;
mode?: string;
permission?: Record<string, string>;
disable?: boolean;
}
interface AgentRecord {
name: string;
description: string;
model: { providerID: string; modelID: string };
variant: string;
temperature: number;
topP: number;
prompt: string;
mode: string;
permission: Array<{ permission: string; pattern: string; action: 'allow' | 'ask' | 'deny' }>;
scope: string;
disable: boolean;
}
interface AgentStoreState {
selectedAgentName: string | null;
agents: AgentRecord[];
setAgentDraft: (draft: AgentDraftSnapshot) => void;
setSelectedAgent: (name: string) => void;
createAgent: () => Promise<{ ok: boolean }>;
deleteAgent: () => Promise<{ ok: boolean }>;
loadAgents: () => Promise<void>;
}
const sourceAgent: AgentRecord = {
name: 'writer',
description: 'Writes concise documentation',
model: { providerID: 'openai', modelID: 'gpt-4.1' },
variant: 'fast',
temperature: 0.4,
topP: 0.8,
prompt: 'Write clear documentation.',
mode: 'subagent',
permission: [{ permission: 'bash', pattern: '*', action: 'ask' }],
scope: 'project',
disable: true,
};
let recordedDraft: AgentDraftSnapshot | null = null;
let selectedAgentName: string | null = null;
let duplicateMenuClick: ClickHandler | null = null;
let mobileDevice = true;
const agentStore: AgentStoreState = {
selectedAgentName: null,
agents: [sourceAgent],
setAgentDraft: (draft) => {
recordedDraft = draft;
},
setSelectedAgent: (name) => {
selectedAgentName = name;
},
createAgent: async () => ({ ok: true }),
deleteAgent: async () => ({ ok: true }),
loadAgents: async () => {},
};
function useAgentsStore<Selected>(selector: (state: AgentStoreState) => Selected): Selected {
return selector(agentStore);
}
function useShallow<Selector>(selector: Selector): Selector {
return selector;
}
mock.module('@/components/ui/button', () => ({
Button: ({ children, onClick }: ClickableProps) => <button onClick={onClick}>{children}</button>,
}));
mock.module('@/components/ui/input', () => ({
Input: () => <input />,
}));
mock.module('@/components/ui', () => ({
toast: { error: () => {}, success: () => {}, warning: () => {} },
}));
mock.module('@/lib/device', () => ({
isMobileDeviceViaCSS: () => mobileDevice,
}));
mock.module('@/components/ui/dialog', () => ({
Dialog: ({ children }: ChildrenProps) => <>{children}</>,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogDescription: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
}));
mock.module('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: ChildrenProps) => <>{children}</>,
DropdownMenuContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DropdownMenuItem: ({ children, onClick }: ClickableProps) => {
if (React.Children.toArray(children).includes('Duplicate')) {
duplicateMenuClick = onClick ?? null;
}
return <button onClick={onClick}>{children}</button>;
},
DropdownMenuTrigger: ({ children }: ChildrenProps) => <>{children}</>,
}));
mock.module('@/components/ui/context-menu', () => ({
ContextMenu: ({ children }: ChildrenProps) => <>{children}</>,
ContextMenuContent: ({ children }: ChildrenProps) => <div>{children}</div>,
ContextMenuItem: ({ children }: ChildrenProps) => <div>{children}</div>,
ContextMenuTrigger: ({ children, render }: TriggerProps) => <>{render}{children}</>,
}));
mock.module('@/hooks/useSettingsDirectory', () => ({
useSettingsDirectory: () => '/workspace',
}));
mock.module('@/stores/useAgentsStore', () => ({
useAgentsStore,
selectAgentsForDirectory: (state: AgentStoreState) => state.agents,
isAgentBuiltIn: () => false,
isAgentHidden: () => false,
}));
mock.module('zustand/react/shallow', () => ({ useShallow }));
mock.module('@/lib/utils', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
}));
mock.module('@/components/ui/ScrollableOverlay', () => ({
ScrollableOverlay: ({ children }: ChildrenProps) => <div>{children}</div>,
}));
mock.module('@/components/sections/shared/SettingsProjectSelector', () => ({
SettingsProjectSelector: () => null,
}));
mock.module('@/components/sections/shared/SidebarGroup', () => ({
SidebarGroup: ({ children }: ChildrenProps) => <>{children}</>,
}));
mock.module('@/components/icon/Icon', () => ({
Icon: () => null,
}));
mock.module('@/lib/i18n', () => ({
useI18n: () => ({
t: (key: string) => (key === 'settings.common.actions.duplicate' ? 'Duplicate' : key),
}),
}));
mock.module('@/components/sections/shared/SettingsSection', () => ({
SETTINGS_PANEL_TITLE_CLASS: '',
}));
const { AgentsSidebar } = await import('./AgentsSidebar');
function getDuplicateMenuClick(): ClickHandler {
if (!duplicateMenuClick) {
throw new Error('Expected the duplicate action to be rendered');
}
return duplicateMenuClick;
}
describe('AgentsSidebar duplicate action', () => {
test('notifies the mobile split-view parent once after preparing a prefilled agent draft', () => {
recordedDraft = null;
selectedAgentName = null;
duplicateMenuClick = null;
mobileDevice = true;
let mobileTransitionCount = 0;
renderToStaticMarkup(
<AgentsSidebar onItemSelect={() => { mobileTransitionCount += 1; }} />,
);
getDuplicateMenuClick()({ stopPropagation: () => {} });
expect(recordedDraft).toEqual({
name: 'writer-copy',
scope: 'project',
description: 'Writes concise documentation',
model: 'openai/gpt-4.1',
variant: 'fast',
temperature: 0.4,
top_p: 0.8,
prompt: 'Write clear documentation.',
mode: 'subagent',
permission: { bash: 'ask' },
disable: true,
});
expect(selectedAgentName).toBe('writer-copy');
expect(mobileTransitionCount).toBe(1);
});
test('does not require a mobile transition callback on desktop', () => {
recordedDraft = null;
selectedAgentName = null;
duplicateMenuClick = null;
mobileDevice = false;
renderToStaticMarkup(<AgentsSidebar />);
getDuplicateMenuClick()({ stopPropagation: () => {} });
expect(selectedAgentName).toBe('writer-copy');
});
});
@@ -250,6 +250,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
disable: draftAgent.disable,
});
setSelectedAgent(newName);
onItemSelect?.();
};
@@ -75,13 +75,13 @@ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
hasTitleChrome ? (
<div className="flex min-w-0 items-center gap-2">
{titleLeading}
<h1 className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
<h1 data-settings-page-heading tabIndex={-1} className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
{/* A status badge carries a fixed word; compressing it
wraps the text inside its own pill. */}
<span className="shrink-0">{titleAccessory}</span>
</div>
) : (
<h1 className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
<h1 data-settings-page-heading tabIndex={-1} className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
)
) : (
title
@@ -0,0 +1,438 @@
import React, { act } from 'react';
import { describe, expect, mock, test } from 'bun:test';
import { createRoot, type Root } from 'react-dom/client';
type ChildrenProps = { children?: React.ReactNode };
type AgentsSidebarProps = { onItemSelect?: () => void };
type SettingsPageLayoutProps = {
children: React.ReactNode;
title?: React.ReactNode;
showSaveStatus?: boolean;
};
interface FakeNode {
nodeType: number;
nodeName: string;
tagName: string;
namespaceURI: string;
ownerDocument: FakeDocument;
parentNode: FakeNode | null;
childNodes: FakeNode[];
style: { setProperty: () => void; getPropertyValue: () => string };
classList: FakeClassList;
attributes: Map<string, string>;
textContent: string;
nodeValue: string | null;
focusOptions?: FocusOptions;
appendChild: (child: FakeNode) => FakeNode;
insertBefore: (child: FakeNode, before: FakeNode | null) => FakeNode;
removeChild: (child: FakeNode) => FakeNode;
setAttribute: (name: string, value: string) => void;
removeAttribute: (name: string) => void;
getAttribute: (name: string) => string | null;
hasAttribute: (name: string) => boolean;
addEventListener: () => void;
removeEventListener: () => void;
contains: (child: FakeNode | null) => boolean;
querySelector: (selector: string) => FakeNode | null;
focus: (options?: FocusOptions) => void;
}
interface FakeDocument {
nodeType: number;
nodeName: string;
defaultView: FakeWindow | null;
body: FakeNode | null;
documentElement: FakeNode | null;
activeElement: FakeNode | null;
createElement: (tag: string) => FakeNode & Element;
createElementNS: (_namespace: string, tag: string) => FakeNode & Element;
createTextNode: (text: string) => FakeNode & Element;
addEventListener: () => void;
removeEventListener: () => void;
}
interface FakeWindow {
document: FakeDocument;
navigator: { userAgent: string; platform: string; maxTouchPoints: number };
history: { state: null; back: () => void; pushState: () => void };
location: { href: string };
requestAnimationFrame: (callback: FrameRequestCallback) => number;
cancelAnimationFrame: (frame: number) => void;
addEventListener: () => void;
removeEventListener: () => void;
HTMLIFrameElement: typeof FakeElement;
HTMLFrameSetElement: typeof FakeElement;
HTMLInputElement: typeof FakeElement;
HTMLTextAreaElement: typeof FakeElement;
HTMLSelectElement: typeof FakeElement;
HTMLOptionElement: typeof FakeElement;
HTMLAnchorElement: typeof FakeElement;
}
type GlobalStubValue = FakeDocument | FakeWindow | FakeWindow['navigator'] | FakeWindow['location'] | typeof FakeElement | boolean;
class FakeElement {}
class FakeClassList {
private readonly classes = new Set<string>();
add(...classes: string[]) {
classes.forEach((className) => this.classes.add(className));
}
remove(...classes: string[]) {
classes.forEach((className) => this.classes.delete(className));
}
contains(className: string) {
return this.classes.has(className);
}
}
function makeNode(tag: string, ownerDocument: FakeDocument, nodeType = 1): FakeNode & Element {
const attributes = new Map<string, string>();
const properties: FakeNode = {
nodeType,
nodeName: nodeType === 3 ? '#text' : tag.toUpperCase(),
tagName: nodeType === 3 ? '#text' : tag.toUpperCase(),
namespaceURI: 'http://www.w3.org/1999/xhtml',
ownerDocument,
parentNode: null,
childNodes: [],
style: {
setProperty: () => {},
getPropertyValue: () => '',
},
classList: new FakeClassList(),
attributes,
textContent: '',
nodeValue: null,
appendChild(child) {
this.childNodes.push(child);
child.parentNode = this;
return child;
},
insertBefore(child, before) {
const index = before ? this.childNodes.indexOf(before) : -1;
if (index === -1) {
this.childNodes.push(child);
} else {
this.childNodes.splice(index, 0, child);
}
child.parentNode = this;
return child;
},
removeChild(child) {
const index = this.childNodes.indexOf(child);
if (index !== -1) {
this.childNodes.splice(index, 1);
}
child.parentNode = null;
return child;
},
setAttribute(name, value) {
attributes.set(name, value);
},
removeAttribute(name) {
attributes.delete(name);
},
getAttribute(name) {
return attributes.get(name) ?? null;
},
hasAttribute(name) {
return attributes.has(name);
},
addEventListener: () => {},
removeEventListener: () => {},
contains(child) {
if (child === this) {
return true;
}
return this.childNodes.some((nodeChild) => nodeChild.contains(child));
},
querySelector(selector) {
if (selector !== '[data-settings-page-heading]') {
return null;
}
if (this.hasAttribute('data-settings-page-heading')) {
return this;
}
for (const child of this.childNodes) {
const match = child.querySelector(selector);
if (match) {
return match;
}
}
return null;
},
focus(options) {
this.focusOptions = options;
this.ownerDocument.activeElement = this;
},
};
const node: FakeNode & Element = Object.assign(Object.create(FakeElement.prototype), properties);
return node;
}
function installDomStub() {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const setGlobal = (name: string, value: GlobalStubValue) => {
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
const frames = new Map<number, FrameRequestCallback>();
let nextFrame = 1;
const documentStub: FakeDocument = {
nodeType: 9,
nodeName: '#document',
defaultView: null,
body: null,
documentElement: null,
activeElement: null,
createElement: (tag) => makeNode(tag, documentStub),
createElementNS: (_namespace, tag) => makeNode(tag, documentStub),
createTextNode: (text) => {
const node = makeNode('#text', documentStub, 3);
node.nodeValue = text;
node.textContent = text;
return node;
},
addEventListener: () => {},
removeEventListener: () => {},
};
const windowStub: FakeWindow = {
document: documentStub,
navigator: { userAgent: 'test', platform: 'test', maxTouchPoints: 0 },
history: { state: null, back: () => {}, pushState: () => {} },
location: { href: 'http://localhost/' },
requestAnimationFrame: (callback) => {
const frame = nextFrame;
nextFrame += 1;
frames.set(frame, callback);
return frame;
},
cancelAnimationFrame: (frame) => {
frames.delete(frame);
},
addEventListener: () => {},
removeEventListener: () => {},
HTMLIFrameElement: FakeElement,
HTMLFrameSetElement: FakeElement,
HTMLInputElement: FakeElement,
HTMLTextAreaElement: FakeElement,
HTMLSelectElement: FakeElement,
HTMLOptionElement: FakeElement,
HTMLAnchorElement: FakeElement,
};
documentStub.defaultView = windowStub;
documentStub.body = makeNode('body', documentStub);
documentStub.documentElement = makeNode('html', documentStub);
documentStub.activeElement = documentStub.body;
setGlobal('document', documentStub);
setGlobal('window', windowStub);
setGlobal('navigator', windowStub.navigator);
setGlobal('location', windowStub.location);
setGlobal('Element', FakeElement);
setGlobal('HTMLElement', FakeElement);
setGlobal('HTMLIFrameElement', FakeElement);
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
return {
container: documentStub.createElement('div'),
document: documentStub,
frameCount: () => frames.size,
flushFrames: () => {
const callbacks = Array.from(frames.values());
frames.clear();
callbacks.forEach((callback) => callback(Date.now()));
},
restore: () => {
for (const [name, descriptor] of descriptors) {
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor);
} else {
Reflect.deleteProperty(globalThis, name);
}
}
},
};
}
const Empty = () => null;
const uiStore = {
settingsPage: 'agents',
isSettingsDialogOpen: true,
setSettingsPage: () => {},
};
type UiStoreValue = (typeof uiStore)[keyof typeof uiStore];
const agentsMeta = { slug: 'agents', title: 'Agents', group: 'opencode', kind: 'split' };
let sidebarOnItemSelect: (() => void) | undefined;
let SettingsPageLayout: React.ComponentType<SettingsPageLayoutProps> | null = null;
mock.module('@/lib/utils', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
getModifierLabel: () => 'Ctrl',
}));
mock.module('@/stores/useUIStore', () => ({
useUIStore: (selector: (state: typeof uiStore) => UiStoreValue) => selector(uiStore),
}));
mock.module('@/hooks/useSettingsDirectory', () => ({ useSettingsDirectory: () => '/workspace' }));
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: (selector: (state: { activeProjectId: null }) => null) => selector({ activeProjectId: null }),
}));
mock.module('@/stores/useAgentsStore', () => ({
refreshAfterOpenCodeRestart: async () => {},
useAgentsStore: { getState: () => ({ loadAgents: async () => {} }) },
}));
mock.module('@/stores/useCommandsStore', () => ({ useCommandsStore: { getState: () => ({ loadCommands: async () => {} }) } }));
mock.module('@/stores/useMcpConfigStore', () => ({ useMcpConfigStore: { getState: () => ({ loadMcpConfigs: async () => {} }) } }));
mock.module('@/stores/useSnippetsStore', () => ({ useSnippetsStore: { getState: () => ({ loadSnippets: async () => {} }) } }));
mock.module('@/stores/useSkillsStore', () => ({ useSkillsStore: { getState: () => ({ loadSkills: async () => {} }) } }));
mock.module('@/stores/useSkillsCatalogStore', () => ({ useSkillsCatalogStore: { getState: () => ({ loadCatalog: async () => {} }) } }));
mock.module('@/stores/useConfigStore', () => ({ useConfigStore: { getState: () => ({ providers: [], setSelectedProvider: () => {} }) } }));
mock.module('@/stores/usePendingOpenCodeRestartStore', () => ({
selectPendingOpenCodeRestartCount: () => 0,
usePendingOpenCodeRestartStore: () => 0,
}));
mock.module('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: ChildrenProps) => <>{children}</>,
TooltipTrigger: ({ children }: ChildrenProps) => <>{children}</>,
}));
mock.module('@/components/ui/ErrorBoundary', () => ({ ErrorBoundary: ({ children }: ChildrenProps) => <>{children}</> }));
mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: ({ children }: ChildrenProps) => <div>{children}</div> }));
mock.module('@/components/sections/shared/SettingsSection', () => ({
SETTINGS_DESCRIPTION_CLASS: '',
SETTINGS_PAGE_TITLE_CLASS: '',
SETTINGS_SECTION_TITLE_CLASS: '',
}));
mock.module('@/lib/persistence', () => ({
getSettingsSaveState: () => 'idle',
subscribeToSettingsSaveState: () => () => {},
}));
mock.module('@/components/icon/Icon', () => ({ Icon: Empty }));
mock.module('@/components/icons/McpIcon', () => ({ McpIcon: Empty }));
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
mock.module('@/lib/device', () => ({
useDeviceInfo: () => ({ isMobile: false }),
}));
mock.module('@/lib/desktop', () => ({
getDesktopHomeDirectory: async () => null,
isDesktopLocalOriginActive: () => false,
isDesktopShell: () => false,
isVSCodeRuntime: () => false,
isWebRuntime: () => true,
}));
mock.module('@/lib/platform', () => ({ isWindowsArm64: () => false }));
mock.module('@/lib/settings/metadata', () => ({
SETTINGS_PAGE_METADATA: [agentsMeta],
getSettingsNavIcon: () => 'settings-3',
getSettingsPageMeta: (slug: string) => slug === 'agents' ? agentsMeta : null,
resolveSettingsSlug: (slug: string) => slug === 'agents' ? 'agents' : 'home',
}));
mock.module('@/lib/settings/search', () => ({ buildSettingsSearchResults: () => [] }));
mock.module('@/components/views/OpenCodeReloadFooterAction', () => ({ OpenCodeReloadFooterAction: Empty }));
mock.module('@/components/sections/agents/AgentsSidebar', () => ({
AgentsSidebar: ({ onItemSelect }: AgentsSidebarProps) => {
sidebarOnItemSelect = onItemSelect;
return <button type="button" onClick={onItemSelect}>Duplicate</button>;
},
}));
mock.module('@/components/sections/agents/AgentsPage', () => ({
AgentsPage: () => {
const Layout = SettingsPageLayout;
if (!Layout) {
throw new Error('SettingsPageLayout must load before SettingsView');
}
return <Layout title="New agent" showSaveStatus={false}><div /></Layout>;
},
}));
for (const [module, exports] of [
['@/components/sections/behavior/BehaviorPage', ['BehaviorPage']],
['@/components/sections/commands/CommandsSidebar', ['CommandsSidebar']],
['@/components/sections/commands/CommandsPage', ['CommandsPage']],
['@/components/sections/mcp/McpSidebar', ['McpSidebar']],
['@/components/sections/mcp/McpPage', ['McpPage']],
['@/components/sections/plugins', ['PluginsSidebar', 'PluginsPage']],
['@/components/sections/skills/SkillsSidebar', ['SkillsSidebar']],
['@/components/sections/skills/SkillsPage', ['SkillsPage']],
['@/components/sections/projects/ProjectsSidebar', ['ProjectsSidebar']],
['@/components/sections/projects/ProjectsPage', ['ProjectsPage']],
['@/components/sections/remote-instances/RemoteInstancesPage', ['RemoteInstancesPage']],
['@/components/sections/providers/ProvidersSidebar', ['ProvidersSidebar']],
['@/components/sections/providers/ProvidersPage', ['ProvidersPage']],
['@/components/sections/usage/UsageSidebar', ['UsageSidebar']],
['@/components/sections/usage/UsagePage', ['UsagePage']],
['@/components/sections/magic-prompts/MagicPromptsSidebar', ['MagicPromptsSidebar']],
['@/components/sections/magic-prompts/MagicPromptsPage', ['MagicPromptsPage']],
['@/components/sections/snippets/SnippetsSidebar', ['SnippetsSidebar']],
['@/components/sections/snippets/SnippetsPage', ['SnippetsPage']],
['@/components/sections/git-identities/GitPage', ['GitPage']],
['@/components/sections/integrations/IntegrationsPage', ['IntegrationsPage']],
['@/components/sections/openchamber/OpenChamberPage', ['OpenChamberPage']],
['@/components/sections/openchamber/AboutSettings', ['AboutSettings']],
] as const) {
mock.module(module, () => Object.fromEntries(exports.map((name) => [name, Empty])));
}
SettingsPageLayout = (await import('../sections/shared/SettingsPageLayout')).SettingsPageLayout;
const { SettingsView } = await import('./SettingsView');
describe('SettingsView mobile split-page focus', () => {
test('focuses the rendered editor heading after a mobile sidebar selection', async () => {
const dom = installDomStub();
const root: Root = createRoot(dom.container);
sidebarOnItemSelect = undefined;
try {
await act(async () => {
root.render(<SettingsView forceMobile initialMobileStage="page-sidebar" />);
});
expect(sidebarOnItemSelect).toBeDefined();
await act(async () => {
sidebarOnItemSelect?.();
});
const heading = dom.container.querySelector('[data-settings-page-heading]');
expect(heading).not.toBeNull();
expect(heading?.getAttribute('tabindex')).toBe('-1');
expect(dom.document.activeElement).toBe(dom.document.body);
expect(dom.frameCount()).toBe(1);
await act(async () => {
dom.flushFrames();
});
expect(dom.document.activeElement).toBe(heading);
expect(heading?.focusOptions).toEqual({ preventScroll: true });
} finally {
await act(async () => {
root.unmount();
});
dom.restore();
}
});
test('does not pass the mobile selection callback to desktop split pages', async () => {
const dom = installDomStub();
const root: Root = createRoot(dom.container);
sidebarOnItemSelect = undefined;
try {
await act(async () => {
root.render(<SettingsView forceMobile={false} />);
});
expect(sidebarOnItemSelect).toBe(undefined);
expect(dom.frameCount()).toBe(0);
} finally {
await act(async () => {
root.unmount();
});
dom.restore();
}
});
});
@@ -209,6 +209,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const [pendingSearchItemId, setPendingSearchItemId] = React.useState<string | null>(null);
const [activeSearchResultIndex, setActiveSearchResultIndex] = React.useState(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const shouldFocusMobilePageContentRef = React.useRef(false);
const searchResultRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const activeSearchResultIndexRef = React.useRef(0);
const keyboardSearchNavigationRef = React.useRef(false);
@@ -751,12 +752,30 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
}, [runtimeCtx.isVSCode]);
const handleMobilePageSidebarItemSelect = React.useCallback(() => {
shouldFocusMobilePageContentRef.current = true;
setMobileStage('page-content');
if (settingsSlug === 'skills.installed') {
pushMobileSplitDetailHistory(settingsSlug);
}
}, [pushMobileSplitDetailHistory, settingsSlug]);
React.useEffect(() => {
if (!isMobile || mobileStage !== 'page-content' || !shouldFocusMobilePageContentRef.current) {
return;
}
shouldFocusMobilePageContentRef.current = false;
const frame = window.requestAnimationFrame(() => {
containerRef.current
?.querySelector<HTMLElement>('[data-settings-page-heading]')
?.focus({ preventScroll: true });
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [isMobile, mobileStage, settingsSlug]);
const handleBack = React.useCallback(() => {
if (backButtonTargetsPageSidebar) {
const currentDetail = typeof window !== 'undefined'