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

+32 -10
View File
@@ -1,6 +1,7 @@
import { bindChatAttachments } from './chatAttachments.js';
import { bindChatPreferences } from './chatPreferences.js';
import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js';
import { isStoppable } from './session.js';
import { t } from '../i18n/index.js';
/**
@@ -94,7 +95,9 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) {
: '';
function open({ focus = true } = {}) {
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?.();
ui.update('chat', { open: true, minimized: false, rows: 2, authNote });
sync();
@@ -389,15 +392,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) {
}
const frame = ui.element('chatPanel')?.getBoundingClientRect?.();
close();
ui.update('voicePanel', {
context: 'chat',
...(frame?.width && {
frameWidth: frame.width,
frameHeight: frame.height,
}),
});
ui.update('chat', { backVisible: true });
sync();
showChatSettings(frame);
void ui.focus(['chatBack']);
}),
// 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();
// 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 {
close,
minimize,
restore: open,
presentation,
restorePresentation,
get 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',
);
});
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');
});
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) => {
const f = mounted(t, { view: true });
await f.commands.session.start();
+5 -2
View File
@@ -64,6 +64,8 @@ export function createProviderCommands(options, { bind, factories, storage }) {
const textOnly = Boolean(
current.session.adapter.getStartOptions?.().textOnly,
);
// Settings opened from chat stay chat's Settings.
const presentation = current.chatPresentation?.() ?? null;
const generation = ++mountGeneration;
voiceEnabled = false;
current.session.destroy();
@@ -80,7 +82,7 @@ export function createProviderCommands(options, { bind, factories, storage }) {
} catch {
/* Storage is optional. */
}
mount();
mount(presentation);
if (resume && !disposed && provider === next)
void current.session.start({
pushToTalk,
@@ -142,7 +144,7 @@ export function createProviderCommands(options, { bind, factories, storage }) {
return typeof value === 'function' ? value.bind(current) : value;
},
});
function mount() {
function mount(presentation = null) {
voiceEnabled = false;
const generation = ++mountGeneration;
current = bind({
@@ -158,6 +160,7 @@ export function createProviderCommands(options, { bind, factories, storage }) {
handleSessionEvent(event);
},
createSession: factories[provider],
presentation,
});
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() {
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 { bindVoiceMiniPreview } from './voiceMiniPreview.js';
import { createVoiceControl, voiceStatusLabel } from './control.js';
import { createVoiceSession } from './session.js';
import { createVoiceSession, isStoppable } from './session.js';
import { VoiceCardControls } from './voiceCard.js';
import { PointerReticle } from './pointerReticle.js';
import { VOICE_PROVIDERS } from './providerCapabilities.js';
@@ -40,6 +40,8 @@ export function createVoiceCommands({
createReticle = createDefaultReticle,
panelHost = null,
nativePanelProvider = null,
// Chat's Settings, carried across a provider switch.
presentation = null,
}) {
if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true });
const ui = createControl(panelHost ? { panel: true } : {});
@@ -225,6 +227,8 @@ export function createVoiceCommands({
onOpen: () => panelMount?.panel.close(),
})
: null;
chat?.restorePresentation(presentation);
controls.chatPresentation = () => chat?.presentation() ?? null;
function minimizeVoicePanel() {
const panel = ui.element('voicePanel');
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
// session belongs to chat, which handles its own 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();
session.stop();
panelMount?.closeAfterStop(ticket);