hermes-agent/apps/desktop/src/lib/haptics.ts
Brooklyn Nicholson 9dbd3c57d7 feat(desktop): drag sessions into chat as @session links + spawn loader
Drag a sidebar session into the composer to drop an @session:<profile>/<id>
chip the agent resolves via session_search. New READ shape dumps a whole
session by id (head+tail when large); a `profile` param reads another
profile's DB read-only, and a cross-profile locate scan resolves bare ids
when the model drops the owning profile from the link.

Also: ASCII "waking up <profile>" overlay during lazy gateway swaps,
global haptic rate-limit to kill the reconnect-storm "clickity" buzz, and
reauth toasts surfaced once per disconnect instead of every backoff tick.
2026-06-04 19:41:51 -05:00

129 lines
3.1 KiB
TypeScript

import type { HapticInput, TriggerOptions } from 'web-haptics'
import { $hapticsMuted } from '@/store/haptics'
export type HapticIntent =
| 'cancel'
| 'close'
| 'crisp'
| 'error'
| 'open'
| 'selection'
| 'streamDone'
| 'streamStart'
| 'submit'
| 'success'
| 'tap'
| 'warning'
interface HapticConfig {
options?: TriggerOptions
pattern: HapticInput
}
const airyTap = [{ duration: 16, intensity: 0.52 }]
const crispTap = [{ duration: 10, intensity: 0.92 }]
const friendlySuccess = [
{ duration: 28, intensity: 0.5 },
{ delay: 42, duration: 30, intensity: 0.68 },
{ delay: 48, duration: 38, intensity: 0.86 }
]
const softArrive = [
{ duration: 18, intensity: 0.42 },
{ delay: 36, duration: 22, intensity: 0.66 }
]
const softLeave = [
{ duration: 22, intensity: 0.58 },
{ delay: 32, duration: 16, intensity: 0.34 }
]
const HAPTIC_INTENTS: Record<HapticIntent, HapticConfig> = {
cancel: {
pattern: [
{ duration: 34, intensity: 0.72 },
{ delay: 54, duration: 26, intensity: 0.38 }
]
},
close: { pattern: softLeave },
crisp: { pattern: crispTap },
error: {
pattern: [
{ duration: 34, intensity: 0.82 },
{ delay: 42, duration: 34, intensity: 0.72 },
{ delay: 58, duration: 44, intensity: 0.86 }
]
},
open: { pattern: softArrive },
selection: { pattern: airyTap },
streamDone: { pattern: friendlySuccess },
streamStart: { pattern: [{ duration: 10, intensity: 0.32 }] },
submit: {
pattern: [
{ duration: 24, intensity: 0.58 },
{ delay: 48, duration: 36, intensity: 0.82 }
]
},
success: { pattern: friendlySuccess },
tap: {
pattern: [
{ duration: 14, intensity: 0.58 },
{ delay: 30, duration: 12, intensity: 0.42 }
]
},
warning: {
pattern: [
{ duration: 34, intensity: 0.64 },
{ delay: 84, duration: 42, intensity: 0.5 }
]
}
}
export type HapticTrigger = (input?: HapticInput, options?: TriggerOptions) => Promise<void> | undefined
let registeredTrigger: HapticTrigger | null = null
let lastSelectionAt = 0
// Global rolling rate-limit. A runaway upstream loop (auth-expiry error-toast
// storms, reconnect flaps) can request dozens of haptics a second, which the
// trackpad actuator renders as a frantic "clickity" buzz. Cap firings to
// RATE_LIMIT per RATE_WINDOW so no source can machine-gun the actuator;
// intentional UI haptics are human-paced and never approach the ceiling.
const RATE_WINDOW = 1000
const RATE_LIMIT = 5
let recentFires: number[] = []
export function registerHapticTrigger(trigger: HapticTrigger | null) {
registeredTrigger = trigger
}
export function triggerHaptic(intent: HapticIntent = 'selection') {
if ($hapticsMuted.get() || !registeredTrigger) {
return
}
const now = performance.now()
if (intent === 'selection') {
if (now - lastSelectionAt < 50) {
return
}
lastSelectionAt = now
}
recentFires = recentFires.filter(t => now - t < RATE_WINDOW)
if (recentFires.length >= RATE_LIMIT) {
return
}
recentFires.push(now)
const config = HAPTIC_INTENTS[intent]
void registeredTrigger(config.pattern, config.options)?.catch(() => undefined)
}