From 53ea76251f46fd2b6f0c08914a30bbbb74a1f423 Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:34:30 +0530 Subject: [PATCH 1/3] fix(voice): keep chat's view across a provider switch and clear stale errors Switching AI Provider in Settings opened from chat rebuilt the voice control, so Settings fell back to voice and the dock went idle, widening the wings. The switch now carries chat's Settings, or chat itself, to the new provider as it already carries the input mode; the conversation still starts fresh and focus stays on the provider choice. After a voice error, opening chat or closing the voice panel left the dock red on ERROR, because both stopped only an active session. They now also end a session left in error, which returns the dock to idle. --- docs/CURRENT-STATE.md | 7 ++++-- src/voice/chatControl.js | 34 ++++++++++++++++++++++++++++- src/voice/chatControlView.test.mjs | 31 ++++++++++++++++++++++++++ src/voice/panel/host.test.mjs | 21 ++++++++++++++++++ src/voice/providerCommands.js | 7 ++++-- src/voice/providerCommands.test.mjs | 15 +++++++++++++ src/voice/sessionCommands.js | 11 +++++++++- 7 files changed, 120 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c3e15ea4d..fa2287567 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -766,9 +766,12 @@ each side, and the empty Voice Preview suggests things to say until voice starts. Settings opened from chat keep chat's frame and show the shared AI provider, the quick-commands option and then the voice preferences (Voice Preview, inactivity timeout and model tier), so they stay in reach while -Preview is off, with Back to chat; voice Settings show Back to voice. Escape steps back from either Settings view, closes Voice +Preview is off, with Back to chat; voice Settings show Back to voice. Switching +AI Provider keeps that view: chat's Settings, or chat itself, stay as they were +for the new provider. Escape steps back from either Settings view, closes Voice Preview, and does nothing in chat. Closing chat ends a typed session, and the -voice panel's close turns voice off. Asking the assistant from chat to open +voice panel's close turns voice off. Opening chat or closing the voice panel +also clears a voice error left on the dock. Asking the assistant from chat to open Voice Settings or Voice Preview puts chat away, minimized or not, and shows the voice panel; the typed conversation and its running reply carry on, and the chat button brings them back. diff --git a/src/voice/chatControl.js b/src/voice/chatControl.js index 6ab5bf494..d366181a7 100644 --- a/src/voice/chatControl.js +++ b/src/voice/chatControl.js @@ -94,7 +94,10 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { : ''; function open({ focus = true } = {}) { leaveChatSettings(); - if (!isTyped() && session.isActive()) session.stop(); + // Chat replaces voice, a stale voice error included, so the dock stops + // showing it. + if (!isTyped() && (session.isActive() || session.state === 'error')) + session.stop(); onOpen?.(); ui.update('chat', { open: true, minimized: false, rows: 2, authNote }); sync(); @@ -413,10 +416,39 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { }), ]; sync(); + // What a provider switch carries to the next mount: chat's Settings or + // chat itself, never the conversation. + function presentation() { + if (inChatSettings()) { + const { frameWidth, frameHeight } = ui.state.voicePanel; + return { view: 'settings', frameWidth, frameHeight }; + } + if (chat().open) return { view: chat().minimized ? 'minimized' : 'open' }; + return null; + } + // Restored without moving focus: the switch keeps it on the provider choice. + function restorePresentation(state) { + if (disposed || !state) return; + if (state.view === 'settings') { + ui.update('voicePanel', { + context: 'chat', + ...(state.frameWidth && { + frameWidth: state.frameWidth, + frameHeight: state.frameHeight, + }), + }); + ui.update('chat', { backVisible: true }); + return sync(); + } + open({ focus: false }); + if (state.view === 'minimized') minimize({ focus: false }); + } return { close, minimize, restore: open, + presentation, + restorePresentation, get minimized() { return chat().open && chat().minimized; }, diff --git a/src/voice/chatControlView.test.mjs b/src/voice/chatControlView.test.mjs index 5dee1ad97..e329a2dc3 100644 --- a/src/voice/chatControlView.test.mjs +++ b/src/voice/chatControlView.test.mjs @@ -364,3 +364,34 @@ test('the chat gear is named for the settings it opens', async (t) => { 'Settings', ); }); + +test('a provider switch carries chat Settings or chat itself to the next mount', async (t) => { + const mount = () => { + const ui = createTestVoiceUi({ panel: true }); + const chat = bindChatControl({ ui, session: typedSession() }); + t.after(() => chat.destroy()); + return { ui, chat }; + }; + // Chat's Settings: the next mount shows Settings in chat's frame again. + const before = mount(); + before.ui.emit('chat-button'); + before.ui.update('voicePanel', { hidden: false }); + before.ui.emit('settings-button'); + const settings = before.chat.presentation(); + assert.equal(settings.view, 'settings'); + const after = mount(); + after.chat.restorePresentation(settings); + assert.equal(after.ui.state.voicePanel.context, 'chat'); + assert.equal(after.ui.state.chat.backVisible, true); + assert.equal(after.ui.state.root.assistantMode, 'chat'); + // Open and minimized chat come back as they were, without taking focus. + for (const view of ['open', 'minimized']) { + const next = mount(); + next.chat.restorePresentation({ view }); + assert.equal(next.ui.state.chat.open, true); + assert.equal(next.ui.state.chat.minimized, view === 'minimized'); + assert.equal(next.chat.presentation().view, view); + } + // Idle chat carries nothing. + assert.equal(mount().chat.presentation(), null); +}); diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index 3c3b3332a..ae5cd1994 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -1298,6 +1298,27 @@ test('undocking while a tray is open brings the panel back as its bar', async (t assert.equal($('#gev-chat-panel').dataset.placement, 'compact'); }); +test('opening chat or closing the voice panel clears a stale voice error', async (t) => { + for (const leave of ['chat', 'close']) { + const f = mounted(t, { view: true }); + await f.commands.session.start(); + f.commands.voicePanel.select('preview'); + f.instances[0].hooks.emit({ + type: 'state', + state: 'error', + detail: 'Lost', + }); + await f.settle(); + assert.equal($('#gev-voice-control').dataset.status, 'error'); + assert.equal(f.commands.session.isActive(), false); + if (leave === 'chat') $('#gev-chat-button').click(); + else $('#gev-voice-settings-close').click(); + await f.settle(); + assert.equal(f.commands.session.state, 'idle', leave); + assert.notEqual($('#gev-voice-control').dataset.status, 'error', leave); + } +}); + test('the voice panel close turns voice off', async (t) => { const f = mounted(t, { view: true }); await f.commands.session.start(); diff --git a/src/voice/providerCommands.js b/src/voice/providerCommands.js index 66201141e..756215c7b 100644 --- a/src/voice/providerCommands.js +++ b/src/voice/providerCommands.js @@ -64,6 +64,8 @@ export function createProviderCommands(options, { bind, factories, storage }) { const textOnly = Boolean( current.session.adapter.getStartOptions?.().textOnly, ); + // The panel stays where the user is: chat's Settings or chat itself. + const presentation = current.chatPresentation?.() ?? null; const generation = ++mountGeneration; voiceEnabled = false; current.session.destroy(); @@ -80,7 +82,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { } catch { /* Storage is optional. */ } - mount(); + mount(presentation); if (resume && !disposed && provider === next) void current.session.start({ pushToTalk, @@ -142,7 +144,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { return typeof value === 'function' ? value.bind(current) : value; }, }); - function mount() { + function mount(presentation = null) { voiceEnabled = false; const generation = ++mountGeneration; current = bind({ @@ -158,6 +160,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { handleSessionEvent(event); }, createSession: factories[provider], + presentation, }); window.__gevVoiceCommands = facade; } diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index 41d092e73..dfcf77052 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -856,3 +856,18 @@ for (const action of ['stop', 'destroy']) { }); } } + +test('a provider switch hands chat Settings or chat to the new mount', async (t) => { + const f = compositionFixture(t); + const first = f.facade.provider; + const next = first === 'openai' ? 'gemini' : 'openai'; + // Without chat open there is nothing to carry. + assert.equal(f.mounts[0].controls.chatPresentation(), null); + const shown = { view: 'settings', frameWidth: 320, frameHeight: 480 }; + f.mounts[0].controls.chatPresentation = () => shown; + f.select(next); + await settle(); + assert.equal(f.mounts.length, 2); + assert.deepEqual(f.mounts[1].options.presentation, shown); + assert.equal(f.facade.provider, next); +}); diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index 78a1816b3..5a8a7e90c 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -40,6 +40,8 @@ export function createVoiceCommands({ createReticle = createDefaultReticle, panelHost = null, nativePanelProvider = null, + // Chat's Settings or chat itself, carried across a provider switch. + presentation = null, }) { if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true }); const ui = createControl(panelHost ? { panel: true } : {}); @@ -225,6 +227,8 @@ export function createVoiceCommands({ onOpen: () => panelMount?.panel.close(), }) : null; + chat?.restorePresentation(presentation); + controls.chatPresentation = () => chat?.presentation() ?? null; function minimizeVoicePanel() { const panel = ui.element('voicePanel'); const holdsFocus = panel?.contains?.(globalThis.document?.activeElement); @@ -284,7 +288,12 @@ export function createVoiceCommands({ // The panel's close turns voice off, as chat's close ends the chat. A typed // session belongs to chat, which handles its own close. const offClose = ui.on('settings-close', () => { - if (!session.isActive() || adapter.getStartOptions?.().textOnly) return; + // A voice error the user closes is over too; stopping returns to idle. + if ( + (!session.isActive() && session.state !== 'error') || + adapter.getStartOptions?.().textOnly + ) + return; const ticket = panelMount?.stopTicket(); session.stop(); panelMount?.closeAfterStop(ticket); From 46dde909ccc403585d0dc4958430ab2822f790ce Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:37:47 +0530 Subject: [PATCH 2/3] docs(changelog): add #1546 fragment --- changelog.d/1546-chat-provider-switch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1546-chat-provider-switch.md diff --git a/changelog.d/1546-chat-provider-switch.md b/changelog.d/1546-chat-provider-switch.md new file mode 100644 index 000000000..1c3210330 --- /dev/null +++ b/changelog.d/1546-chat-provider-switch.md @@ -0,0 +1,5 @@ +- Keep typed chat's view when switching AI Provider: Settings opened from + chat, or chat itself, stay as they were for the new provider instead of + falling back to voice and widening the dock. Opening chat or closing the + voice panel now also clears a voice error left on the dock (Manjunath, + @manjunath22466, #1546). From 3577d195133fc28f7c8bbc1ada3155188a85f3fa Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:17:08 +0530 Subject: [PATCH 3/3] Cover the real provider switch and share chat Settings and stop checks - Host test switches AI Provider from chat Settings through the real provider commands and checks the new mount keeps chat's context, frame, Back to chat and focus on the provider choice. - Drop the open and minimized branches of the carried presentation: the provider choice lives only in Settings, so chat is never open then. - One showChatSettings helper for the gear and the restore, and one isStoppable helper for the live-or-error check in chat and the panel close. --- changelog.d/1546-chat-provider-switch.md | 4 +- docs/CURRENT-STATE.md | 4 +- src/voice/chatControl.js | 56 ++++++++++-------------- src/voice/chatControlView.test.mjs | 23 +++++----- src/voice/panel/host.test.mjs | 50 +++++++++++++++++++++ src/voice/providerCommands.js | 2 +- src/voice/providerCommands.test.mjs | 4 +- src/voice/session.js | 4 ++ src/voice/sessionCommands.js | 10 ++--- 9 files changed, 99 insertions(+), 58 deletions(-) diff --git a/changelog.d/1546-chat-provider-switch.md b/changelog.d/1546-chat-provider-switch.md index 1c3210330..69cdbc939 100644 --- a/changelog.d/1546-chat-provider-switch.md +++ b/changelog.d/1546-chat-provider-switch.md @@ -1,5 +1,5 @@ - Keep typed chat's view when switching AI Provider: Settings opened from - chat, or chat itself, stay as they were for the new provider instead of - falling back to voice and widening the dock. Opening chat or closing the + chat stay chat's Settings for the new provider instead of falling back to + voice and widening the dock. Opening chat or closing the voice panel now also clears a voice error left on the dock (Manjunath, @manjunath22466, #1546). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fa2287567..c01693e3d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -767,8 +767,8 @@ starts. Settings opened from chat keep chat's frame and show the shared AI provider, the quick-commands option and then the voice preferences (Voice Preview, inactivity timeout and model tier), so they stay in reach while Preview is off, with Back to chat; voice Settings show Back to voice. Switching -AI Provider keeps that view: chat's Settings, or chat itself, stay as they were -for the new provider. Escape steps back from either Settings view, closes Voice +AI Provider keeps that view: Settings opened from chat stay chat's Settings, +in chat's frame, for the new provider. Escape steps back from either Settings view, closes Voice Preview, and does nothing in chat. Closing chat ends a typed session, and the voice panel's close turns voice off. Opening chat or closing the voice panel also clears a voice error left on the dock. Asking the assistant from chat to open diff --git a/src/voice/chatControl.js b/src/voice/chatControl.js index d366181a7..0525b9bc5 100644 --- a/src/voice/chatControl.js +++ b/src/voice/chatControl.js @@ -1,6 +1,7 @@ import { bindChatAttachments } from './chatAttachments.js'; import { bindChatPreferences } from './chatPreferences.js'; import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js'; +import { isStoppable } from './session.js'; import { t } from '../i18n/index.js'; /** @@ -96,8 +97,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { leaveChatSettings(); // Chat replaces voice, a stale voice error included, so the dock stops // showing it. - if (!isTyped() && (session.isActive() || session.state === 'error')) - session.stop(); + if (!isTyped() && isStoppable(session)) session.stop(); onOpen?.(); ui.update('chat', { open: true, minimized: false, rows: 2, authNote }); sync(); @@ -392,15 +392,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { } const frame = ui.element('chatPanel')?.getBoundingClientRect?.(); close(); - ui.update('voicePanel', { - context: 'chat', - ...(frame?.width && { - frameWidth: frame.width, - frameHeight: frame.height, - }), - }); - ui.update('chat', { backVisible: true }); - sync(); + showChatSettings(frame); void ui.focus(['chatBack']); }), // The panel clears its context as it closes, so chat tracks its own. @@ -416,32 +408,30 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { }), ]; sync(); - // What a provider switch carries to the next mount: chat's Settings or - // chat itself, never the conversation. + // Settings shown from chat, in chat's frame when it has one. + function showChatSettings(frame) { + ui.update('voicePanel', { + context: 'chat', + ...(frame?.width && { + frameWidth: frame.width, + frameHeight: frame.height, + }), + }); + ui.update('chat', { backVisible: true }); + sync(); + } + // What a provider switch carries to the next mount: chat's Settings, never + // the conversation. The provider choice lives only in Settings, so chat + // itself is never open during a switch. function presentation() { - if (inChatSettings()) { - const { frameWidth, frameHeight } = ui.state.voicePanel; - return { view: 'settings', frameWidth, frameHeight }; - } - if (chat().open) return { view: chat().minimized ? 'minimized' : 'open' }; - return null; + if (!inChatSettings()) return null; + const { frameWidth: width, frameHeight: height } = ui.state.voicePanel; + return { view: 'settings', frame: width ? { width, height } : null }; } // Restored without moving focus: the switch keeps it on the provider choice. function restorePresentation(state) { - if (disposed || !state) return; - if (state.view === 'settings') { - ui.update('voicePanel', { - context: 'chat', - ...(state.frameWidth && { - frameWidth: state.frameWidth, - frameHeight: state.frameHeight, - }), - }); - ui.update('chat', { backVisible: true }); - return sync(); - } - open({ focus: false }); - if (state.view === 'minimized') minimize({ focus: false }); + if (disposed || state?.view !== 'settings') return; + showChatSettings(state.frame); } return { close, diff --git a/src/voice/chatControlView.test.mjs b/src/voice/chatControlView.test.mjs index e329a2dc3..c0175456c 100644 --- a/src/voice/chatControlView.test.mjs +++ b/src/voice/chatControlView.test.mjs @@ -365,7 +365,7 @@ test('the chat gear is named for the settings it opens', async (t) => { ); }); -test('a provider switch carries chat Settings or chat itself to the next mount', async (t) => { +test('a provider switch carries chat Settings to the next mount', async (t) => { const mount = () => { const ui = createTestVoiceUi({ panel: true }); const chat = bindChatControl({ ui, session: typedSession() }); @@ -377,21 +377,22 @@ test('a provider switch carries chat Settings or chat itself to the next mount', before.ui.emit('chat-button'); before.ui.update('voicePanel', { hidden: false }); before.ui.emit('settings-button'); + before.ui.update('voicePanel', { frameWidth: 320, frameHeight: 480 }); const settings = before.chat.presentation(); - assert.equal(settings.view, 'settings'); + assert.deepEqual(settings, { + view: 'settings', + frame: { width: 320, height: 480 }, + }); const after = mount(); after.chat.restorePresentation(settings); assert.equal(after.ui.state.voicePanel.context, 'chat'); + assert.equal(after.ui.state.voicePanel.frameWidth, 320); + assert.equal(after.ui.state.voicePanel.frameHeight, 480); assert.equal(after.ui.state.chat.backVisible, true); assert.equal(after.ui.state.root.assistantMode, 'chat'); - // Open and minimized chat come back as they were, without taking focus. - for (const view of ['open', 'minimized']) { - const next = mount(); - next.chat.restorePresentation({ view }); - assert.equal(next.ui.state.chat.open, true); - assert.equal(next.ui.state.chat.minimized, view === 'minimized'); - assert.equal(next.chat.presentation().view, view); - } - // Idle chat carries nothing. + // Chat itself, or no chat at all, carries nothing. + const open = mount(); + open.ui.emit('chat-button'); + assert.equal(open.chat.presentation(), null); assert.equal(mount().chat.presentation(), null); }); diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index ae5cd1994..57b512ac0 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -1319,6 +1319,56 @@ test('opening chat or closing the voice panel clears a stale voice error', async } }); +test('switching AI Provider from chat Settings keeps chat Settings on the new mount', async (t) => { + const view = mountVoiceView(t); + const host = createVoicePanelHost({ storage: null }); + const instances = []; + const commands = createProviderCommands( + { + runner: async () => ({}), + dataManager: { layers: new Map() }, + supportedProviders: ['openai', 'gemini'], + }, + { + bind: (options) => + bind({ + ...options, + panelHost: host, + createControl: (control) => + createTestVoiceUi({ ...control, pinia: view.pinia }), + }), + factories: { openai: factory(instances), gemini: factory(instances) }, + storage: null, + }, + ); + t.after(() => { + commands.destroy(); + host.destroy(); + }); + await view.settle(); + $('#gev-chat-button').click(); + await view.settle(); + $('#gev-chat-panel').getBoundingClientRect = () => ({ + width: 320, + height: 480, + }); + $('#gev-voice-settings-button').click(); + await view.settle(); + const old = commands.ui; + assert.equal(old.state.voicePanel.context, 'chat'); + old.emit('provider-change', 'gemini'); + await view.settle(); + const { ui } = commands; + assert.notEqual(ui, old); + assert.equal(ui.state.voicePanel.context, 'chat'); + assert.equal(ui.state.voicePanel.frameWidth, 320); + assert.equal(ui.state.voicePanel.frameHeight, 480); + assert.equal(ui.state.chat.backVisible, true); + assert.equal(ui.state.root.assistantMode, 'chat'); + assert.equal($('#gev-chat-back').hidden, false); + assert.equal(active(), 'gev-voice-provider'); +}); + test('the voice panel close turns voice off', async (t) => { const f = mounted(t, { view: true }); await f.commands.session.start(); diff --git a/src/voice/providerCommands.js b/src/voice/providerCommands.js index 756215c7b..5d77ebef6 100644 --- a/src/voice/providerCommands.js +++ b/src/voice/providerCommands.js @@ -64,7 +64,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { const textOnly = Boolean( current.session.adapter.getStartOptions?.().textOnly, ); - // The panel stays where the user is: chat's Settings or chat itself. + // Settings opened from chat stay chat's Settings. const presentation = current.chatPresentation?.() ?? null; const generation = ++mountGeneration; voiceEnabled = false; diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index dfcf77052..a9acd2804 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -857,13 +857,13 @@ for (const action of ['stop', 'destroy']) { } } -test('a provider switch hands chat Settings or chat to the new mount', async (t) => { +test('a provider switch hands chat Settings to the new mount', async (t) => { const f = compositionFixture(t); const first = f.facade.provider; const next = first === 'openai' ? 'gemini' : 'openai'; // Without chat open there is nothing to carry. assert.equal(f.mounts[0].controls.chatPresentation(), null); - const shown = { view: 'settings', frameWidth: 320, frameHeight: 480 }; + const shown = { view: 'settings', frame: { width: 320, height: 480 } }; f.mounts[0].controls.chatPresentation = () => shown; f.select(next); await settle(); diff --git a/src/voice/session.js b/src/voice/session.js index 21400f04c..7c31f17d2 100644 --- a/src/voice/session.js +++ b/src/voice/session.js @@ -185,6 +185,10 @@ export function createVoiceSession({ createAdapter, runner, signal }) { }; } +// Live, or left in error: stopping either returns the session to idle. +export const isStoppable = (session) => + session.isActive() || session.state === 'error'; + function abortError() { return new DOMException('Voice action cancelled', 'AbortError'); } diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index 5a8a7e90c..242a2126b 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -3,7 +3,7 @@ import { bindAssistantPanelLayout, bindLayoutDock } from './panelLayout.js'; import { bindChatControl } from './chatControl.js'; import { bindVoiceMiniPreview } from './voiceMiniPreview.js'; import { createVoiceControl, voiceStatusLabel } from './control.js'; -import { createVoiceSession } from './session.js'; +import { createVoiceSession, isStoppable } from './session.js'; import { VoiceCardControls } from './voiceCard.js'; import { PointerReticle } from './pointerReticle.js'; import { VOICE_PROVIDERS } from './providerCapabilities.js'; @@ -40,7 +40,7 @@ export function createVoiceCommands({ createReticle = createDefaultReticle, panelHost = null, nativePanelProvider = null, - // Chat's Settings or chat itself, carried across a provider switch. + // Chat's Settings, carried across a provider switch. presentation = null, }) { if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true }); @@ -289,11 +289,7 @@ export function createVoiceCommands({ // session belongs to chat, which handles its own close. const offClose = ui.on('settings-close', () => { // A voice error the user closes is over too; stopping returns to idle. - if ( - (!session.isActive() && session.state !== 'error') || - adapter.getStartOptions?.().textOnly - ) - return; + if (!isStoppable(session) || adapter.getStartOptions?.().textOnly) return; const ticket = panelMount?.stopTicket(); session.stop(); panelMount?.closeAfterStop(ticket);