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
24 changes: 20 additions & 4 deletions e2e/demo/model-dialog.demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,18 @@ test.describe('Model Dialog Demo', () => {
});
});

// 3. Simulate extension sending configured models list
await webviewPage.evaluate(() => {
(window as any).simulateExtensionMessage({
command: 'configuredModelsResponse',
models: ['claude-sonnet-4-20250514', 'claude-haiku-4-20250514', 'gpt-4', 'gpt-4-mini']
});
});

// Verify dialog is visible
await expect(webviewPage.getByText('模型设置', { exact: true })).toBeVisible();

// Verify model input is pre-filled
// Verify model select is pre-selected
await expect(webviewPage.locator('#model-select')).toHaveValue('claude-sonnet-4-20250514');
await expect(webviewPage.locator('#fast-model-select')).toHaveValue('claude-haiku-4-20250514');

Expand Down Expand Up @@ -53,11 +61,19 @@ test.describe('Model Dialog Demo', () => {
});
});

// Simulate configured models (even with env vars, models list comes from SDK)
await webviewPage.evaluate(() => {
(window as any).simulateExtensionMessage({
command: 'configuredModelsResponse',
models: ['gpt-4', 'gpt-4-mini']
});
});

await expect(webviewPage.getByText('模型设置', { exact: true })).toBeVisible();

// Verify placeholders are shown
const modelInput = webviewPage.locator('#model-select');
await expect(modelInput).toHaveAttribute('placeholder', /WAVE_MODEL/);
// Verify select is shown with first option selected
const modelSelect = webviewPage.locator('#model-select');
await expect(modelSelect).toBeVisible();

const dialog = webviewPage.locator('.configuration-dialog');
await dialog.screenshot({ path: 'docs/public/screenshots/spec-model-dialog-empty.png' });
Expand Down
32 changes: 24 additions & 8 deletions e2e/webview/model-status-login-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,27 @@ test.describe('Model/Status/Login Slash Commands', () => {
});
});

// Simulate extension sending configured models list
await webviewPage.evaluate(() => {
(window as any).simulateExtensionMessage({
command: 'configuredModelsResponse',
models: ['gpt-4', 'gpt-4-mini', 'gpt-4-turbo']
});
});

// Type /model and press Enter
await input.type('/model');
await webviewPage.keyboard.press('Enter');

// Verify model dialog is visible
await expect(webviewPage.getByText('模型设置', { exact: true })).toBeVisible();

// Verify model input is pre-filled
const modelInput = webviewPage.locator('#model-select');
await expect(modelInput).toHaveValue('gpt-4');
// Verify model select is pre-selected
const modelSelect = webviewPage.locator('#model-select');
await expect(modelSelect).toHaveValue('gpt-4');

const fastModelInput = webviewPage.locator('#fast-model-select');
await expect(fastModelInput).toHaveValue('gpt-4-mini');
const fastModelSelect = webviewPage.locator('#fast-model-select');
await expect(fastModelSelect).toHaveValue('gpt-4-mini');

// Verify sendMessage was NOT sent to extension (it's a local command)
const messages = await webviewPage.evaluate(() => (window as any).getTestMessages());
Expand Down Expand Up @@ -141,13 +149,21 @@ test.describe('Model/Status/Login Slash Commands', () => {
});
});

// Simulate extension sending configured models list
await webviewPage.evaluate(() => {
(window as any).simulateExtensionMessage({
command: 'configuredModelsResponse',
models: ['gpt-4', 'gpt-4-mini', 'claude-opus-4']
});
});

// Open model dialog
await input.type('/model');
await webviewPage.keyboard.press('Enter');

// Change model value
const modelInput = webviewPage.locator('#model-select');
await modelInput.fill('claude-opus-4');
// Change model via dropdown
const modelSelect = webviewPage.locator('#model-select');
await modelSelect.selectOption('claude-opus-4');

// Click save button
await webviewPage.getByText('保存', { exact: true }).click();
Expand Down
147 changes: 147 additions & 0 deletions e2e/webview/modelDropdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { test, expect } from '../utils/webviewTestHarness.js';

/**
* Test /model dialog with dropdown select instead of text input.
* The dialog should show <select> elements populated with configured models from the SDK.
*/

