qwen-code/packages/web-shell/client/App.test.tsx
ytahdn 22cd9c7d7a
fix(web-shell): sync background agent status (#7561)
* fix(web-shell): sync background agent status

* fix(web-shell): harden background agent reconciliation

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
2026-07-23 06:35:04 +00:00

6220 lines
194 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, createRef, type CSSProperties } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon';
import type { WebShellApi } from './App';
import type { Message } from './adapters/types';
import { loadSplitSessions, saveSplitSessions } from './utils/splitUrl';
type StreamingState = 'idle' | 'responding';
type MockConnection = {
status: 'connected' | 'connecting' | 'disconnected' | 'error';
sessionId: string | undefined;
clientId: string;
displayName: string | undefined;
workspaceCwd: string;
currentModel: string;
currentMode: string;
models: Array<{ id: string; label?: string }>;
commands: unknown[];
skills: string[];
capabilities: { qwenCodeVersion: string; features: string[] };
loadingTranscript: boolean;
catchingUp: boolean;
error?: string;
errorStatus?: number;
missingSession?: boolean;
};
type ChatEditorTestProps = {
onSubmit: (
text: string,
images?: { data: string; media_type: string }[],
commitAccepted?: () => void,
metadata?: { inputAnnotations?: DaemonInputAnnotation[] },
) => boolean | void;
onInputTextChange?: (text: string) => void;
onStartNewSessionSuggestion?: () => void;
newSessionSuggestion?: { isVisible: boolean; classifiedInput: string } | null;
skills?: Array<{ name: string; description: string }>;
commands?: Array<{ name: string }>;
isPreparing?: boolean;
dialogOpen?: boolean;
placeholderText?: string;
workspaces?: Array<{ id: string; cwd: string }>;
atWorkspaceCwd?: string;
selectedWorkspaceCwd?: string;
onSelectWorkspace?: (cwd: string | undefined) => void;
onCreateScratchWorkspace?: () => void;
onOpenExistingWorkspace?: () => void;
scratchWorkspaceSupported?: boolean;
existingFolderWorkspaceSupported?: boolean;
};
type AddWorkspaceDialogTestProps = {
onClose: () => void;
onAdd: (cwd: string, persist: boolean, displayName?: string) => Promise<void>;
displayNameEnabled?: boolean;
persistenceSupported?: boolean;
};
const {
mockConnection,
mockSessionActions,
mockWorkspace,
mockWorkspaceActions,
mockMcp,
mockStore,
mockFollowup,
testState,
sidebarTokens,
rawEnqueuePrompt,
editorClear,
editorCommit,
editorFocus,
editorInsertText,
settingsReload,
} = vi.hoisted(() => {
const connection: MockConnection = {
status: 'connected',
sessionId: 'session-1',
clientId: 'client-1',
displayName: 'Session One',
workspaceCwd: '/tmp/project',
currentModel: 'qwen',
currentMode: 'default',
models: [{ id: 'qwen', label: 'Qwen' }],
commands: [],
skills: [],
capabilities: { qwenCodeVersion: '1.2.3', features: [] },
loadingTranscript: false,
catchingUp: false,
};
const loadSkillsStatus = vi.fn().mockResolvedValue({ skills: [] });
const workspaceClient = {
workspaceByCwd: vi.fn(() => ({
workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }),
workspaceSkills: loadSkillsStatus,
})),
sessionStatus: vi.fn(() => Promise.resolve({})),
};
return {
mockConnection: connection,
mockSessionActions: {
sendPrompt: vi.fn().mockResolvedValue(undefined),
generateSessionContent: vi.fn(async function* () {}),
createSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
attachSession: vi.fn().mockResolvedValue(undefined),
clearSession: vi.fn().mockResolvedValue(undefined),
releaseSession: vi.fn().mockResolvedValue(undefined),
refreshCommands: vi.fn().mockResolvedValue(undefined),
setModel: vi.fn().mockResolvedValue(undefined),
setApprovalMode: vi.fn().mockResolvedValue(undefined),
getRewindSnapshots: vi.fn().mockResolvedValue([]),
rewindSession: vi.fn().mockResolvedValue(undefined),
submitPermission: vi.fn().mockResolvedValue(undefined),
clearGoal: vi.fn().mockResolvedValue(undefined),
forkSession: vi.fn().mockResolvedValue({ launched: false }),
sendShellCommand: vi.fn().mockResolvedValue(undefined),
getStats: vi.fn().mockResolvedValue({}),
loadArtifacts: vi.fn().mockResolvedValue({ artifacts: [] }),
loadSession: vi.fn().mockResolvedValue(undefined),
reloadSession: vi.fn().mockResolvedValue(undefined),
},
mockWorkspace: {
capabilities: {
workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }],
},
client: workspaceClient,
refreshCapabilities: vi.fn(),
},
mockWorkspaceActions: {
loadSkillsStatus,
loadProviders: vi.fn().mockResolvedValue({ current: null }),
loadPreflight: vi.fn().mockResolvedValue(null),
loadEnv: vi.fn().mockResolvedValue(null),
loadMcpStatus: vi.fn().mockResolvedValue({ servers: [] }),
loadMcpTools: vi.fn().mockResolvedValue([]),
loadMcpResources: vi.fn().mockResolvedValue([]),
addWorkspace: vi.fn(),
addScratchWorkspace: vi.fn(),
suggestWorkspacePaths: vi.fn(),
},
mockMcp: {
initialize: vi.fn().mockResolvedValue({ accepted: true }),
reloadConfig: vi.fn().mockResolvedValue({ accepted: true }),
reload: vi.fn(),
loadTools: vi.fn(),
loadResources: vi.fn(),
restartServer: vi.fn(),
manageServer: vi.fn(),
addServer: vi.fn(),
removeServer: vi.fn(),
loading: false,
error: undefined,
},
mockStore: {
dispatch: vi.fn(),
reset: vi.fn(),
appendLocalUserMessage: vi.fn(),
appendLocalAssistantMessage: vi.fn(),
},
mockFollowup: {
clear: vi.fn(),
onAcceptFollowup: vi.fn(),
onDismissFollowup: vi.fn(),
},
testState: {
prompt: 'hello',
inputAnnotations: undefined as DaemonInputAnnotation[] | undefined,
promptImages: undefined as
| { data: string; media_type: string }[]
| undefined,
streamingState: 'idle' as StreamingState,
blocks: [] as unknown[],
messages: [] as unknown[],
latestChatEditorProps: null as ChatEditorTestProps | null,
latestAddWorkspaceDialogProps: null as AddWorkspaceDialogTestProps | null,
latestToolApprovalKeyboardActive: null as boolean | null,
latestAskUserQuestionKeyboardActive: null as boolean | null,
latestScheduledTasksProps: null as {
onRunPrompt?: (
prompt: string,
sessionId: string | null,
) => Promise<void>;
onCreateViaChat?: () => void;
workspaces?: Array<{ id: string; cwd: string }>;
lockedWorkspace?: { id: string; cwd: string; primary: boolean };
} | null,
latestGoalsProps: null as {
onCreateGoal?: (condition: string) => Promise<void>;
onOpenSession?: (sessionId: string) => void;
} | null,
},
sidebarTokens: [] as Array<number | undefined>,
rawEnqueuePrompt: vi.fn(() => true),
editorClear: vi.fn(),
editorCommit: vi.fn(),
editorFocus: vi.fn(),
editorInsertText: vi.fn(),
settingsReload: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
useActions: () => mockSessionActions,
useConnection: () => mockConnection,
useDaemonFollowupSuggestion: () => ({
followupState: null,
clear: mockFollowup.clear,
onAcceptFollowup: mockFollowup.onAcceptFollowup,
onDismissFollowup: mockFollowup.onDismissFollowup,
}),
useSessionNotices: () => ({ notices: [], dismissNotice: vi.fn() }),
usePromptStatus: () => 'idle',
useSettings: () => ({
settings: [],
setValue: vi.fn().mockResolvedValue(undefined),
reload: settingsReload,
loading: false,
}),
useProviders: () => ({
providers: [],
current: undefined,
loading: false,
error: undefined,
reload: vi.fn().mockResolvedValue(undefined),
}),
useStreamingState: () => testState.streamingState,
useTranscriptBlocks: () => testState.blocks,
useTranscriptHistory: () => ({
hasMore: false,
loading: false,
capacityReached: false,
loadMore: vi.fn(),
release: vi.fn(),
}),
useTranscriptStore: () => mockStore,
useWorkspace: () => mockWorkspace,
useWorkspaceActions: () => mockWorkspaceActions,
useMcp: () => mockMcp,
useWorkspaceEventSignals: () => ({
artifactsVersion: 0,
extensionsVersion: 0,
}),
}));
vi.mock('@qwen-code/sdk/daemon', () => ({
DaemonHttpError: class DaemonHttpError extends Error {
constructor(
readonly status: number,
readonly body: unknown,
message: string,
) {
super(message);
}
},
DAEMON_GOAL_STATUS_SENTINEL_PREFIX: 'qwen-goal-status:',
isDaemonTurnError: () => false,
}));
vi.mock('./hooks/useMessages', () => ({
useMessages: () => testState.messages,
}));
vi.mock('./hooks/useBackgroundTasks', () => ({
useBackgroundTasks: () => [],
}));
vi.mock('./hooks/useAnimationFrameValue', () => ({
useAnimationFrameValue: (value: unknown) => value,
}));
vi.mock('./hooks/useQueuedPrompts', () => ({
useQueuedPrompts: () => ({
queuedPrompts: [],
queuedTexts: [],
enqueuePrompt: rawEnqueuePrompt,
removeQueuedPrompt: vi.fn(),
insertQueuedPrompt: vi.fn(),
editQueuedPrompt: vi.fn(),
editLastQueuedPrompt: vi.fn(() => false),
clearQueuedPrompts: vi.fn(() => false),
}),
}));
vi.mock('./components/ChatEditor', async () => {
const React = await import('react');
return {
ChatEditor: React.forwardRef(function ChatEditor(
props: ChatEditorTestProps,
ref: React.ForwardedRef<{
clear: () => void;
hasInput: () => boolean;
insertText: (text: string) => void;
focus: () => void;
}>,
) {
testState.latestChatEditorProps = props;
React.useImperativeHandle(ref, () => ({
clear: () => {
testState.prompt = '';
testState.promptImages = undefined;
props.onInputTextChange?.('');
editorClear();
},
hasInput: () => testState.prompt.trim().length > 0,
insertText: editorInsertText,
submit: () => {
props.onSubmit(
testState.prompt,
testState.promptImages,
editorCommit,
testState.inputAnnotations
? { inputAnnotations: testState.inputAnnotations }
: undefined,
);
},
// The panel focus effect calls editorRef.current?.focus() when a panel
// closes with no pending approval (e.g. resuming a session).
focus: editorFocus,
}));
return React.createElement(
React.Fragment,
null,
React.createElement(
'button',
{
'data-testid': 'submit',
'data-preparing': props.isPreparing ? 'true' : 'false',
onClick: () => {
if (testState.inputAnnotations) {
props.onSubmit(testState.prompt, undefined, editorCommit, {
inputAnnotations: testState.inputAnnotations,
});
return;
}
props.onSubmit(testState.prompt, undefined, editorCommit);
},
type: 'button',
},
'submit',
),
props.newSessionSuggestion
? React.createElement(
'div',
{ 'data-testid': 'new-session-suggestion' },
React.createElement(
'button',
{
'data-testid': 'new-session-suggestion-start',
onClick: () => props.onStartNewSessionSuggestion?.(),
type: 'button',
},
'This looks like a new topic',
),
)
: null,
);
}),
};
});
vi.mock('./components/MessageList', async () => {
const React = await import('react');
const { useInteractionBlocker } = await import('./interactionBlockContext');
function InteractionBlockerProbe() {
const registerInteractionBlocker = useInteractionBlocker();
const releaseRef = React.useRef<(() => void) | null>(null);
return React.createElement(
'button',
{
'data-testid': 'interaction-blocker',
onClick: () => {
if (releaseRef.current) {
releaseRef.current();
releaseRef.current = null;
} else {
releaseRef.current = registerInteractionBlocker();
}
},
type: 'button',
},
releaseRef.current ? 'release blocker' : 'register blocker',
);
}
return {
MessageList: React.forwardRef(function MessageList(
props: {
showRetryHint?: boolean;
onRetryClick?: () => void;
welcomeHeader?: React.ReactNode;
},
ref: React.ForwardedRef<{ scrollToBottom: () => void }>,
) {
React.useImperativeHandle(ref, () => ({ scrollToBottom: vi.fn() }));
return React.createElement(
'div',
{ 'data-testid': 'messages' },
props.welcomeHeader ?? null,
React.createElement(InteractionBlockerProbe),
props.showRetryHint
? React.createElement(
'button',
{
'data-testid': 'retry',
onClick: props.onRetryClick,
type: 'button',
},
'retry',
)
: null,
);
}),
};
});
// SettingsMessage / ModelDialog expose their callbacks as buttons so tests can
// walk the fast-model path: open Settings -> onSubDialog('fastModel') opens the
// model picker -> onSelect fires handleFastModelSelect.
vi.mock('./components/messages/SettingsMessage', async () => {
const React = await import('react');
return {
SettingsMessage: (props: {
onSubDialog?: (key: string, scope: 'user' | 'workspace') => void;
onLanguageChange?: (
language: string,
scope: 'user' | 'workspace',
) => void;
}) =>
React.createElement(
'div',
{ 'data-testid': 'settings-message' },
React.createElement(
'button',
{
'data-testid': 'open-fast-model',
type: 'button',
// The real panel forwards the active tab's scope; default is
// workspace, which drives the `--project` flag below.
onClick: () => props.onSubDialog?.('fastModel', 'workspace'),
},
'fast model',
),
React.createElement(
'button',
{
'data-testid': 'open-fast-model-user',
type: 'button',
// User tab → drives the `--global` flag.
onClick: () => props.onSubDialog?.('fastModel', 'user'),
},
'fast model (user)',
),
React.createElement(
'button',
{
'data-testid': 'change-language-workspace',
type: 'button',
// Workspace tab language change → /language ui en --project.
onClick: () => props.onLanguageChange?.('en', 'workspace'),
},
'language (workspace)',
),
),
};
});
vi.mock('./components/dialogs/ModelDialog', async () => {
const React = await import('react');
return {
ModelDialog: (props: { onSelect?: (id: string) => void }) =>
React.createElement(
'button',
{
'data-testid': 'model-select',
type: 'button',
onClick: () => props.onSelect?.('fast-model-x'),
},
'select model',
),
};
});
// The /diff intercept opens this dialog; render it through the (mocked)
// DialogShell so tests can detect it via [data-testid="dialog-shell"] without
// exercising the dialog's diff-fetching hooks.
vi.mock('./components/dialogs/GitDiffDialog', async () => {
const React = await import('react');
const { DialogShell } = await import('./components/dialogs/DialogShell');
return {
GitDiffDialog: () =>
React.createElement(DialogShell, null, 'changes dialog'),
GitDiffContent: () => React.createElement('div', null, 'changes dialog'),
};
});
// Render DialogShell as an observable container so tests can detect an open
// sub-dialog (model picker, approval-mode picker) via [data-testid="dialog-shell"].
vi.mock('./components/dialogs/DialogShell', async () => {
const React = await import('react');
return {
DialogShell: (props: { children?: React.ReactNode }) =>
React.createElement(
'div',
{ 'data-testid': 'dialog-shell' },
props.children,
),
};
});
vi.mock('./components/sidebar/WebShellSidebar', async () => {
const React = await import('react');
return {
WebShellSidebar: (props: {
sessionListReloadToken?: number;
collapsed?: boolean;
onOpenPlugins?: () => void;
onOpenDaemonStatus?: () => void;
onOpenSessions?: () => void;
onOpenSplitView?: () => void;
onNewSession?: () => Promise<boolean> | boolean;
onLoadSession?: (sessionId: string) => Promise<void> | void;
onOpenAddWorkspace?: () => void;
}) => {
sidebarTokens.push(props.sessionListReloadToken);
// Expose the Daemon Status / Session Overview openers so tests can
// exercise those activePanel branches (neither has a slash command).
return React.createElement(
'div',
{
'data-testid': 'sidebar',
'data-collapsed': String(Boolean(props.collapsed)),
},
React.createElement(
'button',
{
'data-testid': 'open-add-workspace',
type: 'button',
onClick: props.onOpenAddWorkspace,
},
'add workspace',
),
React.createElement(
'button',
{
'data-testid': 'new-session',
type: 'button',
onClick: props.onNewSession,
},
'new session',
),
React.createElement(
'button',
{
'data-testid': 'load-session',
type: 'button',
onClick: () => props.onLoadSession?.('session-2'),
},
'load session',
),
React.createElement(
'button',
{
'data-testid': 'open-plugins',
type: 'button',
onClick: props.onOpenPlugins,
},
'plugins',
),
React.createElement(
'button',
{
'data-testid': 'open-daemon-status',
type: 'button',
onClick: props.onOpenDaemonStatus,
},
'daemon status',
),
React.createElement(
'button',
{
'data-testid': 'open-sessions-overview',
type: 'button',
onClick: props.onOpenSessions,
},
'sessions overview',
),
React.createElement(
'button',
{
'data-testid': 'open-split-view',
type: 'button',
onClick: props.onOpenSplitView,
},
'split view',
),
);
},
};
});
vi.mock('./components/dialogs/AddWorkspaceDialog', async () => {
const React = await import('react');
return {
AddWorkspaceDialog: (props: AddWorkspaceDialogTestProps) => {
testState.latestAddWorkspaceDialogProps = props;
return React.createElement('div', {
'data-testid': 'add-workspace-dialog',
});
},
};
});
function mockComponent(path: string, exportName: string): void {
vi.doMock(path, async () => {
const React = await import('react');
return {
[exportName]: () => React.createElement('div'),
};
});
}
mockComponent('./components/StatusBar', 'StatusBar');
mockComponent('./components/StreamingStatus', 'StreamingStatus');
mockComponent('./components/ToastHost', 'ToastHost');
mockComponent('./components/panels/TodoPanel', 'TodoPanel');
mockComponent('./components/WelcomeHeader', 'WelcomeHeader');
mockComponent('./components/dialogs/ApprovalModeDialog', 'ApprovalModeDialog');
mockComponent('./components/dialogs/ResumeDialog', 'ResumeDialog');
mockComponent('./components/dialogs/ToolsDialog', 'ToolsDialog');
mockComponent('./components/tools/ToolsManagerPage', 'ToolsManagerPage');
mockComponent('./components/skills/SkillsManagerPage', 'SkillsManagerPage');
mockComponent('./components/dialogs/DaemonStatusDialog', 'DaemonStatusDialog');
mockComponent('./components/SessionOverviewPanel', 'SessionOverviewPanel');
vi.doMock('./components/SplitView', async () => {
const React = await import('react');
return {
SplitView: (props: {
onExit?: () => void;
sessionIds?: string[];
onPanesChange?: (ids: string[]) => void;
onPaneArtifactsChange?: (
sessionId: string,
artifacts: unknown[],
workspaceActions: unknown,
) => void;
onRightPanelOpen?: (request: unknown) => void;
}) => {
const paneActions = {
readWorkspaceFile: vi.fn().mockResolvedValue('<p>pane</p>'),
};
const artifact = {
id: 'pane-artifact',
kind: 'report',
storage: 'memory',
source: 'tool',
status: 'available',
title: 'Pane artifact',
updatedAt: '2026-07-10T00:00:00Z',
sizeBytes: 10,
};
const updatedArtifact = {
...artifact,
title: 'Updated pane artifact',
updatedAt: '2026-07-10T00:01:00Z',
sizeBytes: 20,
};
return React.createElement(
'div',
{ 'data-testid': 'split-view-mock' },
// Surface the seed so a test can assert the App preserved / restored it.
React.createElement(
'span',
{ 'data-testid': 'split-initial' },
(props.sessionIds ?? []).join(','),
),
// Simulate the real SplitView reporting its live pane set up to the App.
React.createElement(
'button',
{
'data-testid': 'split-report-panes',
type: 'button',
onClick: () => props.onPanesChange?.(['s1', 's2', 's3']),
},
'report',
),
React.createElement(
'button',
{
'data-testid': 'split-report-artifact',
type: 'button',
onClick: () =>
props.onPaneArtifactsChange?.(
'pane-session',
[artifact],
paneActions,
),
},
'artifact',
),
React.createElement(
'button',
{
'data-testid': 'split-report-updated-artifact',
type: 'button',
onClick: () =>
props.onPaneArtifactsChange?.(
'pane-session',
[updatedArtifact],
paneActions,
),
},
'updated artifact',
),
React.createElement(
'button',
{
'data-testid': 'split-clear-artifacts',
type: 'button',
onClick: () =>
props.onPaneArtifactsChange?.('pane-session', [], paneActions),
},
'clear artifacts',
),
React.createElement(
'button',
{
'data-testid': 'split-open-artifact',
type: 'button',
onClick: () =>
props.onRightPanelOpen?.({
id: 'artifact:pane-artifact:pane-session',
kind: 'artifact',
title: artifact.title,
turnId: 'turn-1',
artifactId: artifact.id,
artifact,
workspaceActions: paneActions,
previewContent: '<p>stale</p>',
}),
},
'open artifact',
),
React.createElement(
'button',
{
'data-testid': 'split-back',
type: 'button',
onClick: props.onExit,
},
'back',
),
);
},
};
});
// Capturing mock: stores the onRunPrompt handler (App's real runTaskManually)
// so tests can drive the manual-run orchestration directly, then renders a bare
// node like the other dialog mocks.
vi.doMock('./components/dialogs/ScheduledTasksDialog', async () => {
const React = await import('react');
return {
ScheduledTasksDialog: (props: {
onRunPrompt?: (prompt: string, sessionId: string | null) => Promise<void>;
workspaces?: Array<{ id: string; cwd: string }>;
lockedWorkspace?: { id: string; cwd: string; primary: boolean };
}) => {
testState.latestScheduledTasksProps = props;
return React.createElement('div');
},
};
});
// Capturing mock: stores App's real onCreateGoal / onOpenSession handlers so
// tests can drive the goal-creation orchestration without a daemon.
vi.doMock('./components/dialogs/GoalsDialog', async () => {
const React = await import('react');
return {
GoalsDialog: (props: {
onCreateGoal?: (condition: string) => Promise<void>;
onOpenSession?: (sessionId: string) => void;
}) => {
testState.latestGoalsProps = props;
return React.createElement('div');
},
};
});
vi.doMock('./components/extensions/ExtensionsManagerPage', async () => {
const React = await import('react');
return {
ExtensionsManagerPage: (props: {
onClose: () => void;
initialFocusRef?: React.Ref<HTMLHeadingElement>;
embedded?: unknown;
}) =>
React.createElement(
'div',
{ 'data-testid': 'extensions-manager-page' },
React.createElement(
'h1',
{
ref: props.initialFocusRef,
tabIndex: -1,
'data-testid': 'extensions-manager-heading',
},
'Manage extensions',
),
React.createElement('button', {
'data-testid': 'extensions-manager-back',
onClick: props.onClose,
}),
),
};
});
mockComponent('./components/dialogs/ThemeDialog', 'ThemeDialog');
mockComponent(
'./components/dialogs/DeleteSessionDialog',
'DeleteSessionDialog',
);
mockComponent(
'./components/dialogs/ReleaseSessionDialog',
'ReleaseSessionDialog',
);
mockComponent('./components/dialogs/RewindDialog', 'RewindDialog');
mockComponent('./components/messages/AgentsMessage', 'AgentsMessage');
mockComponent('./components/messages/MemoryMessage', 'MemoryMessage');
mockComponent('./components/messages/AuthMessage', 'AuthMessage');
// Record keyboardActive so app-level tests can assert the overlay is told to
// grab focus when it becomes topmost (the actual focus lives in the real
// components, covered by their own unit tests).
vi.doMock('./components/messages/ToolApproval', async () => {
const React = await import('react');
return {
ToolApproval: (props: { keyboardActive?: boolean }) => {
testState.latestToolApprovalKeyboardActive = props.keyboardActive ?? null;
return React.createElement('div', {
'data-web-shell-permission-panel': '',
});
},
};
});
vi.doMock('./components/messages/AskUserQuestion', async () => {
const React = await import('react');
return {
AskUserQuestion: (props: { keyboardActive?: boolean }) => {
testState.latestAskUserQuestionKeyboardActive =
props.keyboardActive ?? null;
return React.createElement('div', { 'data-web-shell-ask-panel': '' });
},
};
});
mockComponent('./components/messages/TasksStatusMessage', 'TasksStatusMessage');
mockComponent('./components/messages/BtwMessage', 'BtwMessage');
mockComponent('./components/QueuedPromptDisplay', 'QueuedPromptDisplay');
const { App, getBackgroundTaskActivityKey } = await import('./App');
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
describe('background task activity key', () => {
it('includes background shells and excludes background agents', () => {
const messages = [
{
id: 'tools',
role: 'tool_group',
tools: [
{
callId: 'shell-call',
toolName: 'shell',
status: 'in_progress',
args: { is_background: true },
},
{
callId: 'agent-call',
toolName: 'agent',
status: 'pending',
args: { run_in_background: true },
},
],
},
] satisfies Message[];
expect(getBackgroundTaskActivityKey(messages)).toBe(
'shell-call:in_progress',
);
});
});
function renderApp(props: React.ComponentProps<typeof App> = {}): {
container: HTMLElement;
rerender: (nextProps?: React.ComponentProps<typeof App>) => void;
unmount: () => void;
} {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const doRender = (nextProps: React.ComponentProps<typeof App> = props) => {
act(() => {
root.render(<App sidebar={{ enabled: true }} {...nextProps} />);
});
};
doRender(props);
const entry = { root, container };
mounted.push(entry);
const unmount = () => {
const index = mounted.indexOf(entry);
if (index >= 0) mounted.splice(index, 1);
act(() => root.unmount());
container.remove();
};
return { container, rerender: doRender, unmount };
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
async function clickSubmit(container: HTMLElement): Promise<void> {
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="submit"]')
?.click();
await Promise.resolve();
});
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (value?: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value?: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = (value) => res(value as T | PromiseLike<T>);
reject = rej;
});
return { promise, resolve, reject };
}
// A transcript block shaped like extractPendingPermission() expects. Defaults to
// a non-AskUserQuestion tool (→ pendingToolApproval); pass toolName
// 'ask_user_question' to exercise the pendingAskUserApproval branch instead.
// isAskUserPermission() classifies by rawInput.questions being a non-empty
// array, so the ask-user variant carries a toolCall.input.questions payload
// (getPermissionRawInput reads toolCall.input) — a bare toolName isn't enough.
function makePendingPermissionBlock(
overrides: { resolved?: boolean; toolName?: string } = {},
): unknown {
const toolName = overrides.toolName ?? 'run_shell_command';
const isAskUser = toolName === 'ask_user_question';
return {
kind: 'permission',
resolved: overrides.resolved ?? false,
requestId: 'req-1',
sessionId: 'session-1',
title: 'Run ls',
toolCall: {
toolCallId: 'tc-1',
kind: isAskUser ? 'other' : 'execute',
_meta: { toolName },
...(isAskUser
? { input: { questions: [{ question: 'Pick one', options: [] }] } }
: {}),
},
options: [
{ optionId: 'proceed_once', label: 'Allow', raw: {} },
{ optionId: 'cancel', label: 'Reject', raw: {} },
],
};
}
beforeEach(() => {
// Split persistence uses sessionStorage; clear it so one test's split doesn't
// auto-restore into the next test's App mount.
sessionStorage.clear();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
// Query-aware: report a large screen (min-width matches) so the Session
// Overview entry point is available, while keeping the mobile (max-width)
// query false as the other tests expect.
value: vi.fn().mockImplementation((query: string) => ({
matches: typeof query === 'string' && query.includes('min-width'),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
mockConnection.sessionId = 'session-1';
mockConnection.workspaceCwd = '/tmp/project';
mockConnection.status = 'connected';
mockConnection.displayName = 'Session One';
mockConnection.error = undefined;
mockConnection.errorStatus = undefined;
mockConnection.missingSession = false;
mockConnection.commands = [];
mockConnection.skills = [];
mockConnection.loadingTranscript = false;
mockConnection.catchingUp = false;
mockWorkspace.capabilities = {
workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }],
};
mockWorkspace.refreshCapabilities.mockReset();
mockWorkspace.refreshCapabilities.mockResolvedValue(
mockWorkspace.capabilities,
);
mockWorkspace.client.workspaceByCwd.mockClear();
testState.prompt = 'hello';
testState.inputAnnotations = undefined;
testState.promptImages = undefined;
testState.streamingState = 'idle';
testState.blocks = [];
testState.messages = [];
testState.latestChatEditorProps = null;
testState.latestAddWorkspaceDialogProps = null;
testState.latestToolApprovalKeyboardActive = null;
testState.latestAskUserQuestionKeyboardActive = null;
testState.latestScheduledTasksProps = null;
testState.latestGoalsProps = null;
sidebarTokens.length = 0;
rawEnqueuePrompt.mockClear();
editorClear.mockClear();
editorCommit.mockClear();
editorFocus.mockClear();
editorInsertText.mockClear();
settingsReload.mockClear();
settingsReload.mockResolvedValue(undefined);
mockFollowup.clear.mockClear();
for (const value of Object.values(mockSessionActions)) {
if (typeof value === 'function' && 'mockClear' in value) value.mockClear();
}
mockSessionActions.sendPrompt.mockResolvedValue(undefined);
mockSessionActions.createSession.mockResolvedValue({
sessionId: 'session-1',
});
mockSessionActions.attachSession.mockResolvedValue(undefined);
mockSessionActions.clearSession.mockResolvedValue(undefined);
mockSessionActions.releaseSession.mockResolvedValue(undefined);
mockSessionActions.loadSession.mockResolvedValue(undefined);
mockSessionActions.reloadSession.mockResolvedValue(undefined);
mockSessionActions.refreshCommands.mockResolvedValue(undefined);
mockSessionActions.setModel.mockResolvedValue(undefined);
mockSessionActions.setApprovalMode.mockResolvedValue(undefined);
mockSessionActions.getRewindSnapshots.mockResolvedValue([]);
mockSessionActions.rewindSession.mockResolvedValue(undefined);
mockSessionActions.submitPermission.mockResolvedValue(undefined);
mockSessionActions.clearGoal.mockResolvedValue(undefined);
mockSessionActions.forkSession.mockResolvedValue({ launched: false });
mockSessionActions.sendShellCommand.mockResolvedValue(undefined);
mockSessionActions.getStats.mockResolvedValue({});
mockSessionActions.loadSession.mockResolvedValue(undefined);
mockStore.reset.mockClear();
mockStore.dispatch.mockClear();
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ skills: [] });
mockWorkspaceActions.loadProviders.mockResolvedValue({ current: null });
mockWorkspaceActions.loadPreflight.mockResolvedValue(null);
mockWorkspaceActions.loadEnv.mockResolvedValue(null);
mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ servers: [] });
mockWorkspaceActions.loadMcpTools.mockResolvedValue([]);
mockWorkspaceActions.loadMcpResources.mockResolvedValue([]);
mockWorkspaceActions.addWorkspace.mockReset();
mockWorkspaceActions.addScratchWorkspace.mockReset();
mockWorkspaceActions.suggestWorkspacePaths.mockReset();
mockMcp.initialize.mockClear();
mockMcp.initialize.mockResolvedValue({ accepted: true });
mockMcp.reloadConfig.mockClear();
mockMcp.reloadConfig.mockResolvedValue({ accepted: true });
mockMcp.reload.mockReset();
mockMcp.loadTools.mockReset();
mockMcp.loadResources.mockReset();
mockMcp.restartServer.mockReset();
mockMcp.restartServer.mockResolvedValue({
serverName: 'server',
restarted: true,
durationMs: 1,
});
mockMcp.manageServer.mockReset();
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('App session callbacks', () => {
it('submits through a disconnected session when prompt SSE restart is enabled', async () => {
mockConnection.status = 'disconnected';
renderApp({ restartSseOnPrompt: true });
await act(async () => {
testState.latestChatEditorProps?.onSubmit('recover connection');
await Promise.resolve();
});
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'recover connection',
expect.objectContaining({ images: undefined }),
);
});
it('reports the current workspace id and path', async () => {
mockConnection.workspaceCwd = '/work/secondary';
mockWorkspace.capabilities = {
workspaces: [
{ id: 'primary', cwd: '/workspace', primary: true },
{ id: 'secondary', cwd: '/work/secondary', primary: false },
],
};
const onSessionIdChange = vi.fn();
renderApp({ onSessionIdChange });
await flush();
expect(onSessionIdChange).toHaveBeenCalledWith(
'session-1',
'secondary',
'/work/secondary',
);
});
it('creates scratch once, accepts refreshed capabilities, and opens a fresh chat', async () => {
mockWorkspace.capabilities = {
features: [
'dynamic_workspace_registration',
'scratch_workspace_registration',
],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
let resolveScratch!: (value: {
id: string;
cwd: string;
primary: boolean;
trusted: boolean;
persisted: false;
}) => void;
mockWorkspaceActions.addScratchWorkspace.mockReturnValue(
new Promise((resolve) => {
resolveScratch = resolve;
}),
);
const accepted = {
features: ['scratch_workspace_registration'],
workspaces: [
...mockWorkspace.capabilities.workspaces,
{
id: 'scratch',
cwd: '/managed/scratch-Ab3',
primary: false,
trusted: true,
},
],
};
mockWorkspace.refreshCapabilities.mockResolvedValue(accepted);
renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onCreateScratchWorkspace?.();
testState.latestChatEditorProps?.onCreateScratchWorkspace?.();
});
expect(mockWorkspaceActions.addScratchWorkspace).toHaveBeenCalledOnce();
await act(async () => {
resolveScratch({
id: 'scratch',
cwd: '/managed/scratch-Ab3',
primary: false,
trusted: true,
persisted: false,
});
await vi.waitFor(() => {
expect(mockSessionActions.clearSession).toHaveBeenCalled();
});
});
expect(mockWorkspace.refreshCapabilities).toHaveBeenCalledOnce();
expect(mockWorkspace.client.workspaceByCwd).toHaveBeenCalledWith(
'/managed/scratch-Ab3',
);
});
it('opens one App-owned Add workspace dialog from both entry points', async () => {
mockWorkspace.capabilities = {
features: [
'dynamic_workspace_registration',
'persistent_workspace_registration',
'workspace_display_name',
],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
const { container } = renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onOpenExistingWorkspace?.();
});
expect(
container.querySelectorAll('[data-testid="add-workspace-dialog"]'),
).toHaveLength(1);
expect(testState.latestAddWorkspaceDialogProps).toMatchObject({
displayNameEnabled: true,
persistenceSupported: true,
});
act(() => {
testState.latestAddWorkspaceDialogProps?.onClose();
});
expect(
container.querySelector('[data-testid="add-workspace-dialog"]'),
).toBeNull();
act(() => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-add-workspace"]')
?.click();
});
expect(
container.querySelectorAll('[data-testid="add-workspace-dialog"]'),
).toHaveLength(1);
});
it('forwards a supported workspace display name through the shared mutation lane', async () => {
const added = {
id: 'payments',
cwd: '/tmp/payments',
displayName: 'Payments API',
primary: false,
trusted: true,
persisted: true,
};
mockWorkspace.capabilities = {
features: [
'dynamic_workspace_registration',
'persistent_workspace_registration',
'workspace_display_name',
],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
mockWorkspaceActions.addWorkspace.mockResolvedValue(added);
mockWorkspace.refreshCapabilities.mockResolvedValue({
...mockWorkspace.capabilities,
workspaces: [...mockWorkspace.capabilities.workspaces, added],
});
renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onOpenExistingWorkspace?.();
});
await act(async () => {
await testState.latestAddWorkspaceDialogProps?.onAdd(
'/tmp/payments',
true,
'Payments API',
);
});
expect(mockWorkspaceActions.addWorkspace).toHaveBeenCalledWith(
'/tmp/payments',
{ persist: true, displayName: 'Payments API' },
);
expect(mockWorkspace.refreshCapabilities).toHaveBeenCalledOnce();
});
it('omits unsupported persistence and display-name options', async () => {
const added = {
id: 'local',
cwd: '/tmp/local',
primary: false,
trusted: true,
};
mockWorkspace.capabilities = {
features: ['dynamic_workspace_registration'],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
mockWorkspaceActions.addWorkspace.mockResolvedValue(added);
mockWorkspace.refreshCapabilities.mockResolvedValue({
...mockWorkspace.capabilities,
workspaces: [...mockWorkspace.capabilities.workspaces, added],
});
renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onOpenExistingWorkspace?.();
});
expect(testState.latestAddWorkspaceDialogProps).toMatchObject({
displayNameEnabled: false,
persistenceSupported: false,
});
await act(async () => {
await testState.latestAddWorkspaceDialogProps?.onAdd(
'/tmp/local',
true,
'Ignored name',
);
});
expect(mockWorkspaceActions.addWorkspace).toHaveBeenCalledWith(
'/tmp/local',
{ persist: false },
);
});
it('rejects when the daemon does not confirm persistent registration', async () => {
mockWorkspace.capabilities = {
features: [
'dynamic_workspace_registration',
'persistent_workspace_registration',
],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
mockWorkspaceActions.addWorkspace.mockResolvedValue({
id: 'payments',
cwd: '/tmp/payments',
primary: false,
trusted: true,
persisted: false,
});
renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onOpenExistingWorkspace?.();
});
await expect(
testState.latestAddWorkspaceDialogProps?.onAdd('/tmp/payments', true),
).rejects.toThrow(
'The daemon did not confirm persistent workspace registration',
);
});
it('surfaces an inline error when an added folder cannot refresh capabilities', async () => {
const added = {
id: 'payments',
cwd: '/tmp/payments',
primary: false,
trusted: true,
};
mockWorkspace.capabilities = {
features: ['dynamic_workspace_registration'],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
mockWorkspaceActions.addWorkspace.mockResolvedValue(added);
mockWorkspace.refreshCapabilities.mockRejectedValueOnce(
new Error('refresh failed'),
);
renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onOpenExistingWorkspace?.();
});
await expect(
testState.latestAddWorkspaceDialogProps?.onAdd('/tmp/payments', false),
).rejects.toThrow(
'Workspace added, but the workspace list could not be refreshed',
);
expect(mockWorkspaceActions.addWorkspace).toHaveBeenCalledOnce();
});
it('retries only capability refresh after a committed scratch cannot reconcile', async () => {
mockWorkspace.capabilities = {
features: ['scratch_workspace_registration'],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
const result = {
id: 'scratch',
cwd: '/managed/scratch-retry',
primary: false,
trusted: true,
persisted: false as const,
};
const accepted = {
features: ['scratch_workspace_registration'],
workspaces: [...mockWorkspace.capabilities.workspaces, { ...result }],
};
mockWorkspaceActions.addScratchWorkspace.mockResolvedValue(result);
mockWorkspace.refreshCapabilities
.mockRejectedValueOnce(new Error('refresh failed'))
.mockResolvedValueOnce(accepted);
renderApp();
await flush();
await act(async () => {
testState.latestChatEditorProps?.onCreateScratchWorkspace?.();
await vi.waitFor(() => {
expect(mockWorkspace.refreshCapabilities).toHaveBeenCalledOnce();
});
});
act(() => {
testState.latestChatEditorProps?.onCreateScratchWorkspace?.();
});
expect(mockWorkspaceActions.addScratchWorkspace).toHaveBeenCalledOnce();
const refreshButton = Array.from(
document.querySelectorAll<HTMLButtonElement>('button'),
).find((button) => button.textContent === 'Refresh workspace list');
await act(async () => {
refreshButton?.click();
await vi.waitFor(() => {
expect(mockWorkspace.refreshCapabilities).toHaveBeenCalledTimes(2);
expect(mockSessionActions.clearSession).toHaveBeenCalledOnce();
});
});
expect(mockWorkspaceActions.addScratchWorkspace).toHaveBeenCalledOnce();
});
it('locks scratch creation after an unknown outcome until acknowledged', async () => {
mockWorkspace.capabilities = {
features: ['scratch_workspace_registration'],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
mockWorkspaceActions.addScratchWorkspace.mockRejectedValue(
new Error('Add scratch workspace timed out'),
);
mockWorkspace.refreshCapabilities.mockResolvedValue(
mockWorkspace.capabilities,
);
renderApp();
await flush();
await act(async () => {
testState.latestChatEditorProps?.onCreateScratchWorkspace?.();
await vi.waitFor(() => {
expect(mockWorkspaceActions.addScratchWorkspace).toHaveBeenCalledOnce();
expect(mockWorkspace.refreshCapabilities).toHaveBeenCalledOnce();
});
});
await flush();
act(() => {
testState.latestChatEditorProps?.onCreateScratchWorkspace?.();
});
expect(mockWorkspaceActions.addScratchWorkspace).toHaveBeenCalledOnce();
expect(document.body.textContent).toContain('I checked the workspace list');
const ackButton = Array.from(
document.querySelectorAll<HTMLButtonElement>('button'),
).find((button) => button.textContent === 'I checked the workspace list');
expect(ackButton).toBeDefined();
mockWorkspaceActions.addScratchWorkspace.mockResolvedValue({
id: 'scratch-2',
cwd: '/managed/scratch-2',
primary: false,
trusted: true,
persisted: false,
});
mockWorkspace.refreshCapabilities.mockResolvedValue({
features: ['scratch_workspace_registration'],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
{
id: 'scratch-2',
cwd: '/managed/scratch-2',
primary: false,
trusted: true,
},
],
});
await act(async () => {
ackButton?.click();
await vi.waitFor(() => {
expect(document.body.textContent).not.toContain(
'I checked the workspace list',
);
});
});
await act(async () => {
testState.latestChatEditorProps?.onCreateScratchWorkspace?.();
await vi.waitFor(() => {
expect(mockWorkspaceActions.addScratchWorkspace).toHaveBeenCalledTimes(
2,
);
});
});
});
it('reports a definitive 4xx rejection without locking scratch creation', async () => {
mockWorkspace.capabilities = {
features: ['scratch_workspace_registration'],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
const { DaemonHttpError: MockDaemonHttpError } = await import(
'@qwen-code/sdk/daemon'
);
mockWorkspaceActions.addScratchWorkspace.mockRejectedValue(
new MockDaemonHttpError(403, {}, 'Forbidden'),
);
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
renderApp();
await flush();
await act(async () => {
testState.latestChatEditorProps?.onCreateScratchWorkspace?.();
await vi.waitFor(() => {
expect(mockWorkspaceActions.addScratchWorkspace).toHaveBeenCalledOnce();
});
});
await flush();
expect(consoleError).toHaveBeenCalledWith(
'[web-shell]',
expect.stringContaining('Forbidden'),
expect.anything(),
);
expect(document.body.textContent).not.toContain(
'I checked the workspace list',
);
mockWorkspaceActions.addScratchWorkspace.mockResolvedValue({
id: 'scratch-3',
cwd: '/managed/scratch-3',
primary: false,
trusted: true,
persisted: false,
});
mockWorkspace.refreshCapabilities.mockResolvedValue({
features: ['scratch_workspace_registration'],
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
{
id: 'scratch-3',
cwd: '/managed/scratch-3',
primary: false,
trusted: true,
},
],
});
await act(async () => {
testState.latestChatEditorProps?.onCreateScratchWorkspace?.();
await vi.waitFor(() => {
expect(mockWorkspaceActions.addScratchWorkspace).toHaveBeenCalledTimes(
2,
);
});
});
consoleError.mockRestore();
});
it('falls back to primary when the draft workspace becomes untrusted', async () => {
mockConnection.sessionId = undefined;
mockWorkspace.capabilities = {
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
{
id: 'secondary',
cwd: '/work/secondary',
primary: false,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
const view = renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onSelectWorkspace?.('/work/secondary');
});
expect(testState.latestChatEditorProps?.selectedWorkspaceCwd).toBe(
'/work/secondary',
);
mockWorkspace.capabilities = {
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
{
id: 'secondary',
cwd: '/work/secondary',
primary: false,
trusted: false,
},
],
} as typeof mockWorkspace.capabilities;
view.rerender();
await flush();
expect(
testState.latestChatEditorProps?.selectedWorkspaceCwd,
).toBeUndefined();
await act(async () => {
testState.latestChatEditorProps?.onSubmit('primary prompt');
await vi.waitFor(() => {
expect(mockSessionActions.createSession).toHaveBeenCalled();
});
});
expect(mockSessionActions.createSession).toHaveBeenCalledWith(
expect.objectContaining({ workspaceCwd: '/tmp/project' }),
);
});
it('revalidates a draft workspace before its cleanup effect runs', async () => {
mockConnection.sessionId = undefined;
const secondaryWorkspace = {
id: 'secondary',
cwd: '/work/secondary',
primary: false,
trusted: true,
};
mockWorkspace.capabilities = {
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
secondaryWorkspace,
],
} as typeof mockWorkspace.capabilities;
renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onSelectWorkspace?.('/work/secondary');
});
// Mutate the accepted snapshot in place so the selection ref remains stale
// and the create-time trust guard, rather than the cleanup effect, is tested.
secondaryWorkspace.trusted = false;
await act(async () => {
testState.latestChatEditorProps?.onSubmit('primary prompt');
await vi.waitFor(() => {
expect(mockSessionActions.createSession).toHaveBeenCalled();
});
});
expect(mockSessionActions.createSession).toHaveBeenCalledWith(
expect.objectContaining({ workspaceCwd: '/tmp/project' }),
);
});
it('does not start a new chat when selecting the active workspace', async () => {
mockConnection.workspaceCwd = '/tmp/project';
mockWorkspace.capabilities = {
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onSelectWorkspace?.(undefined);
});
expect(mockSessionActions.clearSession).not.toHaveBeenCalled();
});
it('starts a fresh chat when an active session selects a different trusted workspace', async () => {
mockConnection.sessionId = 'session-1';
mockConnection.workspaceCwd = '/tmp/project';
mockWorkspace.capabilities = {
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
{
id: 'secondary',
cwd: '/work/secondary',
primary: false,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
renderApp();
await flush();
mockSessionActions.clearSession.mockClear();
await act(async () => {
testState.latestChatEditorProps?.onSelectWorkspace?.('/work/secondary');
await vi.waitFor(() => {
expect(mockSessionActions.clearSession).toHaveBeenCalled();
});
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
});
it('executes one clear when two workspace-switch intents arrive in the same tick', async () => {
mockConnection.sessionId = 'session-1';
mockConnection.workspaceCwd = '/tmp/project';
mockWorkspace.capabilities = {
workspaces: [
{
id: 'primary',
cwd: '/tmp/project',
primary: true,
trusted: true,
},
{
id: 'secondary',
cwd: '/work/secondary',
primary: false,
trusted: true,
},
{
id: 'tertiary',
cwd: '/work/tertiary',
primary: false,
trusted: true,
},
],
} as typeof mockWorkspace.capabilities;
renderApp();
await flush();
mockSessionActions.clearSession.mockClear();
act(() => {
testState.latestChatEditorProps?.onSelectWorkspace?.('/work/secondary');
testState.latestChatEditorProps?.onSelectWorkspace?.('/work/tertiary');
});
await flush();
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
});
it('creates new sessions in the locked workspace without a selector', async () => {
mockConnection.sessionId = undefined;
mockWorkspace.capabilities = {
workspaces: [
{ id: 'primary', cwd: '/workspace', primary: true },
{ id: 'secondary', cwd: '/work/secondary', primary: false },
],
};
renderApp({ lockedWorkspaceCwd: '/work/secondary' });
await flush();
expect(testState.latestChatEditorProps?.workspaces).toBeUndefined();
expect(testState.latestChatEditorProps?.atWorkspaceCwd).toBe(
'/work/secondary',
);
await act(async () => {
testState.latestChatEditorProps?.onSubmit('locked prompt');
await vi.waitFor(() => {
expect(mockSessionActions.createSession).toHaveBeenCalled();
});
});
expect(mockSessionActions.createSession).toHaveBeenCalledWith(
expect.objectContaining({ workspaceCwd: '/work/secondary' }),
);
});
describe('worktree welcome toggle', () => {
beforeEach(() => {
mockConnection.sessionId = undefined;
mockWorkspace.capabilities = {
workspaces: [
{ id: 'primary', cwd: '/workspace', primary: true, trusted: true },
],
};
mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({
workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }),
workspaceSkills: mockWorkspaceActions.loadSkillsStatus,
}));
});
const toggleSelector = '[data-testid="worktree-welcome-toggle"]';
const cancelSelector = '[data-testid="worktree-welcome-cancel"]';
const badgeDesc = 'Changes happen';
async function waitForToggle(container: HTMLElement): Promise<void> {
await vi.waitFor(() => {
expect(container.querySelector(toggleSelector)).not.toBeNull();
});
}
async function clickButton(
container: HTMLElement,
selector: string,
): Promise<void> {
await act(async () => {
container.querySelector<HTMLButtonElement>(selector)?.click();
});
}
it('shows the toggle in the empty state for a trusted git workspace', async () => {
const { container } = renderApp({ showWorktreeToggle: true });
await waitForToggle(container);
});
it('hides the toggle for an untrusted workspace', async () => {
mockWorkspace.capabilities = {
workspaces: [
{ id: 'primary', cwd: '/workspace', primary: true, trusted: false },
],
};
const { container } = renderApp({ showWorktreeToggle: true });
await flush();
await flush();
expect(container.querySelector(toggleSelector)).toBeNull();
});
it('hides the toggle when the workspace is not a git repository', async () => {
mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({
workspaceGit: vi.fn().mockRejectedValue(new Error('not a git repo')),
workspaceSkills: mockWorkspaceActions.loadSkillsStatus,
}));
const { container } = renderApp({ showWorktreeToggle: true });
await flush();
await flush();
expect(container.querySelector(toggleSelector)).toBeNull();
});
it('toggles the pending badge on and off', async () => {
const { container } = renderApp({ showWorktreeToggle: true });
await waitForToggle(container);
await clickButton(container, toggleSelector);
expect(container.textContent).toContain(badgeDesc);
expect(container.querySelector(toggleSelector)).toBeNull();
await clickButton(container, cancelSelector);
expect(container.textContent).not.toContain(badgeDesc);
expect(container.querySelector(toggleSelector)).not.toBeNull();
});
it('creates the session with worktree when the toggle is enabled', async () => {
const { container } = renderApp({ showWorktreeToggle: true });
await waitForToggle(container);
await clickButton(container, toggleSelector);
await act(async () => {
testState.latestChatEditorProps?.onSubmit('work in isolation');
await vi.waitFor(() => {
expect(mockSessionActions.createSession).toHaveBeenCalled();
});
});
const arg = mockSessionActions.createSession.mock.calls[0]?.[0] as
| Record<string, unknown>
| undefined;
expect(arg?.['worktree']).toEqual({});
});
it('creates the session without worktree when the toggle is off', async () => {
renderApp({ showWorktreeToggle: true });
await flush();
await act(async () => {
testState.latestChatEditorProps?.onSubmit('regular session');
await vi.waitFor(() => {
expect(mockSessionActions.createSession).toHaveBeenCalled();
});
});
const arg = mockSessionActions.createSession.mock.calls[0]?.[0] as
| Record<string, unknown>
| undefined;
expect(arg?.['worktree']).toBeUndefined();
});
it('clears the pending worktree intent when starting a new session from the sidebar', async () => {
const { container } = renderApp({ showWorktreeToggle: true });
await waitForToggle(container);
await clickButton(container, toggleSelector);
expect(container.textContent).toContain(badgeDesc);
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="new-session"]')
?.click();
await Promise.resolve();
});
await act(async () => {
testState.latestChatEditorProps?.onSubmit('regular session');
await vi.waitFor(() => {
expect(mockSessionActions.createSession).toHaveBeenCalled();
});
});
const arg = mockSessionActions.createSession.mock.calls[0]?.[0] as
| Record<string, unknown>
| undefined;
expect(arg?.['worktree']).toBeUndefined();
});
});
it('reloads skills from the target workspace when starting a new session', async () => {
const { container } = renderApp({
lockedWorkspaceCwd: '/work/secondary',
});
await flush();
mockWorkspace.client.workspaceByCwd.mockClear();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="new-session"]')
?.click();
await Promise.resolve();
});
expect(mockWorkspace.client.workspaceByCwd).toHaveBeenCalledWith(
'/work/secondary',
);
});
it('uses a registered capability fallback while the workspace list is stale', async () => {
mockConnection.sessionId = undefined;
mockWorkspace.capabilities = {
workspaceCwd: '/workspace',
workspaces: undefined,
};
const lockedWorkspaceCapability = {
id: 'secondary',
cwd: '/work/secondary',
primary: false,
trusted: true,
};
testState.prompt = '/schedule';
const { container } = renderApp({
lockedWorkspaceCwd: '/work/secondary',
lockedWorkspaceCapability,
});
await flush();
await clickSubmit(container);
await flush();
expect(testState.latestScheduledTasksProps?.workspaces).toEqual([
lockedWorkspaceCapability,
]);
expect(testState.latestScheduledTasksProps?.lockedWorkspace).toEqual(
lockedWorkspaceCapability,
);
});
it('uses configured composer placeholders by state and falls back for blank values', async () => {
const composerPlaceholders = {
idle: 'Ask a question',
loading: 'Preparing chat',
processing: 'Working on it',
};
const { rerender } = renderApp({ composerPlaceholders });
await flush();
expect(testState.latestChatEditorProps?.placeholderText).toBe(
'Ask a question',
);
testState.streamingState = 'responding';
rerender({ composerPlaceholders });
await flush();
expect(testState.latestChatEditorProps?.placeholderText).toBe(
'Working on it',
);
rerender({ composerPlaceholders: { idle: 'Ask a question' } });
await flush();
expect(testState.latestChatEditorProps?.placeholderText).toBe(
'Processing. New messages will be queued.',
);
mockConnection.catchingUp = true;
rerender({ composerPlaceholders });
await flush();
expect(testState.latestChatEditorProps?.placeholderText).toBe(
'Preparing chat',
);
mockConnection.catchingUp = false;
testState.streamingState = 'idle';
rerender({ composerPlaceholders: { idle: ' ' } });
await flush();
expect(testState.latestChatEditorProps?.placeholderText).toBe(
'Type a message or @ file path',
);
});
it('filters disabled skills from the web-shell skills list', async () => {
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({
skills: [
{
name: 'enabled-skill',
description: 'Enabled',
status: 'ok',
},
{
name: 'disabled-extension-skill',
description: 'Disabled',
status: 'disabled',
},
],
});
renderApp();
await flush();
expect(testState.latestChatEditorProps?.skills).toEqual([
{ name: 'enabled-skill', description: 'Enabled' },
]);
});
it('reloads skills when starting a new session', async () => {
mockConnection.commands = [
{
name: 'review',
description: 'Review',
raw: {
name: 'review',
description: 'Review',
input: null,
_meta: { source: 'skill' },
},
},
];
mockConnection.skills = ['review'];
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({
skills: [{ name: 'review', description: 'Review', status: 'ok' }],
});
const { container } = renderApp();
await flush();
expect(testState.latestChatEditorProps?.skills).toEqual([
{ name: 'review', description: 'Review' },
]);
expect(testState.latestChatEditorProps?.commands).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'review' })]),
);
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({
skills: [{ name: 'review', description: 'Review', status: 'disabled' }],
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="new-session"]')
?.click();
await Promise.resolve();
});
expect(testState.latestChatEditorProps?.skills).toEqual([]);
expect(testState.latestChatEditorProps?.commands).not.toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'review' })]),
);
expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(2);
});
it('adds an enabled skill command when starting a new session', async () => {
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({
skills: [{ name: 'review', description: 'Review', status: 'disabled' }],
});
const { container } = renderApp();
await flush();
expect(testState.latestChatEditorProps?.commands).not.toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'review' })]),
);
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({
skills: [
{
name: 'review',
description: 'Review',
argumentHint: '<path>',
status: 'ok',
},
],
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="new-session"]')
?.click();
await Promise.resolve();
});
expect(testState.latestChatEditorProps?.commands).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'review',
argumentHint: '<path>',
source: 'skill',
}),
]),
);
});
it.each([404, 410])(
'shows a missing-session empty state with a new-session action for %d',
async (status) => {
mockConnection.status = 'disconnected';
mockConnection.sessionId = undefined;
mockConnection.error = 'Session load failed';
mockConnection.errorStatus = status;
mockConnection.missingSession = true;
const onSessionIdChange = vi.fn();
const { container } = renderApp({
onSessionIdChange,
});
await flush();
expect(container.textContent).toContain('Current session does not exist');
const submit = container.querySelector('[data-testid="submit"]');
expect(submit?.closest('[class*="chatSubtreeHidden"]')).not.toBeNull();
expect(onSessionIdChange).not.toHaveBeenCalledWith(undefined);
await act(async () => {
Array.from(container.querySelectorAll('button'))
.find((button) => button.textContent === 'New session')
?.click();
await Promise.resolve();
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
expect(mockSessionActions.createSession).not.toHaveBeenCalled();
expect(mockSessionActions.attachSession).not.toHaveBeenCalled();
expect(onSessionIdChange).toHaveBeenCalledWith(undefined);
expect(onSessionIdChange).toHaveBeenCalledTimes(1);
},
);
it('focuses the composer after starting a new session', async () => {
const { container } = renderApp();
await flush();
editorFocus.mockClear();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="new-session"]')
?.click();
await Promise.resolve();
});
expect(mockSessionActions.clearSession).toHaveBeenCalledOnce();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(editorFocus).toHaveBeenCalledOnce();
});
it('focuses a cleared new session without waiting for detach', async () => {
const clear = deferred<void>();
mockSessionActions.clearSession.mockReturnValueOnce(clear.promise);
const { container } = renderApp();
await flush();
editorFocus.mockClear();
act(() => {
container
.querySelector<HTMLButtonElement>('[data-testid="new-session"]')
?.click();
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(editorFocus).toHaveBeenCalledOnce();
await act(async () => clear.resolve());
});
it('focuses the composer after loading an existing session', async () => {
const { container, rerender } = renderApp();
await flush();
editorFocus.mockClear();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="load-session"]')
?.click();
await Promise.resolve();
});
expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-2', {
workspaceCwd: undefined,
});
mockConnection.sessionId = 'session-2';
rerender();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(editorFocus).toHaveBeenCalledOnce();
});
it('does not steal focus when an approval appears before deferred session focus', async () => {
vi.useFakeTimers();
const { container, rerender } = renderApp();
await flush();
editorFocus.mockClear();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="load-session"]')
?.click();
await Promise.resolve();
});
mockConnection.sessionId = 'session-2';
rerender();
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
editorFocus.mockClear();
act(() => vi.runOnlyPendingTimers());
// The editor isn't refocused while an approval is pending; instead the app
// tells the approval overlay to take focus (keyboardActive), so a stray
// keystroke can't send a message past the pending approval.
expect(editorFocus).not.toHaveBeenCalled();
expect(
document.querySelector('[data-testid="approval-overlay"]'),
).not.toBeNull();
expect(testState.latestToolApprovalKeyboardActive).toBe(true);
});
it('does not show missing-session state for non-404/410 errors', async () => {
mockConnection.status = 'disconnected';
mockConnection.sessionId = undefined;
mockConnection.error = 'Server error';
mockConnection.errorStatus = 500;
mockConnection.missingSession = false;
const { container } = renderApp({ onSessionIdChange: vi.fn() });
await flush();
expect(container.textContent).not.toContain(
'Current session does not exist',
);
});
it('does not show missing-session state while connecting', async () => {
mockConnection.status = 'connecting';
mockConnection.sessionId = undefined;
mockConnection.error = 'Session load failed';
mockConnection.errorStatus = 404;
mockConnection.missingSession = true;
const { container } = renderApp({ onSessionIdChange: vi.fn() });
await flush();
expect(container.textContent).not.toContain(
'Current session does not exist',
);
});
it('does not notify session change when missing-session new chat fails', async () => {
mockConnection.status = 'disconnected';
mockConnection.sessionId = undefined;
mockConnection.error = 'Session load failed';
mockConnection.errorStatus = 404;
mockConnection.missingSession = true;
mockSessionActions.clearSession.mockRejectedValueOnce(new Error('network'));
const onSessionIdChange = vi.fn();
const { container } = renderApp({ onSessionIdChange });
await flush();
await act(async () => {
Array.from(container.querySelectorAll('button'))
.find((button) => button.textContent === 'New session')
?.click();
await Promise.resolve();
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
expect(onSessionIdChange).not.toHaveBeenCalled();
});
it('preserves active goal for the same session and clears it after session changes', async () => {
const activeGoals: unknown[] = [];
const { rerender } = renderApp({
renderFooter: (props) => {
activeGoals.push(props.activeGoal);
return null;
},
});
await flush();
await act(async () => {
window.dispatchEvent(
new CustomEvent('web-shell-goal-status-active', {
detail: {
active: true,
condition: 'ship it',
setAt: 123,
},
}),
);
await Promise.resolve();
});
expect(activeGoals.at(-1)).toMatchObject({
condition: 'ship it',
setAt: 123,
});
mockConnection.errorStatus = 404;
rerender({
renderFooter: (props) => {
activeGoals.push(props.activeGoal);
return null;
},
});
await flush();
expect(activeGoals.at(-1)).toMatchObject({
condition: 'ship it',
setAt: 123,
});
mockConnection.sessionId = 'session-2';
rerender({
renderFooter: (props) => {
activeGoals.push(props.activeGoal);
return null;
},
});
await flush();
expect(activeGoals.at(-1)).toBeNull();
});
it('gates direct submissions and dispatches submit events with delayed sidebar reload', async () => {
vi.useFakeTimers();
const onSubmitBefore = vi.fn().mockResolvedValue(undefined);
const onSessionChange = vi.fn();
const { container } = renderApp({ onSubmitBefore, onSessionChange });
await flush();
await clickSubmit(container);
await flush();
expect(onSubmitBefore).toHaveBeenCalledWith({
sessionId: 'session-1',
prompt: 'hello',
});
expect(mockFollowup.clear).toHaveBeenCalledTimes(1);
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ retry: undefined }),
);
expect(editorCommit).toHaveBeenCalledTimes(1);
expect(editorClear).not.toHaveBeenCalled();
expect(onSessionChange).toHaveBeenCalledWith({
type: 'submit',
sessionId: 'session-1',
prompt: 'hello',
queued: false,
});
const tokenAfterSubmit = sidebarTokens.at(-1);
act(() => {
vi.advanceTimersByTime(2000);
});
expect(sidebarTokens.at(-1)).not.toBe(tokenAfterSubmit);
});
it('does not suggest a new session for obvious follow-up prompts', async () => {
vi.useFakeTimers();
mockConnection.capabilities.features = ['session_generation'];
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).tokenCount = 600;
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).contextWindow = 1000;
testState.messages = Array.from({ length: 8 }, (_, index) => ({
id: `m-followup-${index}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `existing session topic ${index} about daemon generation review work`,
timestamp: index,
}));
testState.prompt = '顺手补个测试';
const { container } = renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onInputTextChange?.(testState.prompt);
});
await flush();
act(() => {
vi.advanceTimersByTime(701);
});
await flush();
expect(
container.querySelector('[data-testid="new-session-suggestion"]'),
).toBeNull();
expect(mockSessionActions.generateSessionContent).not.toHaveBeenCalled();
});
it('suggests starting a new session for a new-topic prompt and auto-sends it in the fresh session', async () => {
vi.useFakeTimers();
mockConnection.capabilities.features = ['session_generation'];
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).tokenCount = 600;
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).contextWindow = 1000;
testState.messages = Array.from({ length: 8 }, (_, index) => ({
id: `m-${index}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `existing session topic ${index} about daemon generation review work`,
timestamp: index,
}));
const suggestedPrompt =
'Help me brainstorm Web Shell interaction ideas on top of this interface for a design doc';
testState.prompt = suggestedPrompt;
testState.promptImages = [{ data: 'abc', media_type: 'image/png' }];
mockSessionActions.clearSession.mockImplementation(async () => {
mockConnection.sessionId = undefined;
});
mockSessionActions.createSession.mockImplementation(async () => {
mockConnection.sessionId = 'session-created';
return { sessionId: 'session-created' };
});
mockSessionActions.generateSessionContent.mockImplementation(
async function* () {
yield {
type: 'delta',
requestId: 'req-1',
seq: 0,
text: JSON.stringify({
shouldSuggestNewSession: true,
confidence: 0.91,
}),
};
yield {
type: 'done',
requestId: 'req-1',
model: 'fast-model',
modelSource: 'fast',
};
},
);
const { container } = renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onInputTextChange?.(testState.prompt);
});
await flush();
act(() => {
vi.advanceTimersByTime(121);
});
await flush();
act(() => {
vi.advanceTimersByTime(701);
});
await flush();
expect(
container.querySelector('[data-testid="new-session-suggestion"]')
?.textContent,
).toContain('This looks like a new topic');
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="new-session-suggestion-start"]',
)
?.click();
await Promise.resolve();
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
act(() => {
vi.runOnlyPendingTimers();
});
await flush();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
suggestedPrompt,
expect.objectContaining({
images: [{ data: 'abc', media_type: 'image/png' }],
}),
);
expect(editorInsertText).not.toHaveBeenCalled();
});
it('waits for the current session to detach before auto-submitting the suggested new-session draft', async () => {
vi.useFakeTimers();
const clear = deferred<void>();
mockConnection.capabilities.features = ['session_generation'];
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).tokenCount = 600;
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).contextWindow = 1000;
testState.messages = Array.from({ length: 8 }, (_, index) => ({
id: `m-delayed-${index}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `existing session topic ${index} about daemon generation review work`,
timestamp: index,
}));
const delayedPrompt =
'Help me brainstorm Web Shell interaction ideas on top of this interface for a design doc';
testState.prompt = delayedPrompt;
mockSessionActions.clearSession.mockImplementation(() => {
mockConnection.sessionId = undefined;
return clear.promise;
});
mockSessionActions.generateSessionContent.mockImplementation(
async function* () {
yield {
type: 'delta',
requestId: 'req-2',
seq: 0,
text: JSON.stringify({
shouldSuggestNewSession: true,
confidence: 0.91,
}),
};
yield {
type: 'done',
requestId: 'req-2',
model: 'fast-model',
modelSource: 'fast',
};
},
);
const { container, rerender } = renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onInputTextChange?.(testState.prompt);
});
await flush();
act(() => {
vi.advanceTimersByTime(121);
});
await flush();
act(() => {
vi.advanceTimersByTime(701);
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="new-session-suggestion-start"]',
)
?.click();
await Promise.resolve();
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
rerender();
await flush();
act(() => {
vi.runOnlyPendingTimers();
});
await flush();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
await act(async () => clear.resolve());
act(() => {
vi.runOnlyPendingTimers();
});
await flush();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
delayedPrompt,
expect.any(Object),
);
});
it('dismisses a stale new-session suggestion after the draft changes before acceptance', async () => {
vi.useFakeTimers();
mockConnection.capabilities.features = ['session_generation'];
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).tokenCount = 600;
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).contextWindow = 1000;
testState.messages = Array.from({ length: 8 }, (_, index) => ({
id: `m-stale-${index}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `existing session topic ${index} about daemon generation review work`,
timestamp: index,
}));
testState.prompt =
'Help me brainstorm Web Shell interaction ideas on top of this interface for a design doc';
mockSessionActions.generateSessionContent.mockImplementation(
async function* () {
yield {
type: 'delta',
requestId: 'req-stale',
seq: 0,
text: JSON.stringify({
shouldSuggestNewSession: true,
confidence: 0.91,
}),
};
yield {
type: 'done',
requestId: 'req-stale',
model: 'fast-model',
modelSource: 'fast',
};
},
);
const { container } = renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onInputTextChange?.(testState.prompt);
});
await flush();
act(() => {
vi.advanceTimersByTime(121);
});
await flush();
act(() => {
vi.advanceTimersByTime(701);
});
await flush();
expect(
container.querySelector('[data-testid="new-session-suggestion"]'),
).not.toBeNull();
testState.prompt = '顺手补个测试并继续当前实现';
act(() => {
testState.latestChatEditorProps?.onInputTextChange?.(testState.prompt);
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="new-session-suggestion-start"]',
)
?.click();
await Promise.resolve();
});
await flush();
expect(mockSessionActions.clearSession).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(
container.querySelector('[data-testid="new-session-suggestion"]'),
).toBeNull();
});
it('cancels the pending new-session auto-submit when another session becomes active first', async () => {
vi.useFakeTimers();
const clear = deferred<void>();
mockConnection.capabilities.features = ['session_generation'];
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).tokenCount = 600;
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).contextWindow = 1000;
testState.messages = Array.from({ length: 8 }, (_, index) => ({
id: `m-switch-${index}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `existing session topic ${index} about daemon generation review work`,
timestamp: index,
}));
testState.prompt =
'Help me brainstorm Web Shell interaction ideas on top of this interface for a design doc';
mockSessionActions.clearSession.mockImplementation(() => clear.promise);
mockSessionActions.generateSessionContent.mockImplementation(
async function* () {
yield {
type: 'delta',
requestId: 'req-switch',
seq: 0,
text: JSON.stringify({
shouldSuggestNewSession: true,
confidence: 0.91,
}),
};
yield {
type: 'done',
requestId: 'req-switch',
model: 'fast-model',
modelSource: 'fast',
};
},
);
const { container, rerender } = renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onInputTextChange?.(testState.prompt);
});
await flush();
act(() => {
vi.advanceTimersByTime(121);
});
await flush();
act(() => {
vi.advanceTimersByTime(701);
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="new-session-suggestion-start"]',
)
?.click();
await Promise.resolve();
});
mockConnection.sessionId = 'session-other';
rerender();
await flush();
await act(async () => clear.resolve());
act(() => {
vi.runOnlyPendingTimers();
});
await flush();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('keeps concurrent programmatic submissions behind session preparation', async () => {
mockConnection.sessionId = undefined;
const callbackStarted = deferred<void>();
const callbackFinished = deferred<void>();
mockSessionActions.createSession.mockImplementation(async () => {
mockConnection.sessionId = 'session-created';
return { sessionId: 'session-created' };
});
const onSessionCreated = vi.fn(async () => {
callbackStarted.resolve();
await callbackFinished.promise;
});
renderApp({ onSessionCreated });
await flush();
await act(async () => {
testState.latestChatEditorProps?.onSubmit('first');
await callbackStarted.promise;
});
await act(async () => {
testState.latestChatEditorProps?.onSubmit('second');
await Promise.resolve();
});
expect(mockSessionActions.createSession).toHaveBeenCalledOnce();
expect(mockSessionActions.attachSession).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
await act(async () => {
callbackFinished.resolve();
await vi.waitFor(() => {
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(2);
});
});
expect(mockSessionActions.attachSession).toHaveBeenCalledOnce();
});
it('lets a selected session bypass a stale preparation promise', async () => {
mockConnection.sessionId = undefined;
const callbackStarted = deferred<void>();
const callbackFinished = deferred<void>();
mockSessionActions.createSession.mockImplementation(async () => {
mockConnection.sessionId = 'session-created';
return { sessionId: 'session-created' };
});
const onSessionCreated = vi.fn(async () => {
callbackStarted.resolve();
await callbackFinished.promise;
});
const { rerender } = renderApp({ onSessionCreated });
await flush();
await act(async () => {
testState.latestChatEditorProps?.onSubmit('first');
await callbackStarted.promise;
});
mockConnection.sessionId = 'session-selected';
rerender({ onSessionCreated });
await act(async () => {
testState.latestChatEditorProps?.onSubmit('second');
await vi.waitFor(() => {
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1);
});
});
expect(mockSessionActions.attachSession).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'second',
expect.any(Object),
);
await act(async () => {
callbackFinished.resolve();
await vi.waitFor(() => {
expect(mockSessionActions.releaseSession).toHaveBeenCalledWith(
'session-created',
);
});
});
expect(mockSessionActions.attachSession).not.toHaveBeenCalled();
expect(mockSessionActions.clearSession).not.toHaveBeenCalled();
});
it('lets a selected session bypass creation before its id is allocated', async () => {
mockConnection.sessionId = undefined;
const creationFinished = deferred<{ sessionId: string }>();
mockSessionActions.createSession.mockImplementation(
() => creationFinished.promise,
);
const { rerender } = renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onSubmit('first');
});
await vi.waitFor(() => {
expect(mockSessionActions.createSession).toHaveBeenCalledOnce();
});
mockConnection.sessionId = 'session-selected';
rerender();
await act(async () => {
testState.latestChatEditorProps?.onSubmit('second');
await vi.waitFor(() => {
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1);
});
});
expect(mockSessionActions.attachSession).not.toHaveBeenCalled();
await act(async () => {
creationFinished.resolve({ sessionId: 'session-created' });
await vi.waitFor(() => {
expect(mockSessionActions.releaseSession).toHaveBeenCalledWith(
'session-created',
);
});
});
expect(mockSessionActions.attachSession).not.toHaveBeenCalled();
expect(mockSessionActions.clearSession).not.toHaveBeenCalled();
});
it('clears a shared rejected preparation so a later submit can retry', async () => {
mockConnection.sessionId = undefined;
const firstCreation = deferred<{ sessionId: string }>();
mockSessionActions.createSession
.mockImplementationOnce(() => firstCreation.promise)
.mockImplementationOnce(async () => {
mockConnection.sessionId = 'session-retry';
return { sessionId: 'session-retry' };
});
renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onSubmit('first');
testState.latestChatEditorProps?.onSubmit('second');
});
await vi.waitFor(() => {
expect(mockSessionActions.createSession).toHaveBeenCalledOnce();
});
firstCreation.reject(new Error('create failed'));
await flush();
await flush();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
await act(async () => {
testState.latestChatEditorProps?.onSubmit('third');
await vi.waitFor(() => {
expect(mockSessionActions.createSession).toHaveBeenCalledTimes(2);
});
await vi.waitFor(() => {
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(1);
});
});
});
it('cancels direct submissions when onSubmitBefore rejects and preserves retry state', async () => {
const onSubmitBefore = vi.fn((params: { prompt: string }) =>
params.prompt === 'blocked'
? Promise.reject(new Error('blocked'))
: Promise.resolve(),
);
const { container, rerender } = renderApp({ onSubmitBefore });
await flush();
testState.prompt = 'first';
await clickSubmit(container);
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'first',
expect.objectContaining({ retry: undefined }),
);
act(() => {
testState.blocks = [
{ kind: 'error', source: 'turn_error', id: 'turn-error-1' },
];
rerender({ onSubmitBefore });
});
expect(container.querySelector('[data-testid="retry"]')).not.toBeNull();
mockSessionActions.sendPrompt.mockClear();
editorClear.mockClear();
editorCommit.mockClear();
testState.prompt = 'blocked';
await clickSubmit(container);
await flush();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(mockFollowup.clear).toHaveBeenCalledTimes(1);
expect(editorClear).toHaveBeenCalledTimes(0);
expect(editorCommit).toHaveBeenCalledTimes(0);
expect(testState.latestChatEditorProps?.isPreparing).toBe(false);
expect(container.querySelector('[data-testid="retry"]')).not.toBeNull();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="retry"]')
?.click();
await Promise.resolve();
});
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'first',
expect.objectContaining({ retry: true }),
);
});
it('allows manual retry after a model stream interrupted turn error', async () => {
const { container, rerender } = renderApp();
await flush();
testState.prompt = 'recover this stream';
await clickSubmit(container);
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'recover this stream',
expect.objectContaining({ retry: undefined }),
);
mockSessionActions.sendPrompt.mockClear();
act(() => {
testState.blocks = [
{
kind: 'error',
source: 'turn_error',
id: 'turn-error-stream-interrupted',
errorKind: 'model_stream_interrupted',
text: 'terminated',
},
];
rerender();
});
expect(container.querySelector('[data-testid="retry"]')).not.toBeNull();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="retry"]')
?.click();
await Promise.resolve();
});
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'recover this stream',
expect.objectContaining({
optimisticUserMessage: false,
retry: true,
}),
);
});
it('gates queued submissions and only enqueues after approval', async () => {
let approve: (() => void) | undefined;
const onSubmitBefore = vi.fn(
() =>
new Promise<void>((resolve) => {
approve = resolve;
}),
);
const onSessionChange = vi.fn();
const { container, rerender } = renderApp({
onSubmitBefore,
onSessionChange,
});
await flush();
act(() => {
testState.streamingState = 'responding';
rerender({ onSubmitBefore, onSessionChange });
});
testState.prompt = 'queued';
await clickSubmit(container);
expect(rawEnqueuePrompt).not.toHaveBeenCalled();
expect(editorClear).not.toHaveBeenCalled();
expect(editorCommit).not.toHaveBeenCalled();
await act(async () => {
approve?.();
await Promise.resolve();
});
expect(rawEnqueuePrompt).toHaveBeenCalledWith(
'queued',
undefined,
undefined,
undefined,
);
expect(onSessionChange).toHaveBeenCalledWith({
type: 'submit',
sessionId: 'session-1',
prompt: 'queued',
queued: true,
});
expect(editorCommit).toHaveBeenCalledTimes(1);
expect(editorClear).not.toHaveBeenCalled();
});
it('cancels queued submissions when onSubmitBefore rejects', async () => {
const onSubmitBefore = vi.fn().mockRejectedValue(new Error('blocked'));
const { container, rerender } = renderApp({ onSubmitBefore });
await flush();
act(() => {
testState.streamingState = 'responding';
rerender({ onSubmitBefore });
});
await clickSubmit(container);
await flush();
expect(rawEnqueuePrompt).not.toHaveBeenCalled();
expect(editorClear).not.toHaveBeenCalled();
expect(editorCommit).not.toHaveBeenCalled();
});
it('keeps daemon-bound slash command drafts when onSubmitBefore rejects', async () => {
const onSubmitBefore = vi.fn().mockRejectedValue(new Error('blocked'));
const { container } = renderApp({ onSubmitBefore });
await flush();
testState.prompt = '/goal ship it';
await clickSubmit(container);
await flush();
expect(onSubmitBefore).toHaveBeenCalledWith({
sessionId: 'session-1',
prompt: '/goal ship it',
});
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(editorCommit).not.toHaveBeenCalled();
expect(editorClear).not.toHaveBeenCalled();
});
it('notifies the host before forwarding a slash command', async () => {
const onSlashCommand = vi.fn();
const { container } = renderApp({ onSlashCommand });
await flush();
testState.prompt = '/Deploy staging';
await clickSubmit(container);
await flush();
expect(onSlashCommand).toHaveBeenCalledWith({
command: 'deploy',
args: 'staging',
input: '/Deploy staging',
});
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'/Deploy staging',
expect.any(Object),
);
});
it('lets the host handle a slash command instead of forwarding it', async () => {
const onSlashCommand = vi.fn(() => true);
const { container } = renderApp({ onSlashCommand });
await flush();
testState.prompt = '/deploy production';
await clickSubmit(container);
await flush();
expect(onSlashCommand).toHaveBeenCalledWith({
command: 'deploy',
args: 'production',
input: '/deploy production',
});
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('lets the host override a built-in slash command', async () => {
const onSlashCommand = vi.fn(() => true);
const { container } = renderApp({ onSlashCommand });
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(onSlashCommand).toHaveBeenCalledWith({
command: 'settings',
args: '',
input: '/settings',
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
});
it('does not treat an absolute path as a slash command', async () => {
const onSlashCommand = vi.fn(() => true);
const { container } = renderApp({ onSlashCommand });
await flush();
testState.prompt = '/usr/local/bin/tool';
await clickSubmit(container);
await flush();
expect(onSlashCommand).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'/usr/local/bin/tool',
expect.any(Object),
);
});
it('lets the host handle a slash command while the daemon is unavailable', async () => {
mockConnection.status = 'error';
const onSlashCommand = vi.fn(() => true);
const onToast = vi.fn();
const { container } = renderApp({ onSlashCommand, onToast });
await flush();
testState.prompt = '/deploy production';
await clickSubmit(container);
await flush();
expect(onSlashCommand).toHaveBeenCalledTimes(1);
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(onToast).not.toHaveBeenCalled();
});
it('reports a host slash command error and continues default handling', async () => {
const error = new Error('host handler exploded');
const onSlashCommand = vi.fn(() => {
throw error;
});
const onToast = vi.fn();
const { container } = renderApp({ onSlashCommand, onToast });
await flush();
testState.prompt = '/deploy staging';
await clickSubmit(container);
await flush();
expect(onToast).toHaveBeenCalledWith('error', 'host handler exploded');
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'/deploy staging',
expect.any(Object),
);
});
it('uses the latest slash command handler after a rerender', async () => {
const firstHandler = vi.fn();
const secondHandler = vi.fn(() => true);
const { container, rerender } = renderApp({
onSlashCommand: firstHandler,
});
await flush();
rerender({ onSlashCommand: secondHandler });
await flush();
testState.prompt = '/deploy staging';
await clickSubmit(container);
await flush();
expect(firstHandler).not.toHaveBeenCalled();
expect(secondHandler).toHaveBeenCalledTimes(1);
});
it('forwards input annotations for /plan prompts in active sessions', async () => {
const annotation: DaemonInputAnnotation = {
type: 'reference',
text: '@.husky/',
start: 0,
end: 8,
reference: {
id: '.husky/',
value: '.husky/',
serialized: '@.husky/',
},
};
const { container } = renderApp();
await flush();
testState.prompt = '/plan @.husky/ explain';
testState.inputAnnotations = [annotation];
await clickSubmit(container);
await flush();
expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('plan');
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'@.husky/ explain',
expect.objectContaining({
inputAnnotations: [annotation],
}),
);
});
it('dispatches turn_complete only for the session that was streaming', async () => {
const onSessionChange = vi.fn();
const { container, rerender } = renderApp({ onSessionChange });
await flush();
testState.prompt = 'first';
await clickSubmit(container);
onSessionChange.mockClear();
act(() => {
testState.streamingState = 'responding';
rerender({ onSessionChange });
});
act(() => {
testState.blocks = [
{ kind: 'error', source: 'turn_error', id: 'turn-error-1' },
];
testState.streamingState = 'idle';
rerender({ onSessionChange });
});
expect(onSessionChange).toHaveBeenCalledWith({
type: 'turn_complete',
sessionId: 'session-1',
error: expect.objectContaining({
message: 'Turn error (block turn-error-1)',
}),
});
onSessionChange.mockClear();
act(() => {
testState.streamingState = 'responding';
rerender({ onSessionChange });
});
act(() => {
mockConnection.sessionId = 'session-2';
testState.streamingState = 'idle';
rerender({ onSessionChange });
});
expect(onSessionChange).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'turn_complete' }),
);
});
it('auto-closes an open Settings/Status panel when a tool approval becomes pending', async () => {
// Regression: the approval overlay lives in the chat footer, which is
// hidden (display:none) while a panel is shown. If a gated tool call
// arrives while Settings/Status is open, the panel must step aside so the
// approval is visible instead of the turn hanging behind it.
const { container, rerender } = renderApp();
await flush();
// Open the Settings panel via the /settings command; the panel host carries
// data-testid="inline-panel", so its presence tracks the panel.
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
// A gated tool call arrives.
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
});
it('does not open the extensions manager page with /extension manage', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/extension manage';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="extensions-manager-page"]'),
).toBeNull();
});
it('opens the extensions manager page with /extensions manage', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/extensions manage';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="extensions-manager-page"]'),
).not.toBeNull();
const backButton = container.querySelector(
'[data-testid="extensions-manager-back"]',
);
expect(document.activeElement).not.toBe(backButton);
expect(document.activeElement).toBe(
container.querySelector('[data-testid="extensions-manager-heading"]'),
);
editorFocus.mockClear();
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="extensions-manager-back"]',
)
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="extensions-manager-page"]'),
).toBeNull();
expect(editorFocus).toHaveBeenCalled();
});
it.each(['/skills', '/skills detail', '/skills details'])(
'opens the Skill manager page with %s',
async (command) => {
const { container } = renderApp();
await flush();
testState.prompt = command;
await clickSubmit(container);
await flush();
expect(
container
.querySelector('[data-testid="inline-panel"]')
?.getAttribute('aria-label'),
).toBe('Skills');
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
},
);
it('opens plugin management tabs from the sidebar', async () => {
mockWorkspaceActions.loadMcpStatus.mockResolvedValue({
initialized: true,
discoveryState: 'completed',
servers: [],
});
const { container } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-plugins"]')
?.click();
await Promise.resolve();
});
await flush();
const panel = container.querySelector('[data-testid="inline-panel"]');
const extensionsTab =
panel?.querySelector<HTMLButtonElement>('button[role="tab"]');
const tabs =
panel?.querySelectorAll<HTMLButtonElement>('button[role="tab"]');
expect(panel?.getAttribute('aria-label')).toBe('Plugins');
expect(Array.from(tabs ?? []).map((tab) => tab.textContent)).toEqual([
'Extensions',
'MCP',
'Skills',
]);
expect(extensionsTab?.getAttribute('aria-selected')).toBe('true');
expect(document.activeElement).toBe(extensionsTab);
await act(async () => {
tabs?.[2]?.focus();
tabs?.[2]?.click();
await Promise.resolve();
});
expect(
panel
?.querySelectorAll<HTMLButtonElement>('button[role="tab"]')[2]
?.getAttribute('aria-selected'),
).toBe('true');
});
it('shadow-isolates only the plugin manager body when plugins is enabled', async () => {
const { container } = renderApp({
shadowDom: {
plugins: true,
portals: false,
styles: '.plugin-shadow-content { color: rebeccapurple; }',
},
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-plugins"]')
?.click();
await Promise.resolve();
});
await flush();
const panel = container.querySelector('[data-testid="inline-panel"]');
const host = panel?.querySelector<HTMLElement>(
'[data-web-shell-shadow-host="plugins"]',
);
const extensionsTab =
host?.shadowRoot?.querySelector<HTMLButtonElement>('button[role="tab"]');
expect(host?.shadowRoot).not.toBeNull();
expect(host?.shadowRoot?.firstElementChild?.tagName).toBe('STYLE');
expect(panel?.querySelector('button[role="tab"]')).toBeNull();
expect(extensionsTab?.textContent).toBe('Extensions');
expect(host?.shadowRoot?.activeElement).toBe(extensionsTab);
expect(
document.querySelector('[data-web-shell-portal-root]'),
).not.toBeNull();
});
it('uses one shadow root for all portals without moving plugin content', async () => {
const { container } = renderApp({
shadowDom: {
plugins: false,
portals: true,
styles: '.consumer-shadow-content { color: rebeccapurple; }',
},
style: {
'--web-shell-portal-root-z-index': '2345',
} as CSSProperties,
});
await flush();
const portalHost = document.querySelector<HTMLElement>(
'[data-web-shell-shadow-host="portals"]',
);
const portalRoot = portalHost?.shadowRoot?.querySelector(
'[data-web-shell-portal-root]',
);
expect(portalRoot).not.toBeNull();
expect(portalHost?.style.zIndex).toBe(
'var(--web-shell-portal-root-z-index, 1000)',
);
expect(portalHost?.style.getPropertyPriority('z-index')).toBe('important');
expect(
portalHost?.style.getPropertyValue('--web-shell-portal-root-z-index'),
).toBe('2345');
expect(portalHost?.shadowRoot?.firstElementChild?.tagName).toBe('STYLE');
expect(portalHost?.shadowRoot?.lastElementChild).toBe(portalRoot);
expect(document.querySelector('[data-web-shell-portal-root]')).toBeNull();
expect(
Array.from(portalHost?.shadowRoot?.querySelectorAll('style') ?? []).some(
(style) => style.textContent?.includes('.consumer-shadow-content'),
),
).toBe(true);
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-plugins"]')
?.click();
await Promise.resolve();
});
await flush();
const panel = container.querySelector('[data-testid="inline-panel"]');
expect(panel?.querySelector('button[role="tab"]')).not.toBeNull();
expect(
panel?.querySelector('[data-web-shell-shadow-host="plugins"]'),
).toBeNull();
});
it('only shows server startup progress during MCP discovery', async () => {
mockWorkspaceActions.loadMcpStatus.mockResolvedValue({
initialized: true,
discoveryState: 'starting',
servers: [
{
name: 'filesystem',
source: 'project',
configOrigin: 'workspace_settings',
disabled: false,
mcpStatus: 'connecting',
},
],
});
const { container } = renderApp();
await flush();
testState.prompt = '/mcp';
await clickSubmit(container);
await flush();
expect(container.textContent).toContain(
'MCP servers are starting up (1 initializing)',
);
expect(container.textContent).not.toContain('Loading MCP tools...');
expect(
container.querySelector('[role="button"][aria-label="filesystem"]'),
).toHaveProperty('tabIndex', 0);
});
it('shows server operations without duplicating tools and resources tabs', async () => {
mockWorkspaceActions.loadMcpStatus.mockResolvedValue({
initialized: true,
discoveryState: 'completed',
workspaceCwd: '/workspace',
servers: [
{
name: 'filesystem',
source: 'project',
configOrigin: 'workspace_settings',
disabled: false,
mcpStatus: 'disconnected',
resourceCount: 1,
removable: true,
},
],
});
mockMcp.loadTools.mockResolvedValue({
serverName: 'filesystem',
tools: [{ name: 'read_file', description: 'Read a file' }],
});
mockMcp.loadResources.mockResolvedValue({
serverName: 'filesystem',
resources: [{ uri: 'file:///README.md', name: 'README' }],
});
const { container } = renderApp();
await flush();
testState.prompt = '/mcp';
await clickSubmit(container);
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[aria-label="filesystem"]')
?.click();
await Promise.resolve();
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="mcp-server-actions"]')
?.dispatchEvent(
new MouseEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0,
}),
);
await Promise.resolve();
});
expect(document.body.textContent).not.toContain('View tools');
expect(document.body.textContent).not.toContain('View resources');
expect(document.body.textContent).toContain('Reconnect');
expect(document.body.textContent).not.toContain('Authenticate');
expect(document.body.textContent).toContain('Disable');
expect(document.body.textContent).toContain('Delete');
await act(async () => {
document
.querySelector<HTMLElement>(
'[data-testid="mcp-server-action-reconnect"]',
)
?.click();
await Promise.resolve();
});
await flush();
expect(mockMcp.restartServer).toHaveBeenCalledWith('filesystem');
});
it('polls workspace MCP status until browser authentication completes', async () => {
vi.useFakeTimers();
const disconnectedServer = {
name: 'yuque',
source: 'project' as const,
configOrigin: 'workspace_settings' as const,
disabled: false,
mcpStatus: 'disconnected' as const,
requiresAuth: true,
resourceCount: 0,
};
mockWorkspaceActions.loadMcpStatus.mockResolvedValue({
initialized: true,
discoveryState: 'completed',
workspaceCwd: '/workspace',
servers: [disconnectedServer],
});
mockMcp.loadTools.mockResolvedValue({ serverName: 'yuque', tools: [] });
mockMcp.manageServer.mockResolvedValue({
serverName: 'yuque',
action: 'authenticate',
ok: true,
pending: true,
messages: ['Open the browser to authenticate.'],
authUrl: 'https://example.com/oauth',
});
mockMcp.reload
.mockResolvedValueOnce({
initialized: true,
discoveryState: 'completed',
workspaceCwd: '/workspace',
servers: [
{ ...disconnectedServer, authenticationState: 'pending' as const },
],
})
.mockResolvedValueOnce({
initialized: true,
discoveryState: 'completed',
workspaceCwd: '/workspace',
servers: [
{
...disconnectedServer,
mcpStatus: 'connected' as const,
hasOAuthTokens: true,
authenticationState: 'succeeded' as const,
},
],
});
const { container } = renderApp();
await flush();
testState.prompt = '/mcp';
await clickSubmit(container);
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[aria-label="yuque"]')
?.click();
await Promise.resolve();
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="mcp-server-actions"]')
?.dispatchEvent(
new MouseEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0,
}),
);
await Promise.resolve();
});
await act(async () => {
document
.querySelector<HTMLElement>(
'[data-testid="mcp-server-action-authenticate"]',
)
?.click();
await Promise.resolve();
});
expect(container.textContent).toContain(
'Open the browser to authenticate.',
);
expect(mockMcp.reload).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1_500);
});
expect(mockMcp.reload).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain('Authenticating');
await act(async () => {
await vi.advanceTimersByTimeAsync(1_500);
});
await flush();
expect(mockMcp.reload).toHaveBeenCalledTimes(2);
expect(container.textContent).toContain('Authenticate complete.');
});
it('does not show MCP discovery progress', async () => {
mockWorkspaceActions.loadMcpStatus.mockResolvedValue({
initialized: true,
discoveryState: 'starting',
servers: [],
});
const { container } = renderApp();
await flush();
testState.prompt = '/mcp';
await clickSubmit(container);
await flush();
expect(container.textContent).not.toContain('Loading MCP tools...');
});
it('does not initialize MCP discovery when it is already complete', async () => {
mockWorkspaceActions.loadMcpStatus.mockResolvedValue({
initialized: true,
discoveryState: 'completed',
servers: [],
});
const { container } = renderApp();
await flush();
testState.prompt = '/mcp';
await clickSubmit(container);
await flush();
expect(mockMcp.initialize).not.toHaveBeenCalled();
expect(mockMcp.reloadConfig).not.toHaveBeenCalled();
expect(container.textContent).not.toContain('MCP tools are ready.');
expect(container.textContent).not.toContain('Loading MCP tools...');
});
it('does not show MCP discovery progress before or after completion', async () => {
vi.useFakeTimers();
mockWorkspaceActions.loadMcpStatus.mockResolvedValue({
initialized: true,
discoveryState: 'starting',
servers: [],
});
mockMcp.reload.mockResolvedValue({
initialized: true,
discoveryState: 'completed',
servers: [],
});
const { container } = renderApp();
await flush();
testState.prompt = '/mcp';
await clickSubmit(container);
await flush();
expect(container.textContent).not.toContain('Loading MCP tools...');
await act(async () => {
await vi.advanceTimersByTimeAsync(1_500);
});
await flush();
expect(container.textContent).not.toContain('Loading MCP tools...');
expect(container.textContent).not.toContain('MCP tools are ready.');
});
it('auto-closes an open panel when an AskUserQuestion approval becomes pending', async () => {
// The auto-close effect gates on pendingToolApproval || pendingAskUserApproval;
// this covers the second branch (ask_user_question resolves to
// pendingAskUserApproval), whose overlay is also hidden behind the panel.
const { container, rerender } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
await act(async () => {
testState.blocks = [
makePendingPermissionBlock({ toolName: 'ask_user_question' }),
];
rerender();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
});
it('opens the Daemon Status panel and auto-closes it on a pending approval', async () => {
// Covers the activePanel === 'status' branch (DaemonStatusDialog); the other
// panel tests all open via /settings, so this guards the 'status' literal and
// confirms the auto-close is panel-type-agnostic.
const { container, rerender } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-daemon-status"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
});
it('opens the Session Overview panel from the sidebar', async () => {
const { container } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="open-sessions-overview"]',
)
?.click();
await Promise.resolve();
});
const panel = container.querySelector('[data-testid="inline-panel"]');
expect(panel).not.toBeNull();
// The panelHost aria-label distinguishes which panel is up.
expect(panel?.getAttribute('aria-label')).toBe('Session Overview');
});
it('opens the split view from the sidebar', async () => {
const { container } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
// The outer chat subtree is hidden (display:none + aria-hidden) behind the
// split, so keyboard/AT can't reach the outer composer/toolbar. Assert the
// node is present first, so a missing subtree fails rather than passing
// vacuously through the optional chain.
const messages = container.querySelector('[data-testid="messages"]');
expect(messages).not.toBeNull();
expect(messages?.closest('[aria-hidden="true"]')).not.toBeNull();
});
it('restores a persisted split on load (survives a refresh)', async () => {
// Simulate the storage left behind by a split that was open before a refresh.
saveSplitSessions(['s1', 's2']);
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2');
});
it('does not open the split when nothing was persisted', async () => {
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
});
it('clears the persisted split when the user leaves the split view', async () => {
saveSplitSessions(['s1', 's2']);
const { container } = renderApp();
await flush();
// Restored into the split; leaving via its back button must clear storage
// so a later refresh doesn't bring the split back uninvited.
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-back"]')
?.click();
await Promise.resolve();
});
expect(loadSplitSessions()).toEqual([]);
});
it('syncs the split view from external session ids without the sidebar', async () => {
const { container, rerender } = renderApp({
sidebar: false,
splitSessionIds: ['s1'],
});
await flush();
expect(container.querySelector('[data-testid="sidebar"]')).toBeNull();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1');
rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] });
await flush();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2');
rerender({ sidebar: false, splitSessionIds: [] });
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] });
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2');
});
it('dedupes and caps external split session ids', async () => {
const { container } = renderApp({
sidebar: false,
splitSessionIds: ['s1', 's1', 's2', 's3', 's4', 's5', 's6', 's7'],
});
await flush();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2,s3,s4,s5,s6');
});
it('does not reopen controlled split view when the same ids get a new array reference', async () => {
const { container, rerender } = renderApp({
sidebar: false,
splitSessionIds: ['s1', 's2'],
});
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-back"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container
.querySelector('[data-testid="inline-panel"]')
?.getAttribute('aria-label'),
).toBe('Session Overview');
rerender({ sidebar: false, splitSessionIds: ['s1', 's2'] });
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container
.querySelector('[data-testid="inline-panel"]')
?.getAttribute('aria-label'),
).toBe('Session Overview');
});
it('notifies external callers when split session ids change inside WebShell', async () => {
const onSplitSessionIdsChange = vi.fn();
const { container, rerender } = renderApp({
sidebar: false,
splitSessionIds: ['s1'],
onSplitSessionIdsChange,
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-report-panes"]')
?.click();
await Promise.resolve();
});
expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['s1', 's2', 's3']);
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1');
rerender({
sidebar: false,
splitSessionIds: ['s1', 's2', 's3'],
onSplitSessionIdsChange,
});
await flush();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2,s3');
});
it('notifies external callers when uncontrolled split session ids change', async () => {
const onSplitSessionIdsChange = vi.fn();
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({
sidebar: false,
onSplitSessionIdsChange,
shellRef,
});
await flush();
await act(async () => {
shellRef.current?.openSplitView();
await Promise.resolve();
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-report-panes"]')
?.click();
await Promise.resolve();
});
expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['s1', 's2', 's3']);
});
it('opens the split view from the external shell ref like the sidebar button', async () => {
let shellApi: WebShellApi | null = null;
const { container } = renderApp({
sidebar: false,
shellRef: (api) => {
shellApi = api;
},
});
await flush();
expect(container.querySelector('[data-testid="sidebar"]')).toBeNull();
await act(async () => {
shellApi?.openSplitView();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('session-1');
});
it('requests controlled split ids from the external shell ref', async () => {
const onSplitSessionIdsChange = vi.fn();
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({
sidebar: false,
splitSessionIds: [],
onSplitSessionIdsChange,
shellRef,
});
await flush();
await act(async () => {
shellRef.current?.openSplitView();
await Promise.resolve();
});
expect(onSplitSessionIdsChange).toHaveBeenCalledWith(['session-1']);
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
});
it('assigns and clears the external shell object ref', async () => {
const shellRef = createRef<WebShellApi>();
const { unmount } = renderApp({
sidebar: false,
shellRef,
});
await flush();
expect(shellRef.current).not.toBeNull();
unmount();
expect(shellRef.current).toBeNull();
});
it('opens the Session Overview from the external shell ref like the sidebar button', async () => {
let shellApi: WebShellApi | null = null;
const { container } = renderApp({
sidebar: false,
shellRef: (api) => {
shellApi = api;
},
});
await flush();
expect(container.querySelector('[data-testid="sidebar"]')).toBeNull();
await act(async () => {
shellApi?.openSessionOverview();
await Promise.resolve();
});
const panel = container.querySelector('[data-testid="inline-panel"]');
expect(panel).not.toBeNull();
expect(panel?.getAttribute('aria-label')).toBe('Session Overview');
});
it('forces the compact session drawer from the external shell ref', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();
await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
const drawer = container.querySelector(
'[data-sidebar-shell][role="dialog"]',
);
expect(drawer).not.toBeNull();
expect(drawer?.className).toContain('mobileDrawerForced');
});
it('does not open or lock scrolling when the sidebar is disabled', async () => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'auto';
try {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: false, shellRef });
await flush();
await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(container.querySelector('[data-sidebar-shell]')).toBeNull();
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(document.body.style.overflow).toBe('auto');
} finally {
document.body.style.overflow = previousOverflow;
}
});
it('closes a forced compact drawer when the sidebar becomes disabled', async () => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'auto';
const shellRef = createRef<WebShellApi>();
const { container, rerender, unmount } = renderApp({
sidebar: true,
shellRef,
});
try {
await flush();
await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).not.toBeNull();
expect(document.body.style.overflow).toBe('hidden');
rerender({ sidebar: false, shellRef });
await flush();
expect(container.querySelector('[data-sidebar-shell]')).toBeNull();
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(document.body.style.overflow).toBe('auto');
} finally {
unmount();
document.body.style.overflow = previousOverflow;
}
});
it('dismisses a forced compact drawer before opening split view', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();
await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).not.toBeNull();
await act(async () => {
shellRef.current?.openSplitView();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).toBeNull();
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).not.toContain('mobileDrawerForced');
expect(document.body.style.overflow).not.toBe('hidden');
});
it('dismisses a forced compact drawer before opening the Session Overview', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();
await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).not.toBeNull();
await act(async () => {
shellRef.current?.openSessionOverview();
await Promise.resolve();
});
const panel = container.querySelector('[data-testid="inline-panel"]');
expect(panel).not.toBeNull();
expect(panel?.getAttribute('aria-label')).toBe('Session Overview');
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).toBeNull();
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).not.toContain('mobileDrawerForced');
expect(document.body.style.overflow).not.toBe('hidden');
});
it('returns a forced compact drawer to viewport control when dismissed', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();
await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).toContain('mobileDrawerForced');
await act(async () => {
container
.querySelector<HTMLElement>(
'[data-sidebar-shell] > div[aria-hidden="true"]',
)
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).not.toContain('mobileDrawerForced');
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).toBeNull();
});
it('returns to chat and clears the current page when opening the compact drawer', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();
await act(async () => {
shellRef.current?.openSessionOverview();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
await act(async () => {
shellRef.current?.openSplitView();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).not.toBeNull();
});
it('clears a forced compact drawer after crossing to a wide viewport', async () => {
let mobileChangeHandler:
| ((event: { matches: boolean }) => void)
| undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query.includes('min-width'),
media: query,
addEventListener: (
_type: string,
handler: (event: { matches: boolean }) => void,
) => {
if (query.includes('max-width')) mobileChangeHandler = handler;
},
removeEventListener: vi.fn(),
})),
});
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();
await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).toContain('mobileDrawerForced');
await act(async () => {
mobileChangeHandler?.({ matches: false });
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).not.toContain('mobileDrawerForced');
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).toBeNull();
});
it('starts a new session from the external shell ref and returns to chat', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();
await act(async () => {
shellRef.current?.openSplitView();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
vi.useFakeTimers();
let created: boolean | undefined;
await act(async () => {
created = await shellRef.current?.createNewSession();
vi.runOnlyPendingTimers();
});
expect(created).toBe(true);
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
});
it('reports a failed external new-session attempt through its boolean result', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockSessionActions.clearSession.mockRejectedValueOnce(new Error('boom'));
const shellRef = createRef<WebShellApi>();
renderApp({ sidebar: true, shellRef });
await flush();
vi.useFakeTimers();
let created: boolean | undefined;
await act(async () => {
created = await shellRef.current?.createNewSession();
vi.runOnlyPendingTimers();
});
expect(created).toBe(false);
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell]',
'boom',
expect.any(Error),
);
});
it('returns to the Session Overview when leaving the split view', async () => {
const { container } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-back"]')
?.click();
await Promise.resolve();
});
// Split closed; the Session Overview panel is shown instead of the chat.
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
const panel = container.querySelector('[data-testid="inline-panel"]');
expect(panel).not.toBeNull();
expect(panel?.getAttribute('aria-label')).toBe('Session Overview');
});
it('notifies controlled callers when leaving the split view', async () => {
const onSplitSessionIdsChange = vi.fn();
const { container } = renderApp({
sidebar: false,
splitSessionIds: ['s1', 's2'],
onSplitSessionIdsChange,
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-back"]')
?.click();
await Promise.resolve();
});
expect(onSplitSessionIdsChange).toHaveBeenCalledWith([]);
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container
.querySelector('[data-testid="inline-panel"]')
?.getAttribute('aria-label'),
).toBe('Session Overview');
});
it('preserves the pane set when leaving the split view and reopening it', async () => {
const { container } = renderApp();
await flush();
// Open the split, then let SplitView report a live pane set (s1,s2,s3) back
// to the App — the same way real add/remove mirrors up via onPanesChange.
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-report-panes"]')
?.click();
await Promise.resolve();
});
// Leave the split (back to the overview)…
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-back"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
// …and reopen it from the toolbar. The reported panes must be restored, not
// reset to empty / the current session (the regression this guards).
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2,s3');
});
it('reconciles split pane artifact snapshots in the right panel', async () => {
const { container } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="split-report-artifact"]',
)
?.click();
await Promise.resolve();
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-open-artifact"]')
?.click();
await Promise.resolve();
});
expect(document.body.textContent).toContain('Pane artifact');
expect(document.body.textContent).toContain('10 B');
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="split-report-updated-artifact"]',
)
?.click();
await Promise.resolve();
});
expect(document.body.textContent).toContain('20 B');
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="split-clear-artifacts"]',
)
?.click();
await Promise.resolve();
});
expect(document.body.textContent).toContain('Artifact not found.');
});
it('clears split pane artifact snapshots when switching sessions', async () => {
const { container, rerender } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="split-report-artifact"]',
)
?.click();
await Promise.resolve();
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-open-artifact"]')
?.click();
await Promise.resolve();
});
expect(document.body.textContent).toContain('Pane artifact');
await act(async () => {
mockConnection.sessionId = 'session-2';
rerender();
await Promise.resolve();
});
expect(document.body.textContent).not.toContain('Pane artifact');
});
it('enters the split view from a ?split= URL and consumes the param', async () => {
window.history.pushState({}, '', '/?split=s1,s2');
try {
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
// The one-shot param is stripped so a reload/exit doesn't force it back.
expect(window.location.search).toBe('');
} finally {
window.history.pushState({}, '', '/');
}
});
it('lets controlled split session ids take precedence over a ?split= URL', async () => {
window.history.pushState({}, '', '/?split=s1,s2');
try {
const { container } = renderApp({
sidebar: false,
splitSessionIds: ['s3'],
});
await flush();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s3');
expect(window.location.search).toBe('');
} finally {
window.history.pushState({}, '', '/');
}
});
it('seeds the split from a ?split= URL, deduping and capping the explicit selection', async () => {
// Duplicates and more than MAX_SPLIT_PANES (6) ids drive the explicit-
// selection branch of openSplitView (dedupe + cap + replace), distinct from
// the no-selection restore branch covered above.
window.history.pushState({}, '', '/?split=s1,s1,s2,s3,s4,s5,s6,s7');
try {
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toBe('s1,s2,s3,s4,s5,s6');
} finally {
window.history.pushState({}, '', '/');
}
});
it('keeps the split view open when an approval becomes pending (unlike the scheduled-tasks page)', async () => {
// Each split pane owns its own session's approval, so an approval on the
// outer main session must NOT yank the user out of the split.
const { container, rerender } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
// The outer session's approval overlay must NOT render behind the split —
// otherwise its global keyboard shortcuts could confirm an unseen approval.
expect(
container.querySelector('[data-testid="approval-overlay"]'),
).toBeNull();
});
it('surfaces the outer approval as a split notice and returns to chat when clicked', async () => {
// The overlay is suppressed under the split, so the outer approval would be
// invisible; a notice banner (with a way back) is the only signal.
const { container, rerender } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
const notice = container.querySelector(
'[data-testid="split-approval-notice"]',
);
expect(notice).not.toBeNull();
// Its button leaves the split (mainView -> 'chat') so the approval overlay,
// which only renders in chat, becomes visible and actionable.
await act(async () => {
notice!
.querySelector('button')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="approval-overlay"]'),
).not.toBeNull();
});
it('auto-closes the split view when the screen shrinks below the breakpoint', async () => {
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
const { container } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
// Shrinking below the large-screen breakpoint folds the split back to chat.
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
});
it('notifies controlled callers when a screen shrink closes the split view', async () => {
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
const onSplitSessionIdsChange = vi.fn();
const { container } = renderApp({
sidebar: false,
splitSessionIds: ['s1', 's2'],
onSplitSessionIdsChange,
});
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
expect(onSplitSessionIdsChange).toHaveBeenCalledWith([]);
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
});
it('folds the split without switching the chat session on shrink', async () => {
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
mockConnection.sessionId = 'session-1';
window.history.replaceState(null, '', '/?split=s1,s2');
try {
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
// The split folds back to chat, but folding must leave the chat's own
// connection untouched — switching sessions here would drop its session /
// git-branch / URL context and break the lossless restore on regrow.
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(mockSessionActions.loadSession).not.toHaveBeenCalled();
} finally {
window.history.replaceState(null, '', '/');
}
});
it('restores the split view when the screen grows back after a shrink', async () => {
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
window.history.replaceState(null, '', '/?split=s1,s2');
try {
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
// Shrinking below the breakpoint folds the split away...
await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
// ...and growing back past it restores the same split (a transient resize
// is lossless, not a permanent drop of the panes).
await act(async () => {
large = true;
changeHandler?.({ matches: true });
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
} finally {
window.history.replaceState(null, '', '/');
}
});
it('auto-collapses the sidebar in a narrow split and expands it when wide', async () => {
let wide = false;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
// Keep the large-screen (>=1024) query true so the split renders;
// the >=1200 "sidebar has room" query is the one under test.
if (query.includes('1200')) return wide;
return query.includes('min-width');
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('1200')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
window.history.replaceState(null, '', '/?split=s1,s2');
try {
const { container } = renderApp();
await flush();
const sidebar = () => container.querySelector('[data-testid="sidebar"]');
// Narrow split (< 1200px): the sidebar collapses to free room for panes.
expect(sidebar()?.getAttribute('data-collapsed')).toBe('true');
// Grow past 1200px: the sidebar expands again.
await act(async () => {
wide = true;
changeHandler?.({ matches: true });
await Promise.resolve();
});
expect(sidebar()?.getAttribute('data-collapsed')).toBe('false');
} finally {
window.history.replaceState(null, '', '/');
}
});
it('lands on the first pane, not an empty new chat, when a shrink closes a URL-driven split', async () => {
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
// Capture the isLargeScreen (1024px) query specifically — not the
// separate 1200px split-sidebar query — so flipping it drives the fold.
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
// The single chat has no session of its own — the split was entered from a
// `?split=` deep link — so a naive close would strand on an empty new chat.
mockConnection.sessionId = undefined;
window.history.replaceState(null, '', '/?split=s1,s2');
try {
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
// The split folds back to chat and re-attaches to the first pane's
// session instead of stranding the user on an empty new chat.
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(mockSessionActions.loadSession).toHaveBeenCalledWith('s1');
} finally {
window.history.replaceState(null, '', '/');
}
});
it('keeps the chat on its own session (does not re-point to the first pane) when a shrink closes the split', async () => {
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
// This chat HAS a session of its own — folding must leave it (and its git
// branch / URL) untouched rather than re-pointing at the split's first pane.
mockConnection.sessionId = 'own-session';
window.history.replaceState(null, '', '/?split=s1,s2');
try {
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
mockSessionActions.loadSession.mockClear();
await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
// Folded back to chat, but the guard kept the existing session — no
// re-point to the first pane.
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(mockSessionActions.loadSession).not.toHaveBeenCalled();
} finally {
window.history.replaceState(null, '', '/');
}
});
it('auto-closes the Session Overview when the screen shrinks below the breakpoint', async () => {
// Drive isLargeScreen through a controllable media query: open the panel on
// a large screen, then flip below the breakpoint and confirm it closes.
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
const { container } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="open-sessions-overview"]',
)
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
});
it('dismisses the Scheduled Tasks page when an approval becomes pending', async () => {
// The scheduled-tasks fullPage overlay covers the chat footer where the
// approval renders, so an approval must close it too (like the panel).
const { container, rerender } = renderApp();
await flush();
testState.prompt = '/schedule';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="scheduled-tasks-page"]'),
).not.toBeNull();
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="scheduled-tasks-page"]'),
).toBeNull();
});
it('opening Daemon Status closes the Scheduled Tasks page (mutually exclusive full-pane views)', async () => {
// Regression: both are full-pane views; the Scheduled Tasks fullPage is a
// position:absolute overlay, so opening Daemon Status while it was up left
// the panel rendered *behind* it — the button looked dead.
const { container } = renderApp();
await flush();
testState.prompt = '/schedule';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="scheduled-tasks-page"]'),
).not.toBeNull();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-daemon-status"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="scheduled-tasks-page"]'),
).toBeNull();
});
it('opening Scheduled Tasks closes an open Settings/Status panel', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
testState.prompt = '/schedule';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="scheduled-tasks-page"]'),
).not.toBeNull();
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
});
it('keeps the panel open when transcript blocks carry no actionable approval', async () => {
// Negative control: a resolved permission is not actionable, so the panel
// must stay put (guards against an unconditional "close on any block").
const { container, rerender } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
await act(async () => {
testState.blocks = [makePendingPermissionBlock({ resolved: true })];
rerender();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
});
it('keeps the composer dormant (dialogOpen) while an approval overlay is up', async () => {
// Regression: after the panel auto-closes for an approval, interactionBlocked
// flips false. Unless dialogOpen also keys off the pending approval,
// useComposerCore refocuses the composer and ToolApproval — which ignores
// keys from editable targets — stops responding to its approval shortcuts.
const { rerender } = renderApp();
await flush();
expect(testState.latestChatEditorProps?.dialogOpen).toBe(false);
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
expect(testState.latestChatEditorProps?.dialogOpen).toBe(true);
});
it('dismisses an open sub-dialog (model picker) when an approval becomes pending', async () => {
// A DialogShell sub-dialog left open would sit (backdrop) over the approval
// overlay in the chat footer, hiding it — and, for the approval-mode picker,
// let the user yolo-approve an unseen tool call. /model (no arg) opens the
// picker; an approval must dismiss it.
const { container, rerender } = renderApp();
await flush();
testState.prompt = '/model';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="dialog-shell"]'),
).not.toBeNull();
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="dialog-shell"]')).toBeNull();
});
it('opens the Changes dialog for /diff and does not forward it to the agent', async () => {
// /diff is intercepted locally — it opens the working-tree Changes dialog
// rather than being forwarded to the daemon/agent as a prompt.
const { container } = renderApp();
await flush();
testState.prompt = '/diff';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="dialog-shell"]'),
).not.toBeNull();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('marks the approval overlay keyboard-active when it appears', async () => {
// Focus itself is owned by ToolApproval/AskUserQuestion (covered by their
// own tests); the app's job is to render the overlay and tell it to grab
// focus (keyboardActive) once it's the topmost surface.
const { rerender } = renderApp();
await flush();
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
expect(
document.querySelector('[data-testid="approval-overlay"]'),
).not.toBeNull();
expect(testState.latestToolApprovalKeyboardActive).toBe(true);
});
it('marks the ask-user question overlay keyboard-active when it appears', async () => {
// Symmetric to the ToolApproval case: guards against askUserOverlayVisible
// being mis-derived (e.g. from pendingToolApproval) so the question overlay
// would never pull focus.
const { rerender } = renderApp();
await flush();
await act(async () => {
testState.blocks = [
makePendingPermissionBlock({ toolName: 'ask_user_question' }),
];
rerender();
await Promise.resolve();
});
expect(
document.querySelector('[data-testid="approval-overlay"]'),
).not.toBeNull();
expect(testState.latestAskUserQuestionKeyboardActive).toBe(true);
});
it('closes the panel on Escape from outside the sidebar', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
const panel = container.querySelector('[data-testid="inline-panel"]');
expect(panel).not.toBeNull();
await act(async () => {
panel?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
});
it('keeps the panel open on Escape originating inside the sidebar', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
const sidebar = container.querySelector('[data-testid="sidebar"]');
await act(async () => {
sidebar?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
});
it('marks the composer dormant (dialogOpen) while a panel replaces the chat', async () => {
const { container } = renderApp();
await flush();
expect(testState.latestChatEditorProps?.dialogOpen).toBe(false);
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(testState.latestChatEditorProps?.dialogOpen).toBe(true);
});
it('blocks app-level shortcuts while an external modal is registered', async () => {
const { container } = renderApp();
await flush();
expect(testState.latestChatEditorProps?.dialogOpen).toBe(false);
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="interaction-blocker"]')
?.click();
await Promise.resolve();
});
expect(testState.latestChatEditorProps?.dialogOpen).toBe(true);
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: 'l',
}),
);
window.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: 'y',
}),
);
});
expect(mockStore.reset).not.toHaveBeenCalled();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('restores composer focus after an approval resolves following a panel auto-close', async () => {
// Regression: on panel auto-close the editor focus is intentionally skipped
// (the approval owns the keyboard); when the approval later resolves with no
// panel to return to, focus must come back to the composer rather than being
// orphaned on <body>.
const { container, rerender } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
await Promise.resolve();
});
editorFocus.mockClear();
await act(async () => {
testState.blocks = [];
rerender();
await Promise.resolve();
});
expect(editorFocus).toHaveBeenCalled();
});
it('closes the panel and restores composer focus on Back button click', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
editorFocus.mockClear();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="panel-back"]')
?.click();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
expect(editorFocus).toHaveBeenCalled();
});
it('closes the panel, sends /model --fast, and reloads settings on fast-model pick', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
// Open the fast-model picker from Settings, then pick a model.
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-fast-model"]')
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="dialog-shell"]'),
).not.toBeNull();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="model-select"]')
?.click();
await Promise.resolve();
});
await flush();
expect(
mockSessionActions.sendPrompt.mock.calls.some(
// Workspace tab → the command carries the --project scope flag so the
// fast-model choice persists to workspace settings, not the default.
(c) => c[0] === '/model --fast fast-model-x --project',
),
).toBe(true);
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
expect(settingsReload).toHaveBeenCalled();
});
it('sends /model --fast with --global when the fast-model picker is opened from the User tab', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="open-fast-model-user"]',
)
?.click();
await Promise.resolve();
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="model-select"]')
?.click();
await Promise.resolve();
});
await flush();
expect(
mockSessionActions.sendPrompt.mock.calls.some(
(c) => c[0] === '/model --fast fast-model-x --global',
),
).toBe(true);
});
it('sends /language ui --project for a workspace-scoped language change from Settings', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="change-language-workspace"]',
)
?.click();
await Promise.resolve();
});
await flush();
expect(
mockSessionActions.sendPrompt.mock.calls.some(
(c) => c[0] === '/language ui en --project',
),
).toBe(true);
});
it('marks the chat view aria-hidden while a panel is shown', async () => {
const { container } = renderApp();
await flush();
expect(
container
.querySelector('[data-testid="submit"]')
?.closest('[aria-hidden="true"]'),
).toBeNull();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(
container
.querySelector('[data-testid="submit"]')
?.closest('[aria-hidden="true"]'),
).not.toBeNull();
});
it('closes an open panel when resuming a session via /resume', async () => {
// Resuming a session must surface that chat, not leave it hidden behind an
// open Settings/Status panel — mirrors createNewSession / loadSidebarSession.
const { container } = renderApp();
await flush();
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
testState.prompt = '/resume session-2';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-2');
});
it('dispatches rename only after the current session name changes', async () => {
const onSessionChange = vi.fn();
const { rerender } = renderApp({ onSessionChange });
await flush();
expect(onSessionChange).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'rename' }),
);
act(() => {
mockConnection.displayName = 'Renamed Session';
rerender({ onSessionChange });
});
expect(onSessionChange).toHaveBeenCalledWith({
type: 'rename',
sessionId: 'session-1',
newName: 'Renamed Session',
});
onSessionChange.mockClear();
act(() => {
rerender({ onSessionChange });
});
expect(onSessionChange).not.toHaveBeenCalled();
});
});
describe('App /goal command', () => {
it('opens the Goals page for a bare /goal instead of sending a prompt', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="goals-page"]'),
).not.toBeNull();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
it('opens the Goals page for a bare /goal even while a turn is running', async () => {
const { container, rerender } = renderApp();
await flush();
act(() => {
testState.streamingState = 'responding';
rerender({});
});
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="goals-page"]'),
).not.toBeNull();
expect(rawEnqueuePrompt).not.toHaveBeenCalled();
});
it('still sends /goal <condition> as a prompt rather than opening the page', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/goal ship it';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="goals-page"]')).toBeNull();
expect(mockSessionActions.sendPrompt).toHaveBeenCalled();
});
it('still routes /goal clear through the daemon clear path', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/goal clear';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="goals-page"]')).toBeNull();
expect(mockSessionActions.clearGoal).toHaveBeenCalled();
});
it('starts a goal in a fresh session from the Goals page', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoal = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoal) throw new Error('onCreateGoal was not captured');
mockSessionActions.clearSession.mockClear();
mockSessionActions.sendPrompt.mockClear();
await act(async () => {
await onCreateGoal('all tests pass');
});
// A goal takes over its session's turns, so it starts in a NEW one
// (clearSession is how createNewSession starts one) rather than hijacking
// the conversation the user was already having.
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'/goal all tests pass',
expect.anything(),
);
});
it('keeps the Goals page mounted across createNewSession, not just after it', async () => {
// `createNewSession` switches to the chat view itself, before any await. That
// silently defeated the deferred switch below: by the time `sendPrompt`
// rejected, the Goals page — and the form that renders the error — was already
// gone, dumping the user in an empty chat with no explanation. The handler
// passes `keepView` so the page survives until the prompt is admitted.
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoal = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoal) throw new Error('onCreateGoal was not captured');
mockSessionActions.sendPrompt.mockRejectedValueOnce(
new Error('daemon says no'),
);
await act(async () => {
await expect(onCreateGoal('all tests pass')).rejects.toThrow(
'daemon says no',
);
});
// createNewSession ran (a fresh session was started) …
expect(mockSessionActions.clearSession).toHaveBeenCalled();
// … and the Goals page is STILL up, so the rejection has somewhere to land.
expect(
container.querySelector('[data-testid="goals-page"]'),
).not.toBeNull();
});
it('keeps the Goals page open when the goal prompt is rejected', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoal = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoal) throw new Error('onCreateGoal was not captured');
mockSessionActions.sendPrompt.mockRejectedValueOnce(
new Error('daemon says no'),
);
await act(async () => {
await expect(onCreateGoal('all tests pass')).rejects.toThrow(
'daemon says no',
);
});
// Switching to the chat first would unmount the page, leaving the rejection
// with nowhere to render: the user would land in an empty session with no
// explanation.
expect(
container.querySelector('[data-testid="goals-page"]'),
).not.toBeNull();
});
it('switches to the chat view only after the goal prompt is admitted', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoal = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoal) throw new Error('onCreateGoal was not captured');
await act(async () => {
await onCreateGoal('all tests pass');
});
expect(container.querySelector('[data-testid="goals-page"]')).toBeNull();
});
it("opens a goal's session in the chat view", async () => {
// The goal's session transcript IS its history, so the Goals page has to be
// able to hand off to it. Nothing exercised this wiring before.
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
expect(
container.querySelector('[data-testid="goals-page"]'),
).not.toBeNull();
const onOpenSession = testState.latestGoalsProps?.onOpenSession;
if (!onOpenSession) throw new Error('onOpenSession was not captured');
mockSessionActions.loadSession.mockClear();
await act(async () => {
onOpenSession('goal-session-9');
});
await flush();
// Pin the session id, not the options bag — main added a `{ workspaceCwd }`
// second argument and will likely keep evolving it; the id is what this test
// is about.
expect(mockSessionActions.loadSession.mock.calls[0][0]).toBe(
'goal-session-9',
);
// It must leave the Goals page, or the user loads a transcript they cannot see.
expect(container.querySelector('[data-testid="goals-page"]')).toBeNull();
});
it("reports a failure to open a goal's session instead of swallowing it", async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onOpenSession = testState.latestGoalsProps?.onOpenSession;
if (!onOpenSession) throw new Error('onOpenSession was not captured');
mockSessionActions.loadSession.mockRejectedValueOnce(
new Error('session is gone'),
);
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await act(async () => {
onOpenSession('goal-session-9');
});
await flush();
// `loadSidebarSession` rethrows, so the handler's own `.catch` is the only
// thing standing between a dead session and an unhandled rejection. It has
// to route the failure to `reportError` (console + toast), not swallow it.
expect(consoleError).toHaveBeenCalledWith(
'[web-shell]',
expect.stringContaining('session is gone'),
expect.anything(),
);
consoleError.mockRestore();
});
it('reuses the empty session a failed goal attempt left behind', async () => {
// `sendPrompt` creates the daemon session lazily, so a prompt that fails
// after admission leaves a created-but-empty session. The form keeps the
// condition and invites a retry; if that retry started ANOTHER new session,
// every failed attempt would strand a blank chat in the sidebar.
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoal = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoal) throw new Error('onCreateGoal was not captured');
mockSessionActions.clearSession.mockClear();
mockSessionActions.sendPrompt.mockRejectedValueOnce(
new Error('daemon says no'),
);
await act(async () => {
await expect(onCreateGoal('all tests pass')).rejects.toThrow(
'daemon says no',
);
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
// Retry: the session from the failed attempt is still current and empty, so
// it is reused rather than abandoned. No second clearSession.
await act(async () => {
await onCreateGoal('all tests pass');
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
expect(mockSessionActions.sendPrompt).toHaveBeenLastCalledWith(
'/goal all tests pass',
expect.anything(),
);
});
it('forgets the stranded session once the user leaves the Goals page', async () => {
// The stranded session is only a scratch session while the Goals page is
// up. Leave, and the composer can talk to it — reusing it for a later goal
// would drop the goal loop on top of a real conversation, which is the very
// thing starting a fresh session exists to prevent.
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoal = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoal) throw new Error('onCreateGoal was not captured');
mockSessionActions.clearSession.mockClear();
mockSessionActions.sendPrompt.mockRejectedValueOnce(
new Error('daemon says no'),
);
await act(async () => {
await expect(onCreateGoal('all tests pass')).rejects.toThrow(
'daemon says no',
);
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
// Leave the Goals page via its Back button, then use the session from the
// composer — it is now a real conversation, not a scratch session.
const back = container.querySelector<HTMLButtonElement>(
'[data-testid="goals-page"] button[aria-label="back"]',
);
if (!back) throw new Error('Back button not found');
await act(async () => {
back.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flush();
expect(container.querySelector('[data-testid="goals-page"]')).toBeNull();
testState.prompt = 'hello from the composer';
await clickSubmit(container);
await flush();
// Re-open Goals and set a goal: it must NOT reuse the session the user has
// since been talking to.
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoalAgain = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoalAgain) throw new Error('onCreateGoal was not captured');
mockSessionActions.clearSession.mockClear();
await act(async () => {
await onCreateGoalAgain('all tests pass');
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
});
it('starts a fresh session again once a goal has actually been sent', async () => {
// The reuse above is only for a session stranded by a failure. Once a goal
// lands, that session belongs to it, and the next goal must not be dropped
// on top of the running one.
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoal = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoal) throw new Error('onCreateGoal was not captured');
mockSessionActions.clearSession.mockClear();
mockSessionActions.sendPrompt.mockRejectedValueOnce(
new Error('daemon says no'),
);
await act(async () => {
await expect(onCreateGoal('first goal')).rejects.toThrow(
'daemon says no',
);
});
await act(async () => {
await onCreateGoal('first goal');
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
// A brand-new goal after a successful send: fresh session again.
await act(async () => {
await onCreateGoal('second goal');
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(2);
});
it('does not drop the goal into the current session when the new session fails', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/goal';
await clickSubmit(container);
await flush();
const onCreateGoal = testState.latestGoalsProps?.onCreateGoal;
if (!onCreateGoal) throw new Error('onCreateGoal was not captured');
mockSessionActions.clearSession.mockRejectedValueOnce(
new Error('daemon unreachable'),
);
mockSessionActions.sendPrompt.mockClear();
await act(async () => {
await onCreateGoal('all tests pass');
});
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
});
});
describe('App manual-run orchestration (scheduled tasks)', () => {
// Drives App's real runTaskManually / enqueueManualRun / tryFireBoundRun via
// the onRunPrompt prop the (captured) ScheduledTasksDialog mock receives.
// Opening the page with /schedule mounts the dialog and captures the handler.
async function openRunHandler(
container: HTMLElement,
): Promise<(prompt: string, sessionId: string | null) => Promise<void>> {
testState.prompt = '/schedule';
await clickSubmit(container);
await flush();
const handler = testState.latestScheduledTasksProps?.onRunPrompt;
if (!handler) throw new Error('onRunPrompt was not captured');
return handler;
}
// Make sendPrompt admit the prompt (fire onAdmitted) then resolve, the normal
// "daemon accepted it" path.
const admitOnSend = () =>
mockSessionActions.sendPrompt.mockImplementation(
(_text: string, opts?: { onAdmitted?: () => void }) => {
opts?.onAdmitted?.();
return Promise.resolve(undefined);
},
);
it('resolves an unbound run once the daemon admits the prompt', async () => {
admitOnSend();
const { container } = renderApp();
await flush();
const run = await openRunHandler(container);
await act(async () => {
await expect(run('do the thing', null)).resolves.toBeUndefined();
});
});
it('rejects an unbound run that settles without admitting (cancel path)', async () => {
// Default sendPrompt resolves WITHOUT onAdmitted → onSubmitBefore cancel /
// never reached the session: the caller must skip recording a run.
const { container } = renderApp();
await flush();
const run = await openRunHandler(container);
await act(async () => {
await expect(run('do the thing', null)).rejects.toThrow(
/cancelled before it started/,
);
});
});
it('rejects an unbound run when the send throws before admission', async () => {
mockSessionActions.sendPrompt.mockRejectedValue(new Error('daemon boom'));
const { container } = renderApp();
await flush();
const run = await openRunHandler(container);
await act(async () => {
await expect(run('do the thing', null)).rejects.toThrow('daemon boom');
});
});
it('fires a bound run immediately when its session is already active', async () => {
admitOnSend();
const { container } = renderApp();
await flush();
const run = await openRunHandler(container);
// session-1 is the current, fully-loaded session, so tryFireBoundRun fires
// right after loadSidebarSession without waiting on a dep-change effect.
await act(async () => {
await expect(run('do the thing', 'session-1')).resolves.toBeUndefined();
});
expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-1', {
workspaceCwd: undefined,
});
});
it('supersedes an older pending bound run with a newer one', async () => {
// Neither target is the active session, so both stay latched; the second
// must reject the first so its caller does not record a dropped run.
const { container } = renderApp();
await flush();
const run = await openRunHandler(container);
vi.useFakeTimers();
let firstErr: unknown;
let second: Promise<void> | undefined;
await act(async () => {
void run('first', 'sess-A').catch((e) => {
firstErr = e;
});
second = run('second', 'sess-B').catch(() => {});
await Promise.resolve();
});
expect((firstErr as Error | undefined)?.message).toMatch(/superseded/);
vi.clearAllTimers();
void second;
});
it('rejects a bound run when the session switch times out', async () => {
const { container } = renderApp();
await flush();
const run = await openRunHandler(container);
vi.useFakeTimers();
let err: unknown;
await act(async () => {
void run('do the thing', 'never-active').catch((e) => {
err = e;
});
await Promise.resolve(); // loadSidebarSession resolves; no fire (not current)
});
await act(async () => {
vi.advanceTimersByTime(30_000);
});
expect((err as Error | undefined)?.message).toMatch(/Timed out switching/);
});
it('"create via chat" starts a fresh session and primes the composer', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/schedule';
await clickSubmit(container);
await flush();
const onCreateViaChat =
testState.latestScheduledTasksProps?.onCreateViaChat;
if (!onCreateViaChat) throw new Error('onCreateViaChat was not captured');
mockSessionActions.clearSession.mockClear();
editorInsertText.mockClear();
await act(async () => {
onCreateViaChat();
});
await flush();
// Jumps to a NEW session (clearSession is how createNewSession starts one)
// rather than piling the task-creation chat onto the current conversation.
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1);
// ...then primes the composer with the task starter (deferred one tick).
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(editorInsertText).toHaveBeenCalled();
});
it('"create via chat" does NOT prime the composer when the new session fails', async () => {
// If createNewSession() fails, the error is already surfaced — priming the
// (still-current) session would drop the task starter into the wrong chat.
const { container } = renderApp();
await flush();
testState.prompt = '/schedule';
await clickSubmit(container);
await flush();
const onCreateViaChat =
testState.latestScheduledTasksProps?.onCreateViaChat;
if (!onCreateViaChat) throw new Error('onCreateViaChat was not captured');
mockSessionActions.clearSession.mockClear();
mockSessionActions.clearSession.mockRejectedValueOnce(new Error('boom'));
editorInsertText.mockClear();
await act(async () => {
onCreateViaChat();
});
await flush();
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); // attempted
expect(editorInsertText).not.toHaveBeenCalled(); // but priming skipped
});
});