fix(voice): keep the voice preferences in reach from chat Settings

With Voice Preview off the voice panel never opens on its own, and chat
Settings hid the voice preferences, so the Preview toggle, inactivity
timeout and model tier could not be reached by pointer. Chat Settings now
show them below the chat options, still in chat's frame and without the
voice tabs.
This commit is contained in:
Sameh Khamis committed 2026-11-03 15:54:54 -08:00
1 parent 4f4af9c4e0
commit 829db19f60
4 files changed
+71 -8

No files matched your search

+8 -2
View File
@@ -1242,14 +1242,20 @@ body[data-assistant-docked='true'] #right-context-rail {
display: none;
}
/* Settings opened from chat: same frame as chat, chat options only. */
/* Settings opened from chat: same frame as chat, chat options first, then
the voice preferences, the only pointer route to them while Preview is off. */
#command-dock
#gev-voice-control
#gev-voice-panel[data-context='chat']
:is(.gev-voice-panel-tabs, .gev-panel-layout, #gev-settings-voice),
:is(.gev-voice-panel-tabs, .gev-panel-layout),
#gev-voice-panel:not([data-context='chat']) #gev-settings-chat {
display: none;
}
#gev-voice-panel[data-context='chat'] #gev-settings-voice {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--glass-border);
}
#gev-voice-control
#gev-voice-panel[data-context='chat'][data-placement='compact']:not(
[data-minimized]
+4 -4
View File
@@ -125,6 +125,10 @@ const frameStyle = (panel) => ({
<UiSelect id="gev-voice-provider" class="gev-field" :aria-label="t('AI Provider')" :title="view.provider.title" :model-value="view.provider.value" @update:model-value="emit('provider-change', $event)"><option v-for="option in view.provider.options" :key="option.value" :value="option.value">{{ option.label }}</option></UiSelect>
</label>
<p class="gev-voice-settings-note">{{ t('The selected AI provider is shared by voice and chat.') }}</p>
<div id="gev-settings-chat">
<label class="gev-voice-preference gev-voice-preference-toggle"><input id="gev-chat-quick" type="checkbox" v-checked="view.chat.quickChecked" @change="emit('chat-quick-change', $event.target.checked)" /><span>{{ t('Show quick commands') }}</span></label>
<p>{{ t('Chat replies are silent. No microphone access is needed.') }}</p>
</div>
<div id="gev-settings-voice">
<label class="gev-voice-preference gev-voice-preference-toggle"><input id="gev-voice-preview-enabled" type="checkbox" aria-describedby="gev-voice-preview-note" v-checked="view.preferences.previewChecked" @change="emit('preview-change', $event.target.checked)" /><span>{{ t('Show Voice Preview') }}</span></label>
<p id="gev-voice-preview-note" class="gev-voice-settings-note">{{ view.preferences.previewNote }}</p>
@@ -152,10 +156,6 @@ const frameStyle = (panel) => ({
</div>
</div>
</div>
<div id="gev-settings-chat">
<label class="gev-voice-preference gev-voice-preference-toggle"><input id="gev-chat-quick" type="checkbox" v-checked="view.chat.quickChecked" @change="emit('chat-quick-change', $event.target.checked)" /><span>{{ t('Show quick commands') }}</span></label>
<p>{{ t('Chat replies are silent. No microphone access is needed.') }}</p>
</div>
</section>
</div>
</section>
+56
View File
@@ -1,6 +1,7 @@
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createPinia } from 'pinia';
import { createVoiceControl } from '../control.js';
import { useVoiceStore } from '../../ui/stores/voice.js';
@@ -1007,6 +1008,61 @@ test('chat Settings keep chat context until the panel closes', async (t) => {
assert.equal(f.ui.state.chat.backVisible, false);
});
// Shown once neither the element nor an ancestor is hidden by the page's
// chat stylesheet or a hidden attribute.
function shown(selector) {
for (let node = $(selector); node?.nodeType === 1; node = node.parentElement)
if (node.hidden || globalThis.getComputedStyle(node).display === 'none')
return false;
return true;
}
test('with Preview off, chat Settings reach the voice preferences', async (t) => {
const style = globalThis.document.createElement('style');
style.textContent = readFileSync(
new URL('../../ui/styles/ai-chat.css', import.meta.url),
'utf8',
);
globalThis.document.head.append(style);
t.after(() => style.remove());
const f = mounted(t, { view: true });
// The control renders into the command dock, as the page does.
globalThis.document.body.firstElementChild.id = 'command-dock';
f.ui.emit('preview-change', false);
f.commands.voicePanel.close();
await f.settle();
$('#gev-chat-button').click();
await f.settle();
$('#gev-voice-settings-button').click();
await f.settle();
assert.equal(f.ui.state.voicePanel.context, 'chat');
for (const selector of [
'#gev-settings-chat',
'#gev-settings-voice',
'#gev-voice-preview-enabled',
'#gev-voice-timeout-select',
])
assert.equal(shown(selector), true, selector);
// Chat's frame keeps the voice tabs and dock layout buttons away.
assert.equal(shown('.gev-voice-panel-tabs'), false);
const preview = $('#gev-voice-preview-enabled');
preview.checked = true;
preview.dispatchEvent(new Event('change', { bubbles: true }));
assert.equal(f.commands.getVoicePreferences().previewEnabled, true);
const timeout = $('#gev-voice-timeout-select');
timeout.value = '10';
timeout.dispatchEvent(new Event('change', { bubbles: true }));
assert.equal(f.commands.getVoicePreferences().minutes, 10);
f.ui.update('tier', { hidden: false });
await f.settle();
assert.equal(shown('#gev-voice-tier'), true);
let tiers = 0;
t.after(f.ui.on('tier', () => tiers++));
$('#gev-voice-tier').click();
assert.equal(tiers, 1);
assert.equal(f.ui.state.voicePanel.context, 'chat', 'still chat Settings');
});
test('chat Settings keep only the quick commands preference', async (t) => {
const f = mounted(t, { view: true });
await f.settle();