test.describe('Model Dialog Dropdown', () => {
test('should show select dropdowns populated with configured models', async ({ webviewPage }) => {
const input = webviewPage.getByTestId('message-input');
await input.focus();

// Simulate extension sending configuration data
await webviewPage.evaluate(() => {
(window as any).simulateExtensionMessage({
command: 'configurationResponse',
configurationData: {
model: 'gpt-4',
fastModel: 'gpt-4-mini',
}
});
});

// Simulate extension sending configured models list
await webviewPage.evaluate(() => {
(window as any).simulateExtensionMessage({
command: 'configuredModelsResponse',
models: ['gpt-4', 'gpt-4-mini', 'gpt-4-turbo', 'claude-3-opus', 'claude-3-sonnet']
});
});

// Type /model and press Enter
await input.type('/model');
await webviewPage.keyboard.press('Enter');

// Verify model dialog is visible
await expect(webviewPage.getByText('模型设置', { exact: true })).toBeVisible();

// Verify select elements exist (not text inputs)
const modelSelect = webviewPage.locator('#model-select');
await expect(modelSelect).toBeVisible();
const fastModelSelect = webviewPage.locator('#fast-model-select');
await expect(fastModelSelect).toBeVisible();

// Verify options are populated
const modelOptions = modelSelect.locator('option');
await expect(modelOptions).toHaveCount(5);
await expect(modelOptions.nth(0)).toHaveText('gpt-4');
await expect(modelOptions.nth(1)).toHaveText('gpt-4-mini');
await expect(modelOptions.nth(2)).toHaveText('gpt-4-turbo');
await expect(modelOptions.nth(3)).toHaveText('claude-3-opus');
await expect(modelOptions.nth(4)).toHaveText('claude-3-sonnet');

// Verify current model is selected
await expect(modelSelect).toHaveValue('gpt-4');
await expect(fastModelSelect).toHaveValue('gpt-4-mini');
});

test('should save selected model from dropdown', async ({ webviewPage }) => {
const input = webviewPage.getByTestId('message-input');
await input.focus();

// Simulate extension sending configuration data
await webviewPage.evaluate(() => {
(window as any).simulateExtensionMessage({
command: 'configurationResponse',
configurationData: {
model: 'gpt-4',
fastModel: 'gpt-4-mini',
}
});
});

// Simulate extension sending configured models list
await webviewPage.evaluate(() => {
(window as any).simulateExtensionMessage({
command: 'configuredModelsResponse',
models: ['gpt-4', 'gpt-4-mini', 'claude-3-opus']
});
});

// Open model dialog
await input.type('/model');
await webviewPage.keyboard.press('Enter');

// Change model via dropdown
const modelSelect = webviewPage.locator('#model-select');
await modelSelect.selectOption('claude-3-opus');

// Click save
await webviewPage.getByText('保存', { exact: true }).click();

// Verify setModel message was sent with selected model
const messages = await webviewPage.evaluate(() => (window as any).getTestMessages());
const setModelMsg = messages.find((m: any) => m.command === 'setModel');
expect(setModelMsg).toBeDefined();
expect(setModelMsg.configurationData.model).toBe('claude-3-opus');
expect(setModelMsg.configurationData.fastModel).toBe('gpt-4-mini');
});

test('should show empty state when no configured models', async ({ webviewPage }) => {
const input = webviewPage.getByTestId('message-input');
await input.focus();

// Simulate extension sending configuration data
await webviewPage.evaluate(() => {
(window as any).simulateExtensionMessage({
command: 'configurationResponse',
configurationData: {
model: 'gpt-4',
fastModel: 'gpt-4-mini',
}
});
});

// Simulate extension sending empty configured models list
await webviewPage.evaluate(() => {
(window as any).simulateExtensionMessage({
command: 'configuredModelsResponse',
models: []
});
});

// Open model dialog
await input.type('/model');
await webviewPage.keyboard.press('Enter');

// Verify model dialog is visible
await expect(webviewPage.getByText('模型设置', { exact: true })).toBeVisible();

// Verify a hint about no models is shown
await expect(webviewPage.locator('.model-empty-hint')).toBeVisible();
});

test('should request configured models when opening dialog', async ({ webviewPage }) => {
const input = webviewPage.getByTestId('message-input');
await input.focus();

// Open model dialog
await input.type('/model');
await webviewPage.keyboard.press('Enter');

// Verify getConfiguredModels message was sent to extension
const messages = await webviewPage.evaluate(() => (window as any).getTestMessages());
expect(messages.some((m: any) => m.command === 'getConfiguredModels')).toBe(true);
});
});
28 changes: 28 additions & 0 deletions src/session/messageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ export class MessageHandler {
case 'setModel':
await this.handleSetModel(message.configurationData, viewType, windowId);
break;
case 'getConfiguredModels':
await this.handleGetConfiguredModels(viewType, windowId);
break;
case 'getMcpServers':
await this.handleGetMcpServers(viewType, windowId);
break;
Expand Down Expand Up @@ -751,6 +754,31 @@ export class MessageHandler {
}
}

