mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Merge branch 'main' into pr/wildlife-sightings
This commit is contained in:
9 files changed
+176
-16
No files matched your search
@@ -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).
|
||||
@@ -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
|
||||
provider, the quick-commands option and then the voice preferences (Voice
|
||||
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
|
||||
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 panel; the typed conversation and its running reply carry on, and the
|
||||
chat button brings them back.
|
||||
|
||||
+32
-10
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user