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);