feat(desktop): add notch wake indicator

This commit is contained in:
Flownium
2026-07-30 17:16:14 +10:00
committed by Teknium
parent ff8df5822b
commit 53b8f44e75
16 changed files with 558 additions and 3 deletions

View File

@@ -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.

View File

@@ -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.

View 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
}
}

View 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')
})
})

View 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)
}
}

View File

@@ -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 })

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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

View 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>
)
}

View 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>
)
}

View 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);
}
}

View File

@@ -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).

View 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'])
})
})

View 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')
}

View File

@@ -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>