Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
17 changes: 17 additions & 0 deletions docs/design/webshell-voice-button-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# WebShell Voice Button Visibility

## Summary

The WebShell voice button is visible only when the workspace explicitly enables voice, the embedding host allows the voice toolbar action, and the daemon advertises the `voice_transcribe` capability.

## Decision

The host toolbar filter remains the outer gate. When the host allows voice, `VoiceButton` reads the existing `/workspace/voice` status and accepts only `enabled: true`. A missing capability avoids the status request entirely.

The status is fail-closed: the button stays hidden while the request is pending or after it fails. Each settings event invalidates the previous result and starts a fresh request. Results are tied to the workspace client and settings version so a late response cannot re-enable a stale configuration.

## Boundaries

Voice model selection, available model discovery, and voice mode do not affect button visibility. Invalid model configuration continues to use the existing capture error path after the user clicks the button.

This change does not add a WebShell prop, daemon endpoint, SDK type, or configuration field.
22 changes: 22 additions & 0 deletions packages/web-shell/client/components/ChatEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
};
});

vi.mock('../voice/VoiceButton', () => ({
VoiceButton: () => <span data-testid="voice-button" />,
}));

const mounted: Array<{
root: Root;
container: HTMLDivElement;
Expand Down Expand Up @@ -199,6 +203,24 @@ function renderChatEditor(props: {
return container;
}

describe('ChatEditor voice toolbar integration', () => {
it('mounts voice only when the host toolbar allows it', () => {
expect(
renderChatEditor({}).querySelector('[data-testid="voice-button"]'),
).not.toBeNull();
expect(
renderChatEditor({
visibleToolbarActions: ['voice'],
}).querySelector('[data-testid="voice-button"]'),
).not.toBeNull();
expect(
renderChatEditor({
visibleToolbarActions: [],
}).querySelector('[data-testid="voice-button"]'),
).toBeNull();
});
});

describe('ChatEditor composer tag icons', () => {
it('renders built-in icons for top composer tags', () => {
const kinds = ['extension', 'file', 'mcp', 'skill'] as const;
Expand Down
25 changes: 16 additions & 9 deletions packages/web-shell/client/e2e/utils/mockDaemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface WebShellDaemonScenario {
providers: DaemonWorkspaceProvidersStatus;
skills: DaemonWorkspaceSkillsStatus;
settings: DaemonWorkspaceSettingsStatus;
voice: DaemonWorkspaceVoiceStatus;
extensions: DaemonWorkspaceExtensionsStatus;
extensionOperations: ExtensionActiveOperations;
extensionUpdateCheck: ExtensionUpdateCheckResponse;
Expand Down Expand Up @@ -71,6 +72,7 @@ type ScenarioOverrides = Partial<
| 'providers'
| 'skills'
| 'settings'
| 'voice'
| 'extensions'
| 'extensionOperations'
| 'extensionUpdateCheck'
Expand All @@ -83,6 +85,7 @@ type ScenarioOverrides = Partial<
providers?: Partial<DaemonWorkspaceProvidersStatus>;
skills?: Partial<DaemonWorkspaceSkillsStatus>;
settings?: Partial<DaemonWorkspaceSettingsStatus>;
voice?: Partial<DaemonWorkspaceVoiceStatus>;
extensions?: Partial<DaemonWorkspaceExtensionsStatus>;
extensionOperations?: Partial<ExtensionActiveOperations>;
extensionUpdateCheck?: Partial<ExtensionUpdateCheckResponse>;
Expand Down Expand Up @@ -230,6 +233,17 @@ export function createWebShellDaemonScenario(
...(overrides.settings ?? {}),
};

const voice: DaemonWorkspaceVoiceStatus = {
v: 1,
workspaceCwd,
enabled: false,
mode: 'hold',
language: 'en',
voiceModel: null,
availableVoiceModels: [],
...(overrides.voice ?? {}),
};

const extensions: DaemonWorkspaceExtensionsStatus = {
v: 1,
workspaceCwd,
Expand Down Expand Up @@ -282,6 +296,7 @@ export function createWebShellDaemonScenario(
providers,
skills,
settings,
voice,
extensions,
extensionOperations,
extensionUpdateCheck,
Expand Down Expand Up @@ -934,15 +949,7 @@ function workspaceTools(
function workspaceVoice(
scenario: WebShellDaemonScenario,
): DaemonWorkspaceVoiceStatus {
return {
v: 1,
workspaceCwd: scenario.workspaceCwd,
enabled: false,
mode: 'hold',
language: 'en',
voiceModel: null,
availableVoiceModels: [],
};
return scenario.voice;
}

async function json(route: Route, body: unknown, status = 200): Promise<void> {
Expand Down
26 changes: 26 additions & 0 deletions packages/web-shell/client/e2e/web-shell.smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,32 @@ test('opens slash menu, resume dialog, model dialog, and theme dialog @smoke', a
await expect(page.locator('[data-web-shell-theme-dialog]')).toHaveCount(0);
});

test('gates voice dictation on the workspace voice setting @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({
voice: {
enabled: false,
},
});
scenario.capabilities.features = [
...scenario.capabilities.features,
'voice_transcribe',
];
const daemon = await installScenario(page, scenario, testInfo);
const voiceButton = page.getByRole('button', {
name: 'Start voice dictation',
});

await gotoSession(page, scenario, daemon);
await expect(voiceButton).toHaveCount(0);

scenario.voice.enabled = true;
await page.reload();
await completeReplay(page, daemon);
await expect(voiceButton).toBeVisible();
});

for (const viewportHeight of COMPOSER_VIEWPORT_HEIGHTS) {
test(`grows long text to the responsive composer cap at ${viewportHeight}px @smoke`, async ({
page,
Expand Down
137 changes: 130 additions & 7 deletions packages/web-shell/client/voice/VoiceButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ import type { UseVoiceCaptureReturn } from './useVoiceCapture';
).IS_REACT_ACT_ENVIRONMENT = true;

const mocks = vi.hoisted(() => ({
settingsVersion: 0,
workspaceVoice: vi.fn(),
workspace: {
baseUrl: 'http://127.0.0.1:1234',
token: undefined as string | undefined,
capabilities: { features: ['voice_transcribe'] },
client: {
workspaceVoice: vi.fn(),
},
},
capture: {
status: 'idle' as UseVoiceCaptureReturn['status'],
Expand All @@ -29,6 +34,9 @@ const mocks = vi.hoisted(() => ({

vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useWorkspace: () => mocks.workspace,
useWorkspaceEventSignals: () => ({
settingsVersion: mocks.settingsVersion,
}),
}));

vi.mock('./useVoiceCapture', () => ({
Expand All @@ -38,14 +46,38 @@ vi.mock('./useVoiceCapture', () => ({

const mounted: Array<{ root: Root; container: HTMLElement }> = [];

function render(disabled: boolean): HTMLButtonElement {
function voiceStatus(enabled: boolean) {
return {
v: 1 as const,
workspaceCwd: '/tmp/workspace',
enabled,
mode: 'hold' as const,
language: 'en',
voiceModel: null,
availableVoiceModels: [],
};
}

function mount(disabled: boolean) {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(<VoiceButton disabled={disabled} onInsert={() => {}} />);
});
mounted.push({ root, container });
return { root, container };
}

async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}

async function render(disabled: boolean): Promise<HTMLButtonElement> {
const { container } = mount(disabled);
await flush();
const button = container.querySelector('button');
if (!button) throw new Error('VoiceButton did not render');
return button;
Expand All @@ -58,6 +90,11 @@ const click = (button: HTMLButtonElement) => {
};

beforeEach(() => {
mocks.settingsVersion = 0;
mocks.workspace.capabilities.features = ['voice_transcribe'];
mocks.workspace.client.workspaceVoice = mocks.workspaceVoice;
mocks.workspaceVoice.mockReset();
mocks.workspaceVoice.mockResolvedValue(voiceStatus(true));
mocks.capture.status = 'idle';
mocks.capture.interimText = '';
mocks.capture.audioLevel = 0;
Expand All @@ -75,28 +112,114 @@ afterEach(() => {
});

describe('VoiceButton', () => {
it('lets a disabled composer stop active dictation', () => {
it('renders only when workspace voice is enabled', async () => {
expect(await render(false)).not.toBeNull();

mocks.workspaceVoice.mockResolvedValue(voiceStatus(false));
const { container } = mount(false);
await flush();

expect(container.querySelector('button')).toBeNull();
});

it('stays hidden while the workspace voice request is pending or fails', async () => {
let rejectVoice: (reason?: unknown) => void = () => undefined;
mocks.workspaceVoice.mockReturnValue(
new Promise((_, reject) => {
rejectVoice = reject;
}),
);
const { container } = mount(false);

expect(container.querySelector('button')).toBeNull();

await act(async () => {
rejectVoice(new Error('voice status unavailable'));
await Promise.resolve();
});
expect(container.querySelector('button')).toBeNull();
});

it('does not request workspace voice without the daemon capability', async () => {
mocks.workspace.capabilities.features = [];
const { container } = mount(false);
await flush();

expect(container.querySelector('button')).toBeNull();
expect(mocks.workspaceVoice).not.toHaveBeenCalled();
});

it('reloads workspace voice when settings change', async () => {
mocks.workspaceVoice.mockResolvedValue(voiceStatus(false));
const { root, container } = mount(false);
await flush();
expect(container.querySelector('button')).toBeNull();

mocks.settingsVersion = 1;
mocks.workspaceVoice.mockResolvedValue(voiceStatus(true));
act(() => {
root.render(<VoiceButton disabled={false} onInsert={() => {}} />);
});
expect(container.querySelector('button')).toBeNull();
await flush();
expect(container.querySelector('button')).not.toBeNull();

mocks.settingsVersion = 2;
mocks.workspaceVoice.mockResolvedValue(voiceStatus(false));
act(() => {
root.render(<VoiceButton disabled={false} onInsert={() => {}} />);
});
expect(container.querySelector('button')).toBeNull();
await flush();
expect(container.querySelector('button')).toBeNull();
});

it('ignores a stale workspace voice response', async () => {
let resolveFirst: (value: ReturnType<typeof voiceStatus>) => void = () =>
undefined;
mocks.workspaceVoice.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve;
}),
);
const { root, container } = mount(false);

mocks.settingsVersion = 1;
mocks.workspaceVoice.mockResolvedValueOnce(voiceStatus(false));
act(() => {
root.render(<VoiceButton disabled={false} onInsert={() => {}} />);
});
await flush();

await act(async () => {
resolveFirst(voiceStatus(true));
await Promise.resolve();
});
expect(container.querySelector('button')).toBeNull();
});

it('lets a disabled composer stop active dictation', async () => {
mocks.capture.status = 'recording';
const button = render(true);
const button = await render(true);

expect(button.disabled).toBe(false);
click(button);

expect(mocks.capture.stop).toHaveBeenCalledOnce();
});

it('lets a disabled composer abort a connecting dictation', () => {
it('lets a disabled composer abort a connecting dictation', async () => {
mocks.capture.status = 'connecting';
const button = render(true);
const button = await render(true);

expect(button.disabled).toBe(false);
click(button);

expect(mocks.capture.abort).toHaveBeenCalledOnce();
});

it('keeps disabled idle dictation from starting', () => {
const button = render(true);
it('keeps disabled idle dictation from starting', async () => {
const button = await render(true);

expect(button.disabled).toBe(true);

Expand Down
Loading
Loading