diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a189a61ba..f5c694b42 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -767,7 +767,10 @@ starts. Settings opened from chat keep chat's frame and show the shared AI provider and the quick-commands option, with Back to chat; voice Settings show Back to voice. 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. +voice panel's close turns voice off. 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. Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even after the camera moves. An explicit new scope replaces that basis. Provider diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index 06766a108..3c7a8bbd5 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -511,7 +511,7 @@ test('the voice inactivity timeout never ends a typed chat session', async (t) = async function directActions( t, - { storage = null, delayExit = false, exposePanel = true } = {}, + { storage = null, delayExit = false, exposePanel = true, typed = false } = {}, ) { let f, releaseExit; const state = { active: true, exits: 0, visual: [] }; @@ -544,7 +544,12 @@ async function directActions( exposePanel ? f?.commands.captureVoicePanelOperation() : undefined, }); f = mounted(t, { native: true, runner, storage }); - await f.commands.session.start(); + // A typed chat session starts from chat's first send instead. + if (typed) { + const adapter = f.instances[0]; + adapter.getStartOptions = () => ({ textOnly: true }); + adapter.sendText = () => true; + } else await f.commands.session.start(); return { ...f, runner, @@ -618,6 +623,53 @@ test('named voice settings exits Cockpit directly; Preview stays disabled and vi ); }); +for (const minimized of [false, true]) + test(`an explicit voice panel request from ${minimized ? 'minimized ' : ''}typed chat shows the panel and keeps the chat`, async (t) => { + const f = await directActions(t, { typed: true }); + const adapter = f.instances[0]; + f.ui.emit('chat-button'); + f.ui.emit('chat-input', 'Fly to Tokyo'); + f.ui.emit('chat-submit'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(f.ui.state.chat.busy, true, 'the typed turn is running'); + if (minimized) f.ui.emit('chat-minimize'); + assert.equal(f.ui.state.root.chatOpen, true); + for (const [panelId, view] of [ + ['voice-settings', 'settings'], + ['voice-preview', 'preview'], + ]) { + const result = await f.run('set_panel_open', { panelId, open: true }); + assert.equal(result.ok, true); + // `[data-chat-open='true'] .gev-voice-panel` is display:none. + assert.equal(f.ui.state.root.chatOpen, false, panelId); + assert.equal(f.ui.state.chat.open, false); + assert.equal(f.ui.state.voicePanel.hidden, false); + assert.equal(f.ui.state.voicePanel.view, view); + } + assert.equal(adapter.stops, 0, 'the typed session continues'); + assert.equal(f.commands.session.isActive(), true); + assert.equal(f.ui.state.chat.busy, true, 'the turn is not cancelled'); + adapter.hooks.emit({ + type: 'transcript', + role: 'assistant', + text: 'Flying to Tokyo.', + final: true, + itemId: 'reply', + }); + adapter.hooks.emit({ type: 'completion', status: 'completed' }); + assert.equal(f.ui.state.chat.busy, false); + f.ui.emit('chat-button'); + assert.equal(f.ui.state.root.chatOpen, true); + assert.equal(f.ui.state.voicePanel.hidden, true); + assert.deepEqual( + f.ui.chatMessages.map(({ role }) => role), + ['user', 'assistant'], + 'reopened chat shows the whole conversation', + ); + assert.equal(f.ui.chatMessages[0].text, 'Fly to Tokyo'); + assert.equal(adapter.stops, 0); + }); + 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 e66219748..738a6f8dc 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -157,7 +157,20 @@ export function createVoiceCommands({ controls.voiceCard = card; if (panelMount) { controls.voicePanel = panelMount.panel; - controls.captureVoicePanelOperation = panelMount.captureOperation; + // 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. + controls.captureVoicePanelOperation = () => { + const operation = panelMount.captureOperation(); + return { + ...operation, + show(view, open = true) { + const result = operation.show(view, open); + if (open && result.ok) chat?.close(); + return result; + }, + }; + }; controls.getVoicePanelState = panelMount.snapshot; controls.getVoicePreferences = panelHost.snapshot; }