From 53b8f44e75925d165df17809d050dd970a98e023 Mon Sep 17 00:00:00 2001 From: Flownium <157689911+itsflownium@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:16:14 +1000 Subject: [PATCH] feat(desktop): add notch wake indicator --- apps/desktop/electron/main.ts | 28 +++ apps/desktop/electron/preload.ts | 10 + .../desktop/electron/wake-indicator-window.ts | 174 ++++++++++++++++++ apps/desktop/electron/wake-indicator.test.ts | 42 +++++ apps/desktop/electron/wake-indicator.ts | 34 ++++ apps/desktop/electron/zoom.test.ts | 8 +- apps/desktop/electron/zoom.ts | 3 +- .../chat/composer/hooks/use-composer-voice.ts | 22 +++ apps/desktop/src/app/contrib/wiring.tsx | 2 + .../app/wake-indicator/wake-indicator-app.tsx | 42 +++++ .../wake-indicator/wake-indicator-root.tsx | 26 +++ .../src/app/wake-indicator/wake-indicator.css | 61 ++++++ apps/desktop/src/global.d.ts | 6 + apps/desktop/src/lib/wake-indicator.test.ts | 46 +++++ apps/desktop/src/lib/wake-indicator.ts | 55 ++++++ apps/desktop/src/main.tsx | 2 + 16 files changed, 558 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/electron/wake-indicator-window.ts create mode 100644 apps/desktop/electron/wake-indicator.test.ts create mode 100644 apps/desktop/electron/wake-indicator.ts create mode 100644 apps/desktop/src/app/wake-indicator/wake-indicator-app.tsx create mode 100644 apps/desktop/src/app/wake-indicator/wake-indicator-root.tsx create mode 100644 apps/desktop/src/app/wake-indicator/wake-indicator.css create mode 100644 apps/desktop/src/lib/wake-indicator.test.ts create mode 100644 apps/desktop/src/lib/wake-indicator.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 744c8912b7..b358ca2520 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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. diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 8fb6d97c3f..11483d9ce8 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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. diff --git a/apps/desktop/electron/wake-indicator-window.ts b/apps/desktop/electron/wake-indicator-window.ts new file mode 100644 index 0000000000..7ef676a43f --- /dev/null +++ b/apps/desktop/electron/wake-indicator-window.ts @@ -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 + } +} diff --git a/apps/desktop/electron/wake-indicator.test.ts b/apps/desktop/electron/wake-indicator.test.ts new file mode 100644 index 0000000000..791a2c4b86 --- /dev/null +++ b/apps/desktop/electron/wake-indicator.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/electron/wake-indicator.ts b/apps/desktop/electron/wake-indicator.ts new file mode 100644 index 0000000000..bf95672645 --- /dev/null +++ b/apps/desktop/electron/wake-indicator.ts @@ -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(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) + } +} diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts index 7fbb291a24..c288018979 100644 --- a/apps/desktop/electron/zoom.test.ts +++ b/apps/desktop/electron/zoom.test.ts @@ -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 }) diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index ee3d576488..4a6d0db6bb 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -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) { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 49e0ba5ce7..449471dc07 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -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(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. diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index e5802d0517..a39a16dd86 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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 diff --git a/apps/desktop/src/app/wake-indicator/wake-indicator-app.tsx b/apps/desktop/src/app/wake-indicator/wake-indicator-app.tsx new file mode 100644 index 0000000000..d90b9a8958 --- /dev/null +++ b/apps/desktop/src/app/wake-indicator/wake-indicator-app.tsx @@ -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('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 ( +
+
+
+ ) +} diff --git a/apps/desktop/src/app/wake-indicator/wake-indicator-root.tsx b/apps/desktop/src/app/wake-indicator/wake-indicator-root.tsx new file mode 100644 index 0000000000..fc4a99288c --- /dev/null +++ b/apps/desktop/src/app/wake-indicator/wake-indicator-root.tsx @@ -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( + + + + + + ) +} diff --git a/apps/desktop/src/app/wake-indicator/wake-indicator.css b/apps/desktop/src/app/wake-indicator/wake-indicator.css new file mode 100644 index 0000000000..c9d92d509d --- /dev/null +++ b/apps/desktop/src/app/wake-indicator/wake-indicator.css @@ -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); + } +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index f266f13aff..7d21757775 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 + wakeIndicator?: { + getState: () => Promise + 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). diff --git a/apps/desktop/src/lib/wake-indicator.test.ts b/apps/desktop/src/lib/wake-indicator.test.ts new file mode 100644 index 0000000000..eff6315468 --- /dev/null +++ b/apps/desktop/src/lib/wake-indicator.test.ts @@ -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']) + }) +}) diff --git a/apps/desktop/src/lib/wake-indicator.ts b/apps/desktop/src/lib/wake-indicator.ts new file mode 100644 index 0000000000..2122ef9a4a --- /dev/null +++ b/apps/desktop/src/lib/wake-indicator.ts @@ -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') +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 63d219d1c6..a71427c748 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -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(