Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 0 additions & 1 deletion packages/web-shell/client/components/ChatPane.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@

.footer {
flex: 0 0 auto;
Comment thread
ytahdn marked this conversation as resolved.
border-top: 1px solid var(--border);
padding: 8px;
position: relative;
}
Expand Down
222 changes: 156 additions & 66 deletions packages/web-shell/client/components/ChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { act, forwardRef, useImperativeHandle } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../i18n';

Expand All @@ -19,14 +19,25 @@ let pendingPermission: any;
let latestOnSubmit:
| ((text: string, images?: unknown, commit?: () => void) => boolean)
| undefined;
let sendPromptResolve: (() => void) | undefined;
let sendPromptReject: ((e: unknown) => void) | undefined;
let latestChatEditorProps: any;
let latestFollowupAccept: ((suggestion: string) => void) | undefined;
let sendPromptAdmit: (() => void) | undefined;
const clearFollowup = vi.fn();
const insertText = vi.fn();
const transcriptDispatch = vi.fn();
const sendPrompt = vi.fn(async () => ({}) as any);
const submitPermission = vi.fn(async () => {});
const cancel = vi.fn(async () => {});
const setApprovalMode = vi.fn(async (mode: string) => ({ mode }));
const setModel = vi.fn(async () => ({}) as any);
const enqueuePrompt = vi.fn(() => true);
const removeQueuedPrompt = vi.fn();
const insertQueuedPrompt = vi.fn();
const editQueuedPrompt = vi.fn();
const editLastQueuedPrompt = vi.fn(() => false);
const clearQueuedPrompts = vi.fn(() => false);
let queuedPromptsMock: any[] = [];
let queuedTextsMock: string[] = [];

vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'],
Expand All @@ -38,8 +49,33 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
setModel,
}),
useConnection: () => connectionState,
useDaemonFollowupSuggestion: (options: any) => {
latestFollowupAccept = options?.onAccept;
return {
followupState: { suggestion: 'next idea', isVisible: true },
onAcceptFollowup: vi.fn(),
onDismissFollowup: vi.fn(),
clear: clearFollowup,
};
},
useStreamingState: () => streamingStateValue,
useTranscriptBlocks: () => [],
useTranscriptStore: () => ({
dispatch: transcriptDispatch,
}),
}));

vi.mock('../hooks/useQueuedPrompts', () => ({
useQueuedPrompts: () => ({
queuedPrompts: queuedPromptsMock,
queuedTexts: queuedTextsMock,
enqueuePrompt,
removeQueuedPrompt,
insertQueuedPrompt,
editQueuedPrompt,
editLastQueuedPrompt,
clearQueuedPrompts,
}),
}));

