Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/web-shell/client/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,49 @@
overflow: visible;
}

.missingSessionState {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
padding: 24px;
color: var(--foreground);
font-family: var(--font-sans);
}

.missingSessionMessage {
font-size: inherit;
font-weight: inherit;
line-height: 1.4;
text-align: center;
}

.missingSessionButton {
min-width: 120px;
min-height: 38px;
padding: 0 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
font: inherit;
font-size: 14px;
cursor: pointer;
transition:
border-color 120ms ease,
opacity 120ms ease;
}

.missingSessionButton:hover,
.missingSessionButton:focus-visible {
border-color: color-mix(in srgb, var(--foreground) 24%, var(--border));
opacity: 0.9;
outline: none;
}

/* In-place panel that replaces the chat view (message list + composer) when
Settings or Daemon Status is opened. Fills the chat pane and owns its own
scroll container, mirroring what the DialogShell body used to provide. */
Expand Down
133 changes: 120 additions & 13 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createRoot, type Root } from 'react-dom/client';
type StreamingState = 'idle' | 'responding';

type MockConnection = {
status: 'connected';
status: 'connected' | 'disconnected';
sessionId: string | undefined;
clientId: string;
displayName: string | undefined;
Expand All @@ -18,6 +18,8 @@ type MockConnection = {
capabilities: { qwenCodeVersion: string; features: string[] };
loadingTranscript: boolean;
catchingUp: boolean;
error?: string;
errorStatus?: number;
};

type ChatEditorTestProps = {
Expand Down Expand Up @@ -63,6 +65,9 @@ const {
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),
Expand Down Expand Up @@ -111,6 +116,8 @@ const {

vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
isMissingSessionHttpStatus: (status: number | undefined) =>
status === 404 || status === 410,
useActions: () => mockSessionActions,
useConnection: () => mockConnection,
useDaemonFollowupSuggestion: () => ({
Expand Down Expand Up @@ -429,7 +436,10 @@ beforeEach(() => {
}),
});
mockConnection.sessionId = 'session-1';
mockConnection.status = 'connected';
mockConnection.displayName = 'Session One';
mockConnection.error = undefined;
mockConnection.errorStatus = undefined;
mockConnection.loadingTranscript = false;
mockConnection.catchingUp = false;
testState.prompt = 'hello';
Expand All @@ -451,6 +461,10 @@ beforeEach(() => {
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);
Expand Down Expand Up @@ -482,6 +496,75 @@ afterEach(() => {
});

describe('App session callbacks', () => {
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;

const onSessionIdChange = vi.fn();
const { container } = renderApp({
onSessionIdChange,
});
await flush();

expect(container.textContent).toContain('Current session does not exist');
expect(container.querySelector('[data-testid="submit"]')).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;

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;
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('gates direct submissions and dispatches submit events with delayed sidebar reload', async () => {
vi.useFakeTimers();
const onSubmitBefore = vi.fn().mockResolvedValue(undefined);
Expand Down Expand Up @@ -705,7 +788,9 @@ describe('App session callbacks', () => {
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();

// A gated tool call arrives.
await act(async () => {
Expand All @@ -727,7 +812,9 @@ describe('App session callbacks', () => {
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();

await act(async () => {
testState.blocks = [
Expand All @@ -753,7 +840,9 @@ describe('App session callbacks', () => {
?.click();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();

await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
Expand Down Expand Up @@ -821,7 +910,9 @@ describe('App session callbacks', () => {
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();

testState.prompt = '/schedule';
await clickSubmit(container);
Expand All @@ -841,15 +932,19 @@ describe('App session callbacks', () => {
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull();
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();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
});

it('keeps the composer dormant (dialogOpen) while an approval overlay is up', async () => {
Expand Down Expand Up @@ -881,7 +976,9 @@ describe('App session callbacks', () => {
testState.prompt = '/model';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="dialog-shell"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="dialog-shell"]'),
).not.toBeNull();

await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
Expand Down Expand Up @@ -930,7 +1027,9 @@ describe('App session callbacks', () => {
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();

const sidebar = container.querySelector('[data-testid="sidebar"]');
await act(async () => {
Expand All @@ -939,7 +1038,9 @@ describe('App session callbacks', () => {
);
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
});

it('marks the composer dormant (dialogOpen) while a panel replaces the chat', async () => {
Expand Down Expand Up @@ -985,7 +1086,9 @@ describe('App session callbacks', () => {
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();
editorFocus.mockClear();

await act(async () => {
Expand All @@ -1012,7 +1115,9 @@ describe('App session callbacks', () => {
?.click();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="dialog-shell"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="dialog-shell"]'),
).not.toBeNull();

await act(async () => {
container
Expand Down Expand Up @@ -1059,7 +1164,9 @@ describe('App session callbacks', () => {
testState.prompt = '/settings';
await clickSubmit(container);
await flush();
expect(container.querySelector('[data-testid="inline-panel"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();

testState.prompt = '/resume session-2';
await clickSubmit(container);
Expand Down
Loading
Loading