mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24: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
|
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
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
function abortError() {
|
||||||
return new DOMException('Voice action cancelled', 'AbortError');
|
return new DOMException('Voice action cancelled', 'AbortError');
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user