let messagesState: any[];
Expand Down Expand Up @@ -73,8 +109,12 @@ vi.mock('./StreamingStatus', () => ({
),
}));
vi.mock('./ChatEditor', () => ({
ChatEditor: (props: any) => {
ChatEditor: forwardRef(function MockChatEditor(props: any, ref: any) {
latestOnSubmit = props.onSubmit;
latestChatEditorProps = props;
useImperativeHandle(ref, () => ({
insertText,
}));
return (
<div>
<button
Expand Down Expand Up @@ -117,9 +157,20 @@ vi.mock('./ChatEditor', () => ({
<span data-testid="pane-models">
{JSON.stringify(props.availableModels ?? null)}
</span>
<span data-testid="pane-queued-messages">
{JSON.stringify(props.queuedMessages ?? null)}
</span>
<span data-testid="pane-followup">
{String(props.followupState?.suggestion ?? '')}
</span>
</div>
);
},
}),
}));
vi.mock('./QueuedPromptDisplay', () => ({
QueuedPromptDisplay: (props: any) => (
<div data-testid="pane-queue">{String(props.prompts.length)}</div>
),
}));
vi.mock('./messages/ToolApproval', () => ({
ToolApproval: (props: any) => (
Expand Down Expand Up @@ -150,6 +201,7 @@ let container: HTMLDivElement | null = null;

beforeEach(() => {
connectionState = {
status: 'connected',
sessionId: 'sess-1',
displayName: 'Refactor core',
workspaceCwd: '/w',
Expand All @@ -160,23 +212,30 @@ beforeEach(() => {
pendingPermission = null;
messagesState = [{ id: 'm1', role: 'user', content: 'hi' }];
latestOnSubmit = undefined;
sendPrompt.mockReset();
// Each sendPrompt returns a promise the test controls, so we can assert the
// draft is committed on admission (onAdmitted) rather than on turn completion
// (promise resolution). `sendPromptAdmit` captures the options.onAdmitted hook.
latestChatEditorProps = undefined;
latestFollowupAccept = undefined;
sendPromptAdmit = undefined;
sendPrompt.mockImplementation(
(_text?: string, options?: { onAdmitted?: () => void }) =>
new Promise<unknown>((resolve, reject) => {
sendPromptAdmit = options?.onAdmitted;
sendPromptResolve = () => resolve({});
sendPromptReject = (e) => reject(e);
}),
);
queuedPromptsMock = [];
queuedTextsMock = [];
sendPrompt.mockReset();
sendPrompt.mockImplementation(async (_text: string, options?: any) => {
sendPromptAdmit = options?.onAdmitted;
return {} as any;
});
clearFollowup.mockClear();
insertText.mockClear();
submitPermission.mockClear();
cancel.mockClear();
setApprovalMode.mockClear();
setModel.mockClear();
enqueuePrompt.mockClear();
enqueuePrompt.mockReturnValue(true);
removeQueuedPrompt.mockClear();
insertQueuedPrompt.mockClear();
editQueuedPrompt.mockClear();
editLastQueuedPrompt.mockClear();
clearQueuedPrompts.mockClear();
transcriptDispatch.mockClear();
});

afterEach(() => {
Expand Down Expand Up @@ -217,78 +276,97 @@ describe('ChatPane', () => {
);
});

it('sends a prompt to its own session on submit', () => {
it('sends an idle prompt directly so the pane enters loading immediately', () => {
render();
act(() =>
testid('pane-submit')!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
Comment thread
ytahdn marked this conversation as resolved.
);
expect(sendPrompt).toHaveBeenCalledTimes(1);
expect((sendPrompt.mock.calls[0] as unknown[])[0]).toBe('hello there');
expect(sendPrompt).toHaveBeenCalledWith('hello there', {
onAdmitted: expect.any(Function),
});
expect(clearFollowup).not.toHaveBeenCalled();
expect(enqueuePrompt).not.toHaveBeenCalled();
});

it('commits the draft on admission, without waiting for the turn to finish', async () => {
it('commits an idle prompt only after daemon admission', () => {
render();
const commit = vi.fn();
let returned: boolean | undefined;
act(() => {
returned = latestOnSubmit!('hi', undefined, commit);
});
// Returns false (keep the draft) and does NOT commit before admission.
expect(returned).toBe(false);
expect(commit).not.toHaveBeenCalled();
// The daemon admits the prompt (onAdmitted). The draft clears now, even
// though the turn promise is still pending — a long response must not strand
// the sent text in the composer until the turn ends.
await act(async () => {
sendPromptAdmit!();
await Promise.resolve();
});
act(() => sendPromptAdmit!());
expect(commit).toHaveBeenCalledTimes(1);
// The turn finishing later must NOT commit again — guards against regressing
// to committing on promise resolution (turn end) instead of admission.
await act(async () => {
sendPromptResolve!();
await Promise.resolve();
expect(clearFollowup).toHaveBeenCalledTimes(1);
});

it('forwards images with an idle prompt', () => {
const images = [{ data: 'image-data', media_type: 'image/png' }];
render();
act(() => {
latestOnSubmit!('with image', images);
});
expect(sendPrompt).toHaveBeenCalledWith('with image', {
images,
onAdmitted: expect.any(Function),
});
expect(commit).toHaveBeenCalledTimes(1);
});

it('does not commit (preserves the draft) when the prompt is rejected', async () => {
it('queues a prompt while the pane is already running', () => {
streamingStateValue = 'responding';
render();
const commit = vi.fn();
let returned: boolean | undefined;
act(() => {
latestOnSubmit!('hi', undefined, commit);
returned = latestOnSubmit!('queued next', undefined, commit);
});
await act(async () => {
sendPromptReject!(new Error('session busy'));
await Promise.resolve();
expect(returned).toBe(true);
expect(enqueuePrompt).toHaveBeenCalledWith('queued next', undefined);
expect(sendPrompt).not.toHaveBeenCalled();
Comment thread
ytahdn marked this conversation as resolved.
});

it('forwards images with a queued prompt', () => {
streamingStateValue = 'responding';
const images = [{ data: 'image-data', media_type: 'image/png' }];
render();
act(() => {
latestOnSubmit!('queued image', images);
});
expect(commit).not.toHaveBeenCalled();
expect(enqueuePrompt).toHaveBeenCalledWith('queued image', images);
});

it('does not submit while the pane is disconnected', () => {
connectionState.status = 'disconnected';
render();
let returned: boolean | undefined;
act(() => {
returned = latestOnSubmit!('hi');
});
expect(returned).toBe(false);
expect(sendPrompt).not.toHaveBeenCalled();
expect(enqueuePrompt).not.toHaveBeenCalled();
});

it('keeps the draft cleared and still reports the error when the turn fails after admission', async () => {
it('reports an idle prompt failure to the pane error handler', async () => {
const onError = vi.fn();
sendPrompt.mockRejectedValueOnce(new Error('disconnected'));
render({ onError });
const commit = vi.fn();
act(() => {
latestOnSubmit!('hi', undefined, commit);
});
// Admission clears the draft.
await act(async () => {
sendPromptAdmit!();
await Promise.resolve();
});
expect(commit).toHaveBeenCalledTimes(1);
// The turn then fails mid-flight: the draft stays cleared (no second commit)
// and the failure is still surfaced to onError.
await act(async () => {
sendPromptReject!(new Error('turn crashed'));
latestOnSubmit!('hi', undefined, commit);
await Promise.resolve();
});
expect(commit).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalled();
expect(commit).not.toHaveBeenCalled();
expect(clearFollowup).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
'Failed to send prompt',
);
});

it('keeps pane approvals click-only (no global keyboard shortcuts)', () => {
Expand Down Expand Up @@ -362,19 +440,31 @@ describe('ChatPane', () => {
});
expect(returned).toBe(false);
expect(sendPrompt).not.toHaveBeenCalled();
expect(enqueuePrompt).not.toHaveBeenCalled();
});

it('routes send failures to the onError prop', async () => {
const onError = vi.fn();
render({ onError });
act(() => {
latestOnSubmit!('hi', undefined, vi.fn());
});
await act(async () => {
sendPromptReject!(new Error('disconnected'));
await Promise.resolve();
});
expect(onError).toHaveBeenCalled();
it('passes queued prompts and queue editing controls to the pane editor', () => {
queuedPromptsMock = [{ id: 1, text: 'queued next' }];
queuedTextsMock = ['queued next'];
render();
expect(testid('pane-queue')?.textContent).toBe('1');
expect(testid('pane-queued-messages')?.textContent).toBe(
JSON.stringify(['queued next']),
);
expect(latestChatEditorProps.onPopQueuedMessages()).toBe(false);
expect(latestChatEditorProps.onClearQueuedMessages()).toBe(false);
expect(editLastQueuedPrompt).toHaveBeenCalledTimes(1);
expect(clearQueuedPrompts).toHaveBeenCalledTimes(1);
});

Comment thread
ytahdn marked this conversation as resolved.
it('passes follow-up suggestions to the pane editor', () => {
render();
expect(testid('pane-followup')?.textContent).toBe('next idea');
expect(latestChatEditorProps.onAcceptFollowup).toBeTypeOf('function');
expect(latestChatEditorProps.onDismissFollowup).toBeTypeOf('function');
expect(latestFollowupAccept).toBeTypeOf('function');
act(() => latestFollowupAccept!('test suggestion'));
expect(insertText).toHaveBeenCalledWith('test suggestion');
});

it('surfaces a connection-loss banner when the pane connection drops', () => {
Expand Down
Loading
Loading