fix(desktop): de-dupe cross-window cues so peers don't spam

With multiple full windows, each renderer independently reacts to the
same backend event, so one-shot cues fired N times: OS notifications
(the per-renderer throttle can't see other windows), the turn-end sound
(playCompletionSound runs on every message.complete, ungated by focus),
and auto-spoken replies (double voice when a chat is open in two windows).

Add a single race-free owner in the main process (electron/event-dedupe.ts):
main handles IPC serially, so the first window to claim a key within a
short window wins and peers stay quiet. Notifications collapse at the
hermes:notify choke point; the sound and spoken replies claim via a new
hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the
claim falls back to "emit", preserving single-window behavior.

The sound's mute check runs before the claim so a muted window can't win
the cue and silence an audible peer.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 18:33:14 -05:00
parent a90ca7fe34
commit fb0c6d9ee1
9 changed files with 143 additions and 7 deletions

View file

@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { createEventDeduper } from './event-dedupe'
test('collapses the same key inside the window (two windows, one event)', () => {
const isDup = createEventDeduper(1000)
assert.equal(isDup('input:s1', 0), false, 'first window claims')
assert.equal(isDup('input:s1', 5), true, 'second window is deduped')
})
test('distinct keys are independent', () => {
const isDup = createEventDeduper(1000)
assert.equal(isDup('input:s1', 0), false)
assert.equal(isDup('approval:s1', 0), false, 'different kind')
assert.equal(isDup('input:s2', 0), false, 'different session')
})
test('re-fires once the window elapses', () => {
const isDup = createEventDeduper(1000)
assert.equal(isDup('turnDone:s1', 0), false)
assert.equal(isDup('turnDone:s1', 999), true, 'still within window')
assert.equal(isDup('turnDone:s1', 1000), false, 'window elapsed → fires again')
})
test('prunes stale keys so the map cannot grow unbounded', () => {
const isDup = createEventDeduper(1000)
for (let i = 0; i < 100; i += 1) {
// Each far-apart key is pruned before the next, so none linger as duplicates.
assert.equal(isDup(`turnDone:s${i}`, i * 2000), false)
}
})

View file

@ -0,0 +1,35 @@
// Cross-window de-dupe for one-shot side-effects (OS notifications, the turn-end
// sound, spoken replies). Every desktop window is its own renderer process, so N
// open windows each independently react to the same backend event. The main
// process is the one place they all share and it handles IPC serially, so it's
// the race-free owner: the first window to claim a key within the window wins;
// peers see it's taken and stay quiet.
//
// Pure + injectable clock so it's unit-testable without Electron.
const DEDUPE_WINDOW_MS = 1000
// Returns true when `key` was already claimed within the window (caller drops
// this one). Self-evicting: stale keys are pruned on every call, so the map
// can't grow unbounded.
function createEventDeduper(windowMs = DEDUPE_WINDOW_MS) {
const lastSeenAt = new Map<string, number>()
return function isDuplicate(key: string, now = Date.now()): boolean {
for (const [k, at] of lastSeenAt) {
if (now - at >= windowMs) {
lastSeenAt.delete(k)
}
}
if (lastSeenAt.has(key)) {
return true
}
lastSeenAt.set(key, now)
return false
}
}
export { createEventDeduper, DEDUPE_WINDOW_MS }

View file

@ -67,6 +67,7 @@ import {
uninstallArgsForMode
} from './desktop-uninstall'
import { installEmbedReferer } from './embed-referer'
import { createEventDeduper } from './event-dedupe'
import { readDirForIpc } from './fs-read-dir'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { scanGitRepos } from './git-repo-scan'
@ -8516,11 +8517,28 @@ ipcMain.handle('hermes:api', async (_event, request) => {
})
})
// One deduper per cross-window cue — the choke point every window shares. Main
// handles IPC serially, so the first window to claim a key wins with no race.
const isDuplicateNotification = createEventDeduper()
const claimedAmbientCue = createEventDeduper()
// A window asks "do I own this ambient cue (turn-end sound / spoken reply)?".
// The first caller within the window gets true; peers get false and stay quiet.
ipcMain.handle('hermes:ambient:claim', (_event, key) => !claimedAmbientCue(String(key ?? '')))
ipcMain.handle('hermes:notify', (_event, payload) => {
if (!Notification.isSupported()) {
return false
}
// Multiple full windows each run their own renderer throttle, so the same
// kind+session can arrive here twice. Collapse it at this single choke point.
// Return true (not false): a notification for the event IS being shown by the
// first caller, so the settings "send test" success probe stays honest.
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) {
return true
}
// Action buttons render only on signed macOS builds; elsewhere they're dropped
// and the body click still works.
const actions = Array.isArray(payload?.actions) ? payload.actions : []

View file

@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'),
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
petOverlay: {
// Main renderer → main process: window lifecycle + drag. `request` is
// `{ bounds, screen }`; resolves with the screen bounds it actually used.

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { playSpeechText } from '@/lib/voice-playback'
import { ownsAmbientCue } from '@/store/ambient'
import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
@ -65,9 +66,16 @@ export function useAutoSpeakReplies({
}
markSpoken()
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
notifyError(error, failureLabel)
)
// Only one window voices a given reply when the same chat is open in
// several (reply.id is the shared backend message id). markSpoken already
// ran in every window, so peers just stay quiet.
void ownsAmbientCue(`speak:${reply.id}`).then(owns => {
if (owns) {
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
notifyError(error, failureLabel)
)
}
})
}
// Re-check on a reply completing ($messages) and on the prior clip ending

View file

@ -483,7 +483,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
flushQueuedDeltas(sessionId)
playCompletionSound()
// Keyed by session so only one window beeps when several are open.
playCompletionSound(sessionId)
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)

View file

@ -35,6 +35,10 @@ declare global {
// renders the complete app against the shared backend, so the user can run
// multiple GUI windows at once.
openWindow: () => Promise<{ ok: boolean; error?: string }>
// Claim a one-shot cross-window ambient cue (turn-end sound / spoken
// 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>
// 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

@ -1,6 +1,7 @@
// Completion sound bank for agent turn-end cues.
// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1.
import { ownsAmbientCue } from '@/store/ambient'
import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound'
import { $hapticsMuted } from '@/store/haptics'
@ -452,13 +453,26 @@ export function previewCompletionSound(variantId?: number) {
playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get()))
}
// Plays the selected completion cue on any `message.complete`.
export function playCompletionSound() {
// Plays the selected completion cue on any `message.complete`. Pass a dedupeKey
// (the session id) so only one window beeps when several are open — the mute
// check runs first, so a muted window never claims the cue out from under an
// audible peer.
export function playCompletionSound(dedupeKey?: string) {
if ($hapticsMuted.get()) {
return
}
playVariant($completionSoundVariantId.get())
if (!dedupeKey) {
playVariant($completionSoundVariantId.get())
return
}
void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => {
if (owns) {
playVariant($completionSoundVariantId.get())
}
})
}
interface AirPuffSpec {

View file

@ -0,0 +1,18 @@
// One window owns each cross-window ambient cue (turn-end sound, spoken reply)
// so N open full windows don't all fire it for the same backend event. The main
// process is the race-free owner (see electron/event-dedupe.ts). Off Electron —
// or when the bridge/claim fails — every window emits, preserving the
// single-window behavior rather than going silent.
export async function ownsAmbientCue(key: string): Promise<boolean> {
const claim = window.hermesDesktop?.claimAmbientCue
if (!claim) {
return true
}
try {
return await claim(key)
} catch {
return true
}
}