fix: harden and de-slop the merged contribution batch

Follow-ups promised on merge, plus review findings on the batch itself:

- chat: task-tool output now respects the 512KiB render cap; quick-open
  icon is visible at rest on coarse pointers and reachable by keyboard
  (row keydown no longer swallows inner-button Enter/Space); composer
  inline-code decoration drops the metric-shifting padding; a btw fork
  send carries only the boundary instruction, never the promotion notice
- sync: cascade revert/unrevert aborts busy descendants, busy state is
  read from every child store at the moment of use; rule 9 documents
  redo clearing all descendant revert markers
- electron: renderer recovery keeps memory-eviction (a valid
  render-process-gone reason) and both windows share one
  attachRendererRecovery helper
- vscode: process registry is a thin re-export of the web module
  (provider-env-aliases precedent) with ordered register/unregister
  writes and an awaited close
- server/cli: managed-process registry takes injectable deps (fixes the
  unreaped-orphans ReferenceError), corrupt settings errors name the
  file, getWorktrees test restores console.warn
- tests: module-mock harnesses removed (AgentsSidebar, SettingsView
  mobile focus — behaviors stay live but uncovered, accepted trade),
  QuestionMarkdown asserts rendered DOM
- i18n: German gains the debug-panel request keys, Japanese/German drop
  removed worktree keys, Ukrainian unit spacing fixed
