feat(desktop): add notch wake indicator
This commit is contained in:
@@ -204,6 +204,7 @@ import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
|
||||
import { resolveStagedUpdaterBinary, spawnUpdaterProcess } from './updater-process'
|
||||
import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan'
|
||||
import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace'
|
||||
import { createWakeIndicatorWindowController } from './wake-indicator-window'
|
||||
import {
|
||||
computeWindowOptions,
|
||||
debounce,
|
||||
@@ -8857,6 +8858,17 @@ function createInstanceWindow() {
|
||||
return win
|
||||
}
|
||||
|
||||
// A macOS-only ambient wake cue. It is deliberately a gateway-less helper
|
||||
// window: the active renderer owns voice state and sends only the visual phase.
|
||||
const wakeIndicatorController = createWakeIndicatorWindowController({
|
||||
devServer: DEV_SERVER,
|
||||
isMac: IS_MAC,
|
||||
loadWindowUrl,
|
||||
preloadPath: PRELOAD_PATH,
|
||||
rendererIndex: resolveRendererIndex,
|
||||
wireWindow: window => wireCommonWindowHandlers(window, zoomWiringForWindowKind('wakeIndicator'))
|
||||
})
|
||||
|
||||
// The pet overlay: a single transparent, frameless, always-on-top window that
|
||||
// hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out"
|
||||
// here so it can leave the app's bounds and stay visible while Hermes is
|
||||
@@ -9517,6 +9529,10 @@ ipcMain.handle('hermes:window:openInstance', async () => {
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:wake-indicator:get', () => wakeIndicatorController.getState())
|
||||
ipcMain.on('hermes:wake-indicator:set', (_event, state) => {
|
||||
wakeIndicatorController.setState(state)
|
||||
})
|
||||
|
||||
// --- Text size (zoom) -------------------------------------------------------
|
||||
// The settings UI drives the same clamped zoom scale as the Ctrl/Cmd
|
||||
@@ -11796,6 +11812,17 @@ app.whenReady().then(() => {
|
||||
// it without the renderer visiting Settings. A failed registration is logged
|
||||
// here and surfaced in Settings via the IPC state (never silent).
|
||||
applyQuickEntrySettings(readQuickEntrySettings())
|
||||
|
||||
if (IS_MAC) {
|
||||
const reposition = () => wakeIndicatorController.reposition()
|
||||
|
||||
screen.on('display-added', reposition)
|
||||
|
||||
screen.on('display-metrics-changed', reposition)
|
||||
|
||||
screen.on('display-removed', reposition)
|
||||
}
|
||||
|
||||
createWindow()
|
||||
|
||||
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
|
||||
@@ -11924,6 +11951,7 @@ app.on('before-quit', event => {
|
||||
// The always-on-top overlay isn't a "real" app window; close it so a stray
|
||||
// pet can't keep the process alive or float over a quit app.
|
||||
closePetOverlay()
|
||||
wakeIndicatorController.close()
|
||||
|
||||
// Same for the Quick Entry composer — and release its global accelerator so a
|
||||
// quitting Hermes never keeps another app's chord hostage.
|
||||
|
||||
@@ -8,6 +8,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||
openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'),
|
||||
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
|
||||
wakeIndicator: {
|
||||
getState: () => ipcRenderer.invoke('hermes:wake-indicator:get'),
|
||||
setState: state => ipcRenderer.send('hermes:wake-indicator:set', state),
|
||||
onState: callback => {
|
||||
const listener = (_event, state) => callback(state)
|
||||
ipcRenderer.on('hermes:wake-indicator:state', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:wake-indicator:state', listener)
|
||||
}
|
||||
},
|
||||
petOverlay: {
|
||||
// Main renderer → main process: window lifecycle + drag. `request` is
|
||||
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
|
||||
|
||||
174
apps/desktop/electron/wake-indicator-window.ts
Normal file
174
apps/desktop/electron/wake-indicator-window.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { BrowserWindow, screen } from 'electron'
|
||||
|
||||
import {
|
||||
normalizeWakeIndicatorState,
|
||||
selectWakeIndicatorDisplay,
|
||||
WAKE_INDICATOR_FADE_MS,
|
||||
type WakeIndicatorState,
|
||||
wakeIndicatorWindowBounds
|
||||
} from './wake-indicator'
|
||||
|
||||
interface WakeIndicatorWindowOptions {
|
||||
devServer?: string
|
||||
isMac: boolean
|
||||
loadWindowUrl: (window: BrowserWindow, url: string, label: string) => void
|
||||
preloadPath: string
|
||||
rendererIndex: () => string
|
||||
wireWindow: (window: BrowserWindow) => void
|
||||
}
|
||||
|
||||
export function createWakeIndicatorWindowController({
|
||||
devServer,
|
||||
isMac,
|
||||
loadWindowUrl,
|
||||
preloadPath,
|
||||
rendererIndex,
|
||||
wireWindow
|
||||
}: WakeIndicatorWindowOptions) {
|
||||
let hideTimer: NodeJS.Timeout | null = null
|
||||
let state: WakeIndicatorState = 'hidden'
|
||||
let window: BrowserWindow | null = null
|
||||
|
||||
const url = () => {
|
||||
if (devServer) {
|
||||
return `${devServer.endsWith('/') ? devServer.slice(0, -1) : devServer}/?win=wake#/`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(rendererIndex()).toString()}?win=wake#/`
|
||||
}
|
||||
|
||||
const selectedDisplay = () => selectWakeIndicatorDisplay(screen.getAllDisplays(), screen.getPrimaryDisplay())
|
||||
|
||||
const reposition = () => {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
window.setBounds(wakeIndicatorWindowBounds(selectedDisplay()))
|
||||
}
|
||||
|
||||
const sendState = () => {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
window.webContents.send('hermes:wake-indicator:state', state)
|
||||
}
|
||||
|
||||
const spawn = () => {
|
||||
const next = new BrowserWindow({
|
||||
...wakeIndicatorWindowBounds(selectedDisplay()),
|
||||
alwaysOnTop: true,
|
||||
backgroundColor: '#00000000',
|
||||
focusable: false,
|
||||
frame: false,
|
||||
fullscreenable: false,
|
||||
hasShadow: false,
|
||||
hiddenInMissionControl: true,
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
movable: false,
|
||||
resizable: false,
|
||||
show: false,
|
||||
skipTaskbar: false,
|
||||
transparent: true,
|
||||
type: 'panel',
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
devTools: true,
|
||||
nodeIntegration: false,
|
||||
preload: preloadPath,
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
|
||||
next.setAlwaysOnTop(true, 'floating')
|
||||
next.setHiddenInMissionControl?.(true)
|
||||
next.setIgnoreMouseEvents(true, { forward: true })
|
||||
|
||||
try {
|
||||
next.setVisibleOnAllWorkspaces(true, {
|
||||
skipTransformProcessType: true,
|
||||
visibleOnFullScreen: true
|
||||
})
|
||||
} catch {
|
||||
// Best effort on older Electron/macOS combinations.
|
||||
}
|
||||
|
||||
wireWindow(next)
|
||||
|
||||
next.webContents.on('did-finish-load', sendState)
|
||||
next.once('ready-to-show', () => {
|
||||
if (!next.isDestroyed() && state !== 'hidden') {
|
||||
next.showInactive()
|
||||
}
|
||||
})
|
||||
next.on('closed', () => {
|
||||
if (window === next) {
|
||||
window = null
|
||||
}
|
||||
})
|
||||
|
||||
loadWindowUrl(next, url(), 'Wake indicator')
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
const setState = (value: unknown) => {
|
||||
if (!isMac) {
|
||||
return
|
||||
}
|
||||
|
||||
state = normalizeWakeIndicatorState(value)
|
||||
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
|
||||
if (state === 'hidden') {
|
||||
sendState()
|
||||
hideTimer = setTimeout(() => {
|
||||
hideTimer = null
|
||||
|
||||
if (state === 'hidden' && window && !window.isDestroyed()) {
|
||||
window.hide()
|
||||
}
|
||||
}, WAKE_INDICATOR_FADE_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!window || window.isDestroyed()) {
|
||||
window = spawn()
|
||||
} else {
|
||||
reposition()
|
||||
sendState()
|
||||
window.showInactive()
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.close()
|
||||
}
|
||||
|
||||
window = null
|
||||
state = 'hidden'
|
||||
}
|
||||
|
||||
return {
|
||||
close,
|
||||
getState: () => state,
|
||||
reposition,
|
||||
setState
|
||||
}
|
||||
}
|
||||
42
apps/desktop/electron/wake-indicator.test.ts
Normal file
42
apps/desktop/electron/wake-indicator.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeWakeIndicatorState, selectWakeIndicatorDisplay, wakeIndicatorWindowBounds } from './wake-indicator'
|
||||
|
||||
describe('wake indicator window', () => {
|
||||
it('centers the helper window at the top of the selected display', () => {
|
||||
expect(
|
||||
wakeIndicatorWindowBounds({
|
||||
bounds: { height: 982, width: 1512, x: -120, y: 40 }
|
||||
})
|
||||
).toEqual({
|
||||
height: 52,
|
||||
width: 176,
|
||||
x: 548,
|
||||
y: 40
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the internal display and falls back to the primary display', () => {
|
||||
const primary = {
|
||||
bounds: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
id: 'primary',
|
||||
internal: false
|
||||
}
|
||||
|
||||
const internal = {
|
||||
bounds: { height: 982, width: 1512, x: 1920, y: 0 },
|
||||
id: 'internal',
|
||||
internal: true
|
||||
}
|
||||
|
||||
expect(selectWakeIndicatorDisplay([primary, internal], primary)).toBe(internal)
|
||||
|
||||
expect(selectWakeIndicatorDisplay([primary], primary)).toBe(primary)
|
||||
})
|
||||
|
||||
it('rejects unknown renderer states', () => {
|
||||
expect(normalizeWakeIndicatorState('capturing')).toBe('capturing')
|
||||
expect(normalizeWakeIndicatorState('other')).toBe('hidden')
|
||||
expect(normalizeWakeIndicatorState(null)).toBe('hidden')
|
||||
})
|
||||
})
|
||||
34
apps/desktop/electron/wake-indicator.ts
Normal file
34
apps/desktop/electron/wake-indicator.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const WAKE_INDICATOR_WINDOW_WIDTH = 176
|
||||
export const WAKE_INDICATOR_WINDOW_HEIGHT = 52
|
||||
export const WAKE_INDICATOR_FADE_MS = 500
|
||||
|
||||
export const WAKE_INDICATOR_STATES = ['hidden', 'detected', 'capturing'] as const
|
||||
|
||||
export type WakeIndicatorState = (typeof WAKE_INDICATOR_STATES)[number]
|
||||
|
||||
interface DisplayLike {
|
||||
bounds: {
|
||||
height: number
|
||||
width: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
internal?: boolean
|
||||
}
|
||||
|
||||
export function normalizeWakeIndicatorState(value: unknown): WakeIndicatorState {
|
||||
return WAKE_INDICATOR_STATES.includes(value as WakeIndicatorState) ? (value as WakeIndicatorState) : 'hidden'
|
||||
}
|
||||
|
||||
export function selectWakeIndicatorDisplay<T extends DisplayLike>(displays: T[], primary: T): T {
|
||||
return displays.find(display => display.internal === true) ?? primary
|
||||
}
|
||||
|
||||
export function wakeIndicatorWindowBounds(display: DisplayLike) {
|
||||
return {
|
||||
height: WAKE_INDICATOR_WINDOW_HEIGHT,
|
||||
width: WAKE_INDICATOR_WINDOW_WIDTH,
|
||||
x: Math.round(display.bounds.x + (display.bounds.width - WAKE_INDICATOR_WINDOW_WIDTH) / 2),
|
||||
y: Math.round(display.bounds.y)
|
||||
}
|
||||
}
|
||||
@@ -162,8 +162,8 @@ test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
|
||||
assert.equal(calls, 0)
|
||||
})
|
||||
|
||||
// Zoom-wiring contract: chat windows keep global UI zoom, the pet overlay
|
||||
// opts out. Tested via the extracted config — no source-text regex.
|
||||
// Zoom-wiring contract: chat windows keep global UI zoom while fixed-size
|
||||
// helper windows opt out. Tested via the extracted config — no source-text regex.
|
||||
test('chat windows opt into zoom', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('chat'), { zoom: true })
|
||||
})
|
||||
@@ -172,6 +172,10 @@ test('pet overlay opts out of zoom', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('petOverlay'), { zoom: false })
|
||||
})
|
||||
|
||||
test('wake indicator opts out of zoom', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('wakeIndicator'), { zoom: false })
|
||||
})
|
||||
|
||||
test('unknown window kinds default to chat (zoom enabled)', () => {
|
||||
assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true })
|
||||
assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true })
|
||||
|
||||
@@ -108,7 +108,8 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc
|
||||
export const ZOOM_WINDOW_CONFIG = {
|
||||
chat: { zoom: true },
|
||||
petOverlay: { zoom: false },
|
||||
quickEntry: { zoom: false }
|
||||
quickEntry: { zoom: false },
|
||||
wakeIndicator: { zoom: false }
|
||||
} as const
|
||||
|
||||
export function zoomWiringForWindowKind(kind) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator'
|
||||
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
@@ -62,6 +63,7 @@ export function useComposerVoice({
|
||||
const { $messages } = useComposerScope()
|
||||
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
const ownsWakeIndicatorRef = useRef(false)
|
||||
const voiceStartRequest = useStore($voiceConversationStartRequest)
|
||||
|
||||
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
|
||||
@@ -150,6 +152,26 @@ export function useComposerVoice({
|
||||
beforeMicOpen: () => wakePauseBarrierRef.current ?? undefined
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- ownership token used only by unmount cleanup
|
||||
useEffect(() => {
|
||||
if (target !== 'main') {
|
||||
return
|
||||
}
|
||||
|
||||
if (syncWakeIndicatorWithVoice(voiceConversationActive, conversation.status)) {
|
||||
ownsWakeIndicatorRef.current = voiceConversationActive
|
||||
}
|
||||
}, [conversation.status, target, voiceConversationActive])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (ownsWakeIndicatorRef.current) {
|
||||
clearWakeIndicator()
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
|
||||
// with STT unconfigured lets the conversation surface its own "configure
|
||||
// speech-to-text" notice rather than silently no-opping.
|
||||
|
||||
@@ -29,6 +29,7 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
|
||||
import { sessionMessagesSignature } from '@/lib/session-signatures'
|
||||
import { isMessagingSource } from '@/lib/session-source'
|
||||
import { latestSessionTodos } from '@/lib/todos'
|
||||
import { activateWakeIndicator } from '@/lib/wake-indicator'
|
||||
import { playWakeSound } from '@/lib/wake-sound'
|
||||
import { $billingSettingsRequest } from '@/store/billing-block'
|
||||
import { requestVoiceConversationStart } from '@/store/composer'
|
||||
@@ -688,6 +689,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
// Audible confirmation that the wake registered, before voice capture
|
||||
// starts. Gated by the shared sound-mute toggle.
|
||||
playWakeSound()
|
||||
activateWakeIndicator()
|
||||
|
||||
// Multi-profile routing: a wake phrase enrolled by another profile
|
||||
// re-homes the gateway to that profile first (live swap — same path
|
||||
|
||||
42
apps/desktop/src/app/wake-indicator/wake-indicator-app.tsx
Normal file
42
apps/desktop/src/app/wake-indicator/wake-indicator-app.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import './wake-indicator.css'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { WakeIndicatorState } from '@/lib/wake-indicator'
|
||||
|
||||
export function WakeIndicatorApp() {
|
||||
const [state, setState] = useState<WakeIndicatorState>('hidden')
|
||||
|
||||
useEffect(() => {
|
||||
const api = window.hermesDesktop?.wakeIndicator
|
||||
let mounted = true
|
||||
let receivedLiveState = false
|
||||
|
||||
const unsubscribe = api?.onState(next => {
|
||||
if (mounted) {
|
||||
receivedLiveState = true
|
||||
setState(next)
|
||||
}
|
||||
})
|
||||
|
||||
void api
|
||||
?.getState()
|
||||
.then(next => {
|
||||
if (mounted && !receivedLiveState) {
|
||||
setState(next)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main aria-hidden className="wake-indicator-surface" data-state={state}>
|
||||
<div className="wake-indicator-light" />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
26
apps/desktop/src/app/wake-indicator/wake-indicator-root.tsx
Normal file
26
apps/desktop/src/app/wake-indicator/wake-indicator-root.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
|
||||
import { WakeIndicatorApp } from './wake-indicator-app'
|
||||
|
||||
export function mountWakeIndicator(): void {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = 'html,body,#root{background:transparent !important;overflow:hidden;}'
|
||||
document.head.appendChild(style)
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="wake-indicator">
|
||||
<WakeIndicatorApp />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
61
apps/desktop/src/app/wake-indicator/wake-indicator.css
Normal file
61
apps/desktop/src/app/wake-indicator/wake-indicator.css
Normal file
@@ -0,0 +1,61 @@
|
||||
.wake-indicator-surface {
|
||||
align-items: flex-start;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
padding-top: 5px;
|
||||
pointer-events: none;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.wake-indicator-light {
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
background: rgba(124, 58, 237, 0.62);
|
||||
border: 1px solid rgba(216, 180, 254, 0.82);
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
0 0 10px rgba(124, 58, 237, 0.7),
|
||||
0 0 24px rgba(168, 85, 247, 0.48);
|
||||
height: 28px;
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transition:
|
||||
opacity 500ms ease-out,
|
||||
transform 500ms ease-out;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.wake-indicator-surface[data-state='detected'] .wake-indicator-light {
|
||||
animation: wake-indicator-breathe 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wake-indicator-surface[data-state='capturing'] .wake-indicator-light {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@keyframes wake-indicator-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.34;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.94;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wake-indicator-light {
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
.wake-indicator-surface[data-state='detected'] .wake-indicator-light {
|
||||
animation: none;
|
||||
opacity: 0.72;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
6
apps/desktop/src/global.d.ts
vendored
6
apps/desktop/src/global.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
import type { GatewayWsUrlResult } from '@hermes/shared'
|
||||
|
||||
import type { WakeIndicatorState } from './lib/wake-indicator'
|
||||
import type {
|
||||
PetOverlayBounds,
|
||||
PetOverlayControl,
|
||||
@@ -42,6 +43,11 @@ declare global {
|
||||
// reply). Resolves true for the first window to claim a key, false for
|
||||
// peers — so N open windows don't all fire the same cue.
|
||||
claimAmbientCue: (key: string) => Promise<boolean>
|
||||
wakeIndicator?: {
|
||||
getState: () => Promise<WakeIndicatorState>
|
||||
setState: (state: WakeIndicatorState) => void
|
||||
onState: (callback: (state: WakeIndicatorState) => void) => () => void
|
||||
}
|
||||
// The pop-out pet overlay: a transparent always-on-top window hosting only
|
||||
// the mascot. The main renderer drives it (open/close/drag + state push);
|
||||
// the overlay sends control messages back (pop-in, composer submit).
|
||||
|
||||
46
apps/desktop/src/lib/wake-indicator.test.ts
Normal file
46
apps/desktop/src/lib/wake-indicator.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('wake indicator lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('moves from detection to capture and hides when the wake-started session ends', async () => {
|
||||
const setState = vi.fn()
|
||||
vi.stubGlobal('window', { hermesDesktop: { wakeIndicator: { setState } } })
|
||||
const { activateWakeIndicator, syncWakeIndicatorWithVoice } = await import('./wake-indicator')
|
||||
|
||||
activateWakeIndicator()
|
||||
expect(syncWakeIndicatorWithVoice(false, 'idle')).toBe(false)
|
||||
syncWakeIndicatorWithVoice(true, 'idle')
|
||||
syncWakeIndicatorWithVoice(true, 'listening')
|
||||
syncWakeIndicatorWithVoice(true, 'thinking')
|
||||
syncWakeIndicatorWithVoice(false, 'idle')
|
||||
|
||||
expect(setState.mock.calls.map(([state]) => state)).toEqual(['detected', 'capturing', 'detected', 'hidden'])
|
||||
})
|
||||
|
||||
it('does not show for a manually started voice conversation', async () => {
|
||||
const setState = vi.fn()
|
||||
vi.stubGlobal('window', { hermesDesktop: { wakeIndicator: { setState } } })
|
||||
const { syncWakeIndicatorWithVoice } = await import('./wake-indicator')
|
||||
|
||||
expect(syncWakeIndicatorWithVoice(true, 'listening')).toBe(false)
|
||||
expect(syncWakeIndicatorWithVoice(false, 'idle')).toBe(false)
|
||||
|
||||
expect(setState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deduplicates repeated visual states', async () => {
|
||||
const setState = vi.fn()
|
||||
vi.stubGlobal('window', { hermesDesktop: { wakeIndicator: { setState } } })
|
||||
const { activateWakeIndicator, clearWakeIndicator } = await import('./wake-indicator')
|
||||
|
||||
activateWakeIndicator()
|
||||
activateWakeIndicator()
|
||||
clearWakeIndicator()
|
||||
clearWakeIndicator()
|
||||
|
||||
expect(setState.mock.calls.map(([state]) => state)).toEqual(['detected', 'hidden'])
|
||||
})
|
||||
})
|
||||
55
apps/desktop/src/lib/wake-indicator.ts
Normal file
55
apps/desktop/src/lib/wake-indicator.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type WakeIndicatorState = 'capturing' | 'detected' | 'hidden'
|
||||
|
||||
export type WakeIndicatorVoiceStatus = 'idle' | 'listening' | 'speaking' | 'thinking' | 'transcribing'
|
||||
|
||||
let wakeStartedConversation = false
|
||||
let voiceConversationStarted = false
|
||||
let lastState: WakeIndicatorState = 'hidden'
|
||||
|
||||
function pushState(state: WakeIndicatorState): void {
|
||||
if (state === lastState) {
|
||||
return
|
||||
}
|
||||
|
||||
lastState = state
|
||||
window.hermesDesktop?.wakeIndicator?.setState(state)
|
||||
}
|
||||
|
||||
export function activateWakeIndicator(): void {
|
||||
wakeStartedConversation = true
|
||||
voiceConversationStarted = false
|
||||
pushState('detected')
|
||||
}
|
||||
|
||||
export function syncWakeIndicatorWithVoice(active: boolean, status: WakeIndicatorVoiceStatus): boolean {
|
||||
if (!wakeStartedConversation) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!active && !voiceConversationStarted) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
wakeStartedConversation = false
|
||||
voiceConversationStarted = false
|
||||
pushState('hidden')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
voiceConversationStarted = true
|
||||
pushState(status === 'listening' ? 'capturing' : 'detected')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function clearWakeIndicator(): void {
|
||||
if (!wakeStartedConversation && lastState === 'hidden') {
|
||||
return
|
||||
}
|
||||
|
||||
wakeStartedConversation = false
|
||||
voiceConversationStarted = false
|
||||
pushState('hidden')
|
||||
}
|
||||
@@ -43,6 +43,8 @@ if (winParam === 'overlay') {
|
||||
void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay())
|
||||
} else if (winParam === 'quick') {
|
||||
void import('./app/quick-entry/quick-entry-root').then(({ mountQuickEntry }) => mountQuickEntry())
|
||||
} else if (winParam === 'wake') {
|
||||
void import('./app/wake-indicator/wake-indicator-root').then(({ mountWakeIndicator }) => mountWakeIndicator())
|
||||
} else {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
Reference in New Issue
Block a user