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; }, };