- changelog: Copilot AI Credits entries (main + VS Code)
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 02:08:09 +03:00
parent a79aff45c1
commit b8465ae133
33 changed files with 673 additions and 1205 deletions
@@ -36,7 +36,7 @@ import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session
import { BtwPanel } from './btw/BtwPanel';
import { useBtwPanelState } from './btw/useBtwPanelState';
import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata';
import { BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { buildBtwSyntheticTexts, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { ToolPopupContent } from './message/types';
@@ -1134,12 +1134,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null,
composerAttachments: attachedFiles,
inlineComments: drafts,
// btw mode: the boundary rides with every send, not just the
// first one, so the inherited transcript stays reference material
// for the whole side conversation.
syntheticTexts: [
...(isBtwActive ? [BTW_BOUNDARY_INSTRUCTION] : []),
...(isPromotedBtwSession ? [BTW_PROMOTION_NOTICE] : []),
...buildBtwSyntheticTexts({ isBtwActive, isPromotedBtwSession }),
...(syntheticParts?.map((part) => part.text) ?? []),
],
linkedIssue: linkedIssue
@@ -1,25 +1,35 @@
import { describe, expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
import { QuestionMarkdown } from './QuestionMarkdown';
// The markdown renderer is lazy, so a synchronous server render always emits the
// Suspense fallback QuestionMarkdown supplies. That fallback is the surface that
// has to keep the exact question text and the question typography classes.
describe('QuestionMarkdown', () => {
test('delegates exact content to the tool markdown renderer', () => {
test('renders the question content verbatim', () => {
const content = 'Choose **one** from `mode`: [details](https://example.com)';
const element = QuestionMarkdown({ content, size: 'meta' });
expect(element.type).toBe(SimpleMarkdownRenderer);
expect(element.props.content).toBe(content);
expect(element.props.variant).toBe('tool');
expect(element.props.fallbackContent.props.children).toBe(content);
expect(element.props.fallbackContent.props.className).toContain('whitespace-pre-wrap');
const html = renderToStaticMarkup(<QuestionMarkdown content={content} size="meta" />);
expect(html).toBe(
`<div class="question-markdown typography-meta whitespace-pre-wrap">${content}</div>`,
);
});
test('preserves question typography size and caller classes', () => {
const meta = QuestionMarkdown({ content: 'Meta', size: 'meta', className: 'font-medium text-foreground' });
const micro = QuestionMarkdown({ content: 'Micro', size: 'micro', className: 'text-muted-foreground' });
test('applies meta typography and caller classes', () => {
const html = renderToStaticMarkup(
<QuestionMarkdown content="Meta" size="meta" className="font-medium text-foreground" />,
);
expect(meta.props.className).toBe('question-markdown typography-meta font-medium text-foreground');
expect(micro.props.className).toBe('question-markdown typography-micro text-muted-foreground');
expect(html).toContain('class="question-markdown typography-meta font-medium text-foreground whitespace-pre-wrap"');
});
test('applies micro typography and caller classes', () => {
const html = renderToStaticMarkup(
<QuestionMarkdown content="Micro" size="micro" className="text-muted-foreground" />,
);
expect(html).toContain('class="question-markdown typography-micro text-muted-foreground whitespace-pre-wrap"');
});
});
@@ -103,7 +103,7 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
mentionAgent: 'text-[var(--status-success)]',
mentionCommand: 'text-[var(--primary)]',
mentionSnippet: 'text-[var(--status-warning)]',
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)] px-[0.3125rem] py-0.5',
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)]',
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
// A `~path` is written for the reader's benefit, not to attach anything —
// it takes the same colour as a file mention, since it names the same kind
@@ -46,9 +46,9 @@ import {
buildTaskSummaryEntriesFromSession,
normalizeTaskSummaryEntries,
parseTaskMetadataBlock,
prepareTaskToolOutput,
readTaskSessionIdFromOutput,
readTaskSessionIdFromRecord,
stripTaskMetadataFromOutput,
type TaskToolSummaryEntry,
} from './taskToolModel';
import { areRenderRelevantPartsEqual } from '../renderCompare';
@@ -1004,9 +1004,7 @@ const TaskToolSummary: React.FC<{
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const runtime = React.useContext(RuntimeAPIContext);
const trimmedOutput = typeof output === 'string'
? stripTaskMetadataFromOutput(output)
: '';
const trimmedOutput = prepareTaskToolOutput(output);
const hasOutput = trimmedOutput.length > 0;
const [isOutputExpanded, setIsOutputExpanded] = React.useState(false);
@@ -2037,6 +2035,9 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
};
const handleMainKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
// Nested buttons (quick-open, copy) handle their own Enter/Space; the row
// must not swallow the key and toggle instead.
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
@@ -2092,13 +2093,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
openQuickTarget();
};
const handleQuickOpenKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
openQuickTarget();
};
const iconStyle = !isTaskTool && isError ? TOOL_ERROR_ICON_STYLE : TOOL_NORMAL_ICON_STYLE;
const titleStyle = !isTaskTool && isError ? TOOL_ERROR_TITLE_STYLE : TOOL_NORMAL_TITLE_STYLE;
const shouldRenderTaskSummary = useDeferredExpandedContent(isTaskTool && (taskSummaryEntries.length > 0 || isActive || shouldTreatAsFinalized || !!taskSessionId));
@@ -2192,7 +2186,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
</div>
</div>
<div className="flex items-center gap-1 min-w-0 flex-1">
<div className={cn('flex items-center min-w-0 flex-1', quickOpenTarget ? 'gap-1' : 'gap-2')}>
<MinDurationShineText
active={Boolean(isActive && !isError)}
minDurationMs={300}
@@ -2206,10 +2200,11 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
<button
type="button"
onClick={handleQuickOpen}
onKeyDown={handleQuickOpenKeyDown}
className={cn(
'flex-shrink-0 inline-flex h-4 w-4 items-center justify-center rounded transition-opacity hover:bg-[var(--surface-hover)]',
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100',
// Coarse pointers never hover, so the icon has to rest visible
// there or it stays invisible while remaining tappable.
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-60',
)}
style={{ color: 'var(--tools-icon)' }}
title={t('chat.toolPart.openFile')}
@@ -4,9 +4,11 @@ import type { Message, Part } from '@opencode-ai/sdk/v2';
import {
buildTaskSummaryEntriesFromSession,
parseTaskMetadataBlock,
prepareTaskToolOutput,
readTaskSessionIdFromRecord,
readTaskSessionIdFromOutput,
} from './taskToolModel';
import { TOOL_OUTPUT_MAX_CHARS } from '../toolRenderers';
describe('taskToolModel', () => {
test('reads the current OpenCode running-state identity contract', () => {
@@ -39,4 +41,19 @@ describe('taskToolModel', () => {
state: { status: 'completed', title: undefined, input: { filePath: 'a.ts' } },
}]);
});
test('strips task metadata and caps oversized task output before markdown rendering', () => {
const oversized = 'x'.repeat(TOOL_OUTPUT_MAX_CHARS + 5_000);
const output = `${oversized}\n<task_metadata>{"sessionID":"child-1"}</task_metadata>`;
const prepared = prepareTaskToolOutput(output);
expect(prepared.length).toBeLessThan(oversized.length);
expect(prepared).toContain('output truncated');
expect(prepared).not.toContain('task_metadata');
});
test('leaves normal task output untouched', () => {
expect(prepareTaskToolOutput('done\n<task_metadata>{"sessionID":"child-1"}</task_metadata>')).toBe('done');
expect(prepareTaskToolOutput(undefined)).toBe('');
});
});
@@ -1,5 +1,6 @@
import type { MessageRecord } from '@/lib/messageCompletion';
import { capToolOutputText } from '../toolRenderers';
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
export type TaskToolSummaryEntry = {
@@ -131,3 +132,12 @@ export const buildTaskSummaryEntriesFromSession = (messages: MessageRecord[]): T
export const stripTaskMetadataFromOutput = (output: string): string => {
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
};
// The task tool renders its output through the markdown parser instead of the
// shared tool-output path, so it needs the same size guard as
// `getToolOutputText` (issue #2265): an unbounded single string reaching the
// parser can exhaust V8's Zone allocator and crash the renderer.
export const prepareTaskToolOutput = (output: string | undefined): string => {
if (!output) return '';
return capToolOutputText(stripTaskMetadataFromOutput(output));
};
@@ -2,8 +2,18 @@ import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { buildChildrenIndex, computeSubtreeCost, formatCost } from './subagentCost';
function makeSession(id: string, cost: number, parentID?: string): Session {
return { id, cost, parentID } as unknown as Session;
function makeSession(id: string, cost: number | undefined, parentID?: string): Session {
return {
id,
slug: id,
projectID: 'project',
directory: '/project',
title: id,
version: '1',
time: { created: 0, updated: 0 },
cost,
parentID,
};
}
describe('buildChildrenIndex', () => {
@@ -57,7 +67,7 @@ describe('computeSubtreeCost', () => {
test('treats zero and undefined cost as zero, not a break', () => {
const root = makeSession('root', 0);
const child = { id: 'child', parentID: 'root' } as unknown as Session;
const child = makeSession('child', undefined, 'root');
const sessions = [root, child];
const sessionsById = new Map(sessions.map((s) => [s.id, s]));
const childrenByParent = buildChildrenIndex(sessions);
@@ -17,7 +17,7 @@ export const formatCost = (cost: number): string => `$${trimZeros(cost.toFixed(4
export function buildChildrenIndex(sessions: Session[]): Map<string, Session[]> {
const index = new Map<string, Session[]>();
for (const session of sessions) {
const parentID = (session as unknown as { parentID?: string }).parentID;
const parentID = session.parentID;
if (!parentID) continue;
const existing = index.get(parentID);
if (existing) {
@@ -30,8 +30,7 @@ export function buildChildrenIndex(sessions: Session[]): Map<string, Session[]>
}
function sessionCost(session: Session | undefined): number {
const cost = (session as unknown as { cost?: number } | undefined)?.cost;
return typeof cost === 'number' ? cost : 0;
return session?.cost ?? 0;
}
/**
@@ -3,7 +3,17 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { computeRollup } from './useSubagentCostRollup';
function makeSession(id: string, cost: number, parentID?: string): Session {
return { id, cost, parentID } as unknown as Session;
return {
id,
slug: id,
projectID: 'project',
directory: '/project',
title: id,
version: '1',
time: { created: 0, updated: 0 },
cost,
parentID,
};
}
const sessions: Session[] = [
@@ -1,228 +0,0 @@
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');
});
});
@@ -172,7 +172,7 @@ const LineChart: React.FC<{
);
};
export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
const { t } = useI18n();
const [activeTab, setActiveTab] = React.useState<DebugTab>('memory');
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle');
@@ -1,438 +0,0 @@
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();
}
});
});