private async handleGetConfiguredModels(viewType?: 'sidebar' | 'tab' | 'window', windowId?: string) {
try {
const session = this.context.getChatSession(viewType || 'tab', windowId);
// Get models from the agent instance (like wave-agent does)
// The agent's getConfiguredModels() reads from SDK's ConfigurationService which has remote models
const models = session.agent?.getConfiguredModels() || [];
// Also get the current model values from the agent (these include remote config)
const modelConfig = session.agent?.getModelConfig() || { model: '', fastModel: '' };
this.context.postMessage({
command: 'configuredModelsResponse',
models,
currentModel: modelConfig.model || '',
currentFastModel: modelConfig.fastModel || ''
}, viewType, windowId);
} catch (error) {
console.error(`Failed to get configured models for ${viewType}:`, error);
this.context.postMessage({
command: 'configuredModelsResponse',
models: [],
currentModel: '',
currentFastModel: ''
}, viewType, windowId);
}
}

private async handleGetMcpServers(viewType?: 'sidebar' | 'tab' | 'window', windowId?: string) {
const session = this.context.getChatSession(viewType || 'tab', windowId);
const servers = session.getMcpServers();
Expand Down
37 changes: 37 additions & 0 deletions webview/src/components/ChatApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const initialState: ChatState = {
configurationData: undefined,
configurationLoading: false,
configurationError: undefined,
configuredModels: [],
currentModel: '',
currentFastModel: '',
// Permission mode state
permissionMode: 'default',
// Attached images state
Expand Down Expand Up @@ -155,6 +158,22 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState {
configurationData: action.payload,
configurationLoading: false
};
case 'SET_CONFIGURED_MODELS':
return {
...state,
configuredModels: action.payload
};
case 'SET_CURRENT_MODELS':
return {
...state,
currentModel: action.payload.model,
currentFastModel: action.payload.fastModel,
configurationData: {
...state.configurationData,
model: action.payload.model || state.configurationData?.model,
fastModel: action.payload.fastModel || state.configurationData?.fastModel
}
};
case 'SET_INITIAL_STATE':
return {
...state,
Expand Down Expand Up @@ -281,6 +300,22 @@ export const ChatApp: React.FC<ChatAppProps> = ({ vscode }) => {
payload: message.configurationData
});
break;
case 'configuredModelsResponse':
dispatch({
type: 'SET_CONFIGURED_MODELS',
payload: message.models || []
});
// Also update current model values from agent
if (message.currentModel !== undefined || message.currentFastModel !== undefined) {
dispatch({
type: 'SET_CURRENT_MODELS',
payload: {
model: message.currentModel || '',
fastModel: message.currentFastModel || ''
}
});
}
break;
case 'setInitialState':
dispatch({
type: 'SET_INITIAL_STATE',
Expand Down Expand Up @@ -381,6 +416,7 @@ export const ChatApp: React.FC<ChatAppProps> = ({ vscode }) => {
if (trimmedText === '/model') {
dispatch({ type: 'SHOW_DIALOG', payload: { type: 'model', data: stateRef.current.configurationData || {} } });
vscode.postMessage({ command: 'getConfiguration' });
vscode.postMessage({ command: 'getConfiguredModels' });
return;
}
if (trimmedText === '/status') {
Expand Down Expand Up @@ -636,6 +672,7 @@ export const ChatApp: React.FC<ChatAppProps> = ({ vscode }) => {
{state.activeDialog === 'model' && (
<ModelDialog
configurationData={state.configurationData || {}}
configuredModels={state.configuredModels}
onSave={handleModelSave}
onClose={handleDialogClose}
vscode={vscode}
Expand Down
Loading