fix(agents): open duplicate editor on mobile
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user