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.
This commit is contained in:
manjunath22466 committed 2026-11-05 00:17:08 +05:30
1 parent 46dde909cc
commit 3577d19513
9 files changed
+97 -56

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
- Keep typed chat's view when switching AI Provider: Settings opened from - 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 chat stay chat's Settings for the new provider instead of falling back to
falling back to voice and widening the dock. Opening chat or closing the voice and widening the dock. Opening chat or closing the
voice panel now also clears a voice error left on the dock (Manjunath, voice panel now also clears a voice error left on the dock (Manjunath,
@manjunath22466, #1546). @manjunath22466, #1546).
+2 -2
View File
@@ -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 provider, the quick-commands option and then the voice preferences (Voice
Preview, inactivity timeout and model tier), so they stay in reach while 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 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 AI Provider keeps that view: Settings opened from chat stay chat's Settings,
for the new provider. Escape steps back from either Settings view, closes Voice 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 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 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 also clears a voice error left on the dock. Asking the assistant from chat to open
+21 -31
View File
@@ -1,6 +1,7 @@
import { bindChatAttachments } from './chatAttachments.js'; import { bindChatAttachments } from './chatAttachments.js';
import { bindChatPreferences } from './chatPreferences.js'; import { bindChatPreferences } from './chatPreferences.js';
import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js'; import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js';
import { isStoppable } from './session.js';
import { t } from '../i18n/index.js'; import { t } from '../i18n/index.js';
/** /**
@@ -96,8 +97,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) {
leaveChatSettings(); leaveChatSettings();
// Chat replaces voice, a stale voice error included, so the dock stops // Chat replaces voice, a stale voice error included, so the dock stops
// showing it. // showing it.
if (!isTyped() && (session.isActive() || session.state === 'error')) if (!isTyped() && isStoppable(session)) session.stop();
session.stop();
onOpen?.(); onOpen?.();
ui.update('chat', { open: true, minimized: false, rows: 2, authNote }); ui.update('chat', { open: true, minimized: false, rows: 2, authNote });
sync(); sync();
@@ -392,15 +392,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) {
} }
const frame = ui.element('chatPanel')?.getBoundingClientRect?.(); const frame = ui.element('chatPanel')?.getBoundingClientRect?.();
close(); close();
ui.update('voicePanel', { showChatSettings(frame);
context: 'chat',
...(frame?.width && {
frameWidth: frame.width,
frameHeight: frame.height,
}),
});
ui.update('chat', { backVisible: true });
sync();
void ui.focus(['chatBack']); void ui.focus(['chatBack']);
}), }),
// The panel clears its context as it closes, so chat tracks its own. // 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(); sync();
// What a provider switch carries to the next mount: chat's Settings or // Settings shown from chat, in chat's frame when it has one.
// chat itself, never the conversation. function showChatSettings(frame) {
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', { ui.update('voicePanel', {
context: 'chat', context: 'chat',
...(state.frameWidth && { ...(frame?.width && {
frameWidth: state.frameWidth, frameWidth: frame.width,
frameHeight: state.frameHeight, frameHeight: frame.height,
}), }),
}); });
ui.update('chat', { backVisible: true }); ui.update('chat', { backVisible: true });
return sync(); sync();
} }
open({ focus: false }); // What a provider switch carries to the next mount: chat's Settings, never
if (state.view === 'minimized') minimize({ focus: false }); // the conversation. The provider choice lives only in Settings, so chat
// itself is never open during a switch.
function presentation() {
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?.view !== 'settings') return;
showChatSettings(state.frame);
} }
return { return {
close, close,
+12 -11
View File
@@ -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 mount = () => {
const ui = createTestVoiceUi({ panel: true }); const ui = createTestVoiceUi({ panel: true });
const chat = bindChatControl({ ui, session: typedSession() }); 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.emit('chat-button');
before.ui.update('voicePanel', { hidden: false }); before.ui.update('voicePanel', { hidden: false });
before.ui.emit('settings-button'); before.ui.emit('settings-button');
before.ui.update('voicePanel', { frameWidth: 320, frameHeight: 480 });
const settings = before.chat.presentation(); const settings = before.chat.presentation();
assert.equal(settings.view, 'settings'); assert.deepEqual(settings, {
view: 'settings',
frame: { width: 320, height: 480 },
});
const after = mount(); const after = mount();
after.chat.restorePresentation(settings); after.chat.restorePresentation(settings);
assert.equal(after.ui.state.voicePanel.context, 'chat'); 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.chat.backVisible, true);
assert.equal(after.ui.state.root.assistantMode, 'chat'); assert.equal(after.ui.state.root.assistantMode, 'chat');
// Open and minimized chat come back as they were, without taking focus. // Chat itself, or no chat at all, carries nothing.
for (const view of ['open', 'minimized']) { const open = mount();
const next = mount(); open.ui.emit('chat-button');
next.chat.restorePresentation({ view }); assert.equal(open.chat.presentation(), null);
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); assert.equal(mount().chat.presentation(), null);
}); });
+50
View File
@@ -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) => { test('the voice panel close turns voice off', async (t) => {
const f = mounted(t, { view: true }); const f = mounted(t, { view: true });
await f.commands.session.start(); await f.commands.session.start();
+1 -1
View File
@@ -64,7 +64,7 @@ export function createProviderCommands(options, { bind, factories, storage }) {
const textOnly = Boolean( const textOnly = Boolean(
current.session.adapter.getStartOptions?.().textOnly, 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 presentation = current.chatPresentation?.() ?? null;
const generation = ++mountGeneration; const generation = ++mountGeneration;
voiceEnabled = false; voiceEnabled = false;
+2 -2
View File
@@ -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 f = compositionFixture(t);
const first = f.facade.provider; const first = f.facade.provider;
const next = first === 'openai' ? 'gemini' : 'openai'; const next = first === 'openai' ? 'gemini' : 'openai';
// Without chat open there is nothing to carry. // Without chat open there is nothing to carry.
assert.equal(f.mounts[0].controls.chatPresentation(), null); 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.mounts[0].controls.chatPresentation = () => shown;
f.select(next); f.select(next);
await settle(); await settle();
+4
View File
@@ -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() { function abortError() {
return new DOMException('Voice action cancelled', 'AbortError'); return new DOMException('Voice action cancelled', 'AbortError');
} }
+3 -7
View File
@@ -3,7 +3,7 @@ import { bindAssistantPanelLayout, bindLayoutDock } from './panelLayout.js';
import { bindChatControl } from './chatControl.js'; import { bindChatControl } from './chatControl.js';
import { bindVoiceMiniPreview } from './voiceMiniPreview.js'; import { bindVoiceMiniPreview } from './voiceMiniPreview.js';
import { createVoiceControl, voiceStatusLabel } from './control.js'; import { createVoiceControl, voiceStatusLabel } from './control.js';
import { createVoiceSession } from './session.js'; import { createVoiceSession, isStoppable } from './session.js';
import { VoiceCardControls } from './voiceCard.js'; import { VoiceCardControls } from './voiceCard.js';
import { PointerReticle } from './pointerReticle.js'; import { PointerReticle } from './pointerReticle.js';
import { VOICE_PROVIDERS } from './providerCapabilities.js'; import { VOICE_PROVIDERS } from './providerCapabilities.js';
@@ -40,7 +40,7 @@ export function createVoiceCommands({
createReticle = createDefaultReticle, createReticle = createDefaultReticle,
panelHost = null, panelHost = null,
nativePanelProvider = null, nativePanelProvider = null,
// Chat's Settings or chat itself, carried across a provider switch. // Chat's Settings, carried across a provider switch.
presentation = null, presentation = null,
}) { }) {
if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true }); if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true });
@@ -289,11 +289,7 @@ export function createVoiceCommands({
// session belongs to chat, which handles its own close. // session belongs to chat, which handles its own close.
const offClose = ui.on('settings-close', () => { const offClose = ui.on('settings-close', () => {
// A voice error the user closes is over too; stopping returns to idle. // A voice error the user closes is over too; stopping returns to idle.
if ( if (!isStoppable(session) || adapter.getStartOptions?.().textOnly) return;
(!session.isActive() && session.state !== 'error') ||
adapter.getStartOptions?.().textOnly
)
return;
const ticket = panelMount?.stopTicket(); const ticket = panelMount?.stopTicket();
session.stop(); session.stop();
panelMount?.closeAfterStop(ticket); panelMount?.closeAfterStop(ticket);