Merge branch 'main' into pr/wildlife-sightings

This commit is contained in:
Yehe Liu authored and GitHub committed 2026-11-05 17:04:06 -05:00
commit 4423f4b4a6
9 files changed
+176 -16

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
- Keep typed chat's view when switching AI Provider: Settings opened from
chat stay chat's Settings for the new provider instead of falling back to
voice and widening the dock. Opening chat or closing the
voice panel now also clears a voice error left on the dock (Manjunath,
@manjunath22466, #1546).
+5 -2
View File
@@ -853,9 +853,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 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. 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: Settings opened from chat stay chat's Settings,
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. 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 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 voice panel; the typed conversation and its running reply carry on, and the
chat button brings them back. chat button brings them back.
+32 -10
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';
/** /**
@@ -94,7 +95,9 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) {
: ''; : '';
function open({ focus = true } = {}) { function open({ focus = true } = {}) {
leaveChatSettings(); leaveChatSettings();
if (!isTyped() && session.isActive()) session.stop(); // Chat replaces voice, a stale voice error included, so the dock stops
// showing it.
if (!isTyped() && isStoppable(session)) 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();
@@ -389,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.
@@ -413,10 +408,37 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) {
}), }),
]; ];
sync(); sync();
// Settings shown from chat, in chat's frame when it has one.
function showChatSettings(frame) {
ui.update('voicePanel', {
context: 'chat',
...(frame?.width && {
frameWidth: frame.width,
frameHeight: frame.height,
}),
});
ui.update('chat', { backVisible: true });
sync();
}
// What a provider switch carries to the next mount: chat's Settings, never
// 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,
minimize, minimize,
restore: open, restore: open,
presentation,
restorePresentation,
get minimized() { get minimized() {
return chat().open && chat().minimized; return chat().open && chat().minimized;
}, },
+32
View File
@@ -364,3 +364,35 @@ test('the chat gear is named for the settings it opens', async (t) => {
'Settings', 'Settings',
); );
}); });
test('a provider switch carries chat Settings 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');
before.ui.update('voicePanel', { frameWidth: 320, frameHeight: 480 });
const settings = before.chat.presentation();
assert.deepEqual(settings, {
view: 'settings',
frame: { width: 320, height: 480 },
});
const after = mount();
after.chat.restorePresentation(settings);
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.root.assistantMode, 'chat');
// Chat itself, or no chat at all, carries nothing.
const open = mount();
open.ui.emit('chat-button');
assert.equal(open.chat.presentation(), null);
assert.equal(mount().chat.presentation(), null);
});
+71
View File
@@ -1298,6 +1298,77 @@ test('undocking while a tray is open brings the panel back as its bar', async (t
assert.equal($('#gev-chat-panel').dataset.placement, 'compact'); 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('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();
+5 -2
View File
@@ -64,6 +64,8 @@ export function createProviderCommands(options, { bind, factories, storage }) {
const textOnly = Boolean( const textOnly = Boolean(
current.session.adapter.getStartOptions?.().textOnly, current.session.adapter.getStartOptions?.().textOnly,
); );
// Settings opened from chat stay chat's Settings.
const presentation = current.chatPresentation?.() ?? null;
const generation = ++mountGeneration; const generation = ++mountGeneration;
voiceEnabled = false; voiceEnabled = false;
current.session.destroy(); current.session.destroy();
@@ -80,7 +82,7 @@ export function createProviderCommands(options, { bind, factories, storage }) {
} catch { } catch {
/* Storage is optional. */ /* Storage is optional. */
} }
mount(); mount(presentation);
if (resume && !disposed && provider === next) if (resume && !disposed && provider === next)
void current.session.start({ void current.session.start({
pushToTalk, pushToTalk,
@@ -142,7 +144,7 @@ export function createProviderCommands(options, { bind, factories, storage }) {
return typeof value === 'function' ? value.bind(current) : value; return typeof value === 'function' ? value.bind(current) : value;
}, },
}); });
function mount() { function mount(presentation = null) {
voiceEnabled = false; voiceEnabled = false;
const generation = ++mountGeneration; const generation = ++mountGeneration;
current = bind({ current = bind({
@@ -158,6 +160,7 @@ export function createProviderCommands(options, { bind, factories, storage }) {
handleSessionEvent(event); handleSessionEvent(event);
}, },
createSession: factories[provider], createSession: factories[provider],
presentation,
}); });
window.__gevVoiceCommands = facade; window.__gevVoiceCommands = facade;
} }
+15
View File
@@ -856,3 +856,18 @@ for (const action of ['stop', 'destroy']) {
}); });
} }
} }
test('a provider switch hands chat Settings 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', frame: { width: 320, height: 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);
});
+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');
} }
+7 -2
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,6 +40,8 @@ export function createVoiceCommands({
createReticle = createDefaultReticle, createReticle = createDefaultReticle,
panelHost = null, panelHost = null,
nativePanelProvider = null, nativePanelProvider = null,
// Chat's Settings, carried across a provider switch.
presentation = null,
}) { }) {
if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true }); if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true });
const ui = createControl(panelHost ? { panel: true } : {}); const ui = createControl(panelHost ? { panel: true } : {});
@@ -225,6 +227,8 @@ export function createVoiceCommands({
onOpen: () => panelMount?.panel.close(), onOpen: () => panelMount?.panel.close(),
}) })
: null; : null;
chat?.restorePresentation(presentation);
controls.chatPresentation = () => chat?.presentation() ?? null;
function minimizeVoicePanel() { function minimizeVoicePanel() {
const panel = ui.element('voicePanel'); const panel = ui.element('voicePanel');
const holdsFocus = panel?.contains?.(globalThis.document?.activeElement); const holdsFocus = panel?.contains?.(globalThis.document?.activeElement);
@@ -284,7 +288,8 @@ export function createVoiceCommands({
// The panel's close turns voice off, as chat's close ends the chat. A typed // 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. // session belongs to chat, which handles its own close.
const offClose = ui.on('settings-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 (!isStoppable(session) || adapter.getStartOptions?.().textOnly) return;
const ticket = panelMount?.stopTicket(); const ticket = panelMount?.stopTicket();
session.stop(); session.stop();
panelMount?.closeAfterStop(ticket); panelMount?.closeAfterStop(ticket);