From 6ce9498d18403639e96efcacf14eb9394fe9e864 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 16:04:54 -0800 Subject: [PATCH] fix(voice): move focus with the voice panel that chat asks for Asking from the chat composer to open Voice Settings or Voice Preview closed chat around the focused input, leaving focus on a hidden control. Focus held in chat now moves to the requested view's tab, as the panel's own tabs move it; focus anywhere else stays put. --- src/voice/panel/host.test.mjs | 48 +++++++++++++++++++++++++++++++++-- src/voice/sessionCommands.js | 17 ++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index 7b7e33347..3c3b3332a 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -512,7 +512,13 @@ test('the voice inactivity timeout never ends a typed chat session', async (t) = async function directActions( t, - { storage = null, delayExit = false, exposePanel = true, typed = false } = {}, + { + storage = null, + delayExit = false, + exposePanel = true, + typed = false, + view = false, + } = {}, ) { let f, releaseExit; const state = { active: true, exits: 0, visual: [] }; @@ -544,7 +550,7 @@ async function directActions( readVoicePanelOperation: () => exposePanel ? f?.commands.captureVoicePanelOperation() : undefined, }); - f = mounted(t, { native: true, runner, storage }); + f = mounted(t, { native: true, runner, storage, view }); // A typed chat session starts from chat's first send instead. if (typed) { const adapter = f.instances[0]; @@ -671,6 +677,44 @@ for (const minimized of [false, true]) assert.equal(adapter.stops, 0); }); +for (const [panelId, target] of [ + ['voice-settings', 'gev-voice-settings-tab'], + ['voice-preview', 'gev-voice-preview-tab'], +]) + test(`${panelId} asked for from the chat composer takes focus with it`, async (t) => { + const f = await directActions(t, { typed: true, view: true }); + await f.settle(); + $('#gev-chat-button').click(); + await f.commands.session.start({ textOnly: true }); + await f.settle(); + $('#gev-chat-input').focus(); + assert.equal(active(), 'gev-chat-input'); + const result = await f.run('set_panel_open', { panelId, open: true }); + assert.equal(result.ok, true); + await f.settle(); + assert.equal($('#gev-chat-panel').hidden, true); + assert.equal(active(), target); + assert.equal(f.ui.visible('voicePanel'), true); + }); + +test('a voice panel asked for from chat leaves unrelated focus alone', async (t) => { + const f = await directActions(t, { typed: true, view: true }); + await f.settle(); + $('#gev-chat-button').click(); + await f.commands.session.start({ textOnly: true }); + await f.settle(); + const elsewhere = globalThis.document.body.appendChild( + globalThis.document.createElement('button'), + ); + elsewhere.id = 'elsewhere'; + t.after(() => elsewhere.remove()); + elsewhere.focus(); + await f.run('set_panel_open', { panelId: 'voice-settings', open: true }); + await f.settle(); + assert.equal($('#gev-chat-panel').hidden, true); + assert.equal(active(), 'elsewhere'); +}); + test('canonical exit reveals requested voice UI; newer manual choice or Stop defeats pending reveal', async (t) => { const f = await directActions(t, { delayExit: true }); const pending = f.run('control_cockpit', { diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index 738a6f8dc..78a1816b3 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -160,13 +160,28 @@ export function createVoiceCommands({ // An explicit, successful show hands the frame from chat to the voice // panel, which chat's open state hides. Closing chat keeps its typed // session, running turn and messages; reopening chat restores them. + // Focus held in chat (its composer, or chat Settings' Back) would be + // left on a hidden control, so it moves to the panel's view, as the + // panel's own tabs do; focus anywhere else stays where it is. controls.captureVoicePanelOperation = () => { const operation = panelMount.captureOperation(); return { ...operation, show(view, open = true) { + const focused = globalThis.document?.activeElement; + const inChat = + Boolean(focused) && + (ui.element('chatPanel')?.contains?.(focused) || + focused === ui.element('chatBack')); const result = operation.show(view, open); - if (open && result.ok) chat?.close(); + if (!open || !result.ok) return result; + chat?.close(); + if (inChat) + void ui.focus( + result.view === 'settings' + ? ['settingsTab', 'voiceBack', 'settingsClose'] + : ['previewTab', 'settingsTab', 'settingsClose'], + ); return result; }, };