mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
* fix(cli): keep workspace skills in sync with extensions * fix(cli): address workspace skills review feedback * test(cli): cover synthesized inactive extension skills * fix(cli): address workspace skills review issues * fix(cli): address workspace skills review followups --------- Co-authored-by: ytahdn <ytahdn@gmail.com>
2213 lines
71 KiB
TypeScript
2213 lines
71 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { act, createRef } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import type { WebShellApi } from './App';
|
|
|
|
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[];
|
|
capabilities: { qwenCodeVersion: string; features: string[] };
|
|
loadingTranscript: boolean;
|
|
catchingUp: boolean;
|
|
error?: string;
|
|
errorStatus?: number;
|
|
missingSession?: boolean;
|
|
};
|
|
|
|
type ChatEditorTestProps = {
|
|
onSubmit: (
|
|
text: string,
|
|
images?: undefined,
|
|
commitAccepted?: () => void,
|
|
) => boolean | void;
|
|
skills?: Array<{ name: string; description: string }>;
|
|
isPreparing?: boolean;
|
|
dialogOpen?: boolean;
|
|
};
|
|
|
|
const {
|
|
mockConnection,
|
|
mockSessionActions,
|
|
mockWorkspaceActions,
|
|
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: [],
|
|
capabilities: { qwenCodeVersion: '1.2.3', features: [] },
|
|
loadingTranscript: false,
|
|
catchingUp: false,
|
|
};
|
|
return {
|
|
mockConnection: connection,
|
|
mockSessionActions: {
|
|
sendPrompt: vi.fn().mockResolvedValue(undefined),
|
|
createSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
|
|
attachSession: vi.fn().mockResolvedValue(undefined),
|
|
closeSession: vi.fn().mockResolvedValue(undefined),
|
|
clearSession: 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({}),
|
|
loadSession: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
mockWorkspaceActions: {
|
|
loadSkillsStatus: vi.fn().mockResolvedValue({ skills: [] }),
|
|
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([]),
|
|
},
|
|
mockStore: {
|
|
dispatch: vi.fn(),
|
|
appendLocalUserMessage: vi.fn(),
|
|
appendLocalAssistantMessage: vi.fn(),
|
|
},
|
|
mockFollowup: {
|
|
clear: vi.fn(),
|
|
onAcceptFollowup: vi.fn(),
|
|
onDismissFollowup: vi.fn(),
|
|
},
|
|
testState: {
|
|
prompt: 'hello',
|
|
streamingState: 'idle' as StreamingState,
|
|
blocks: [] as unknown[],
|
|
latestChatEditorProps: null as ChatEditorTestProps | null,
|
|
latestScheduledTasksProps: null as {
|
|
onRunPrompt?: (
|
|
prompt: string,
|
|
sessionId: string | null,
|
|
) => Promise<void>;
|
|
onCreateViaChat?: () => 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() }),
|
|
useSettings: () => ({
|
|
settings: [],
|
|
setValue: vi.fn().mockResolvedValue(undefined),
|
|
reload: settingsReload,
|
|
loading: false,
|
|
}),
|
|
useStreamingState: () => testState.streamingState,
|
|
useTranscriptBlocks: () => testState.blocks,
|
|
useTranscriptStore: () => mockStore,
|
|
useWorkspaceActions: () => mockWorkspaceActions,
|
|
useWorkspaceEventSignals: () => ({ extensionsVersion: 0 }),
|
|
}));
|
|
|
|
vi.mock('@qwen-code/sdk/daemon', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('@qwen-code/sdk/daemon')>()),
|
|
isDaemonTurnError: () => false,
|
|
}));
|
|
|
|
vi.mock('./hooks/useMessages', () => ({
|
|
useMessages: () => [],
|
|
}));
|
|
|
|
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;
|
|
insertText: (text: string) => void;
|
|
focus: () => void;
|
|
}>,
|
|
) {
|
|
testState.latestChatEditorProps = props;
|
|
React.useImperativeHandle(ref, () => ({
|
|
clear: editorClear,
|
|
insertText: editorInsertText,
|
|
// 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(
|
|
'button',
|
|
{
|
|
'data-testid': 'submit',
|
|
'data-preparing': props.isPreparing ? 'true' : 'false',
|
|
onClick: () =>
|
|
props.onSubmit(testState.prompt, undefined, editorCommit),
|
|
type: 'button',
|
|
},
|
|
'submit',
|
|
);
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock('./components/MessageList', async () => {
|
|
const React = await import('react');
|
|
return {
|
|
MessageList: React.forwardRef(function MessageList(
|
|
props: { showRetryHint?: boolean; onRetryClick?: () => void },
|
|
ref: React.ForwardedRef<{ scrollToBottom: () => void }>,
|
|
) {
|
|
React.useImperativeHandle(ref, () => ({ scrollToBottom: vi.fn() }));
|
|
return React.createElement(
|
|
'div',
|
|
{ 'data-testid': 'messages' },
|
|
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) => void }) =>
|
|
React.createElement(
|
|
'div',
|
|
{ 'data-testid': 'settings-message' },
|
|
React.createElement(
|
|
'button',
|
|
{
|
|
'data-testid': 'open-fast-model',
|
|
type: 'button',
|
|
onClick: () => props.onSubDialog?.('fastModel'),
|
|
},
|
|
'fast model',
|
|
),
|
|
),
|
|
};
|
|
});
|
|
|
|
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',
|
|
),
|
|
};
|
|
});
|
|
|
|
// 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;
|
|
onOpenDaemonStatus?: () => void;
|
|
onOpenSessions?: () => void;
|
|
onOpenSplitView?: () => 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' },
|
|
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',
|
|
),
|
|
);
|
|
},
|
|
};
|
|
});
|
|
|
|
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/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;
|
|
}) =>
|
|
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-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>;
|
|
}) => {
|
|
testState.latestScheduledTasksProps = props;
|
|
return React.createElement('div');
|
|
},
|
|
};
|
|
});
|
|
mockComponent('./components/dialogs/ExtensionsDialog', 'ExtensionsDialog');
|
|
mockComponent('./components/dialogs/ThemeDialog', 'ThemeDialog');
|
|
mockComponent(
|
|
'./components/dialogs/DeleteSessionDialog',
|
|
'DeleteSessionDialog',
|
|
);
|
|
mockComponent(
|
|
'./components/dialogs/ReleaseSessionDialog',
|
|
'ReleaseSessionDialog',
|
|
);
|
|
mockComponent('./components/dialogs/RewindDialog', 'RewindDialog');
|
|
mockComponent('./components/dialogs/McpDialog', 'McpDialog');
|
|
mockComponent('./components/messages/AgentsMessage', 'AgentsMessage');
|
|
mockComponent('./components/messages/MemoryMessage', 'MemoryMessage');
|
|
mockComponent('./components/messages/AuthMessage', 'AuthMessage');
|
|
mockComponent('./components/messages/ToolApproval', 'ToolApproval');
|
|
mockComponent('./components/messages/AskUserQuestion', 'AskUserQuestion');
|
|
mockComponent('./components/messages/TasksStatusMessage', 'TasksStatusMessage');
|
|
mockComponent('./components/messages/BtwMessage', 'BtwMessage');
|
|
mockComponent('./components/QueuedPromptDisplay', 'QueuedPromptDisplay');
|
|
|
|
const { App } = await import('./App');
|
|
|
|
(
|
|
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
// 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(() => {
|
|
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.status = 'connected';
|
|
mockConnection.displayName = 'Session One';
|
|
mockConnection.error = undefined;
|
|
mockConnection.errorStatus = undefined;
|
|
mockConnection.missingSession = false;
|
|
mockConnection.loadingTranscript = false;
|
|
mockConnection.catchingUp = false;
|
|
testState.prompt = 'hello';
|
|
testState.streamingState = 'idle';
|
|
testState.blocks = [];
|
|
testState.latestChatEditorProps = null;
|
|
testState.latestScheduledTasksProps = 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.closeSession.mockResolvedValue(undefined);
|
|
mockSessionActions.clearSession.mockResolvedValue(undefined);
|
|
mockSessionActions.loadSession.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);
|
|
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([]);
|
|
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('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.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('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('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,
|
|
);
|
|
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('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('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('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('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('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('min-width')) 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('min-width')) 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('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('min-width')) 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('moves focus to the approval overlay when it appears', async () => {
|
|
const { rerender } = renderApp();
|
|
await flush();
|
|
|
|
await act(async () => {
|
|
testState.blocks = [makePendingPermissionBlock()];
|
|
rerender();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const overlay = document.querySelector('[data-testid="approval-overlay"]');
|
|
expect(overlay).not.toBeNull();
|
|
expect(document.activeElement).toBe(overlay);
|
|
});
|
|
|
|
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('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(
|
|
(c) => c[0] === '/model --fast fast-model-x',
|
|
),
|
|
).toBe(true);
|
|
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
|
|
expect(settingsReload).toHaveBeenCalled();
|
|
});
|
|
|
|
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 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');
|
|
});
|
|
|
|
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
|
|
});
|
|
});
|