feat(desktop): route the bots face clock through the SDK budgeted loop

Exports createBudgetedLoop (+types) through the plugin SDK and migrates the
Bots plugin's face-animation clock onto it. The hand-rolled clock only
checked document.hidden; via the shared loop it now also pauses while the
window is minimized or unfocused, matching every other desktop render loop.

- sdk/index.ts: export createBudgetedLoop/BudgetedLoop/BudgetedLoopOptions
  from @/lib/budgeted-loop with plugin-facing guidance
- hermes-bots/plugin.js: startFaceClock delegates scheduling to the SDK loop
  when present (fps 15, idleWhen = no visible faces); paint body, IO-based
  visibility tracking, and the 1Hz rescan are shared; the hand-rolled rAF
  path remains as a feature-detected fallback for older desktops, per the
  plugin's established SkillsView/McpTab pattern
- tests: new SDK-path case (fps/idleWhen wiring, visibility wake, re-entry
  wake, dispose-on-stop) driven through an injected fake loop; verified to
  fail with the SDK branch removed; existing fallback-path tests unchanged
This commit is contained in:
Teknium
2026-08-17 13:48:47 -07:00
parent aeabff6aec
commit b779fbf423
3 changed files with 122 additions and 31 deletions
+67 -29
View File
@@ -68,6 +68,9 @@ const { McpTab, ToolsetConfigPanel } = sdk
// Keep optional exports feature-detected; test harnesses may strip the SDK namespace.
const SkillsView = typeof sdk === 'undefined' ? undefined : sdk.SkillsView
const Streamdown = typeof sdk === 'undefined' ? undefined : sdk.Streamdown
// Budgeted render loop (fps cap + observability pause + dormancy + teardown).
// Feature-detected: older desktops fall back to the hand-rolled clock below.
const createBudgetedLoop = typeof sdk === 'undefined' ? undefined : sdk.createBudgetedLoop
const ID = 'hermes-bots'
const ROSTER_KEY = [ID, 'roster']
@@ -1122,10 +1125,6 @@ function startFaceClock() {
// off-screen cards do not consume a full animation frame by themselves.
let faces = []
let lastScan = -Infinity
let lastPaint = -Infinity
let rafId = 0
let dormant = false
let stopped = false
const visibleFaces = new Set()
const observedFaces = new Set()
const observer =
@@ -1144,7 +1143,7 @@ function startFaceClock() {
// A parked clock (no visible faces) resumes when one scrolls in.
if (becameVisible) {
wake()
window.__hbFaceClock?.wake()
}
})
: null
@@ -1174,6 +1173,65 @@ function startFaceClock() {
}
}
// Shared painting body for both scheduling paths: 1Hz document rescans,
// paint only visible faces (all cached faces when IO is unavailable).
const paint = now => {
if (now - lastScan > 1000) {
scanFaces()
lastScan = now
}
const t = (now - t0) / 1000
const facesToPaint = observer ? visibleFaces : faces
for (const svg of facesToPaint) {
if (svg.isConnected) {
paintMathFace(svg, t)
}
}
}
// Nothing worth animating: no faces mounted (BotFace wakes us on the next
// mount) or none visible (the observer wakes us when one scrolls in).
const idle = () => faces.length === 0 || (observer && visibleFaces.size === 0)
const teardownCaches = () => {
if (observer) {
observer.disconnect()
}
visibleFaces.clear()
observedFaces.clear()
faces = []
delete window.__hbFaceClock
}
// Newer desktops: the SDK's budgeted loop owns scheduling (15fps budget,
// hidden/minimized/unfocused pause, dormancy, teardown). typeof-guarded so
// older shells and the vm test harness use the hand-rolled path below.
if (typeof createBudgetedLoop === 'function' && createBudgetedLoop) {
const loop = createBudgetedLoop(paint, { fps: 15, idleWhen: idle })
window.__hbFaceClock = {
stop: () => {
loop.dispose()
teardownCaches()
},
wake: () => {
// Faces may have mounted/unmounted while parked — rescan on wake.
lastScan = -Infinity
loop.wake()
}
}
return
}
// Fallback scheduling for desktops whose SDK predates createBudgetedLoop.
let lastPaint = -Infinity
let rafId = 0
let dormant = false
let stopped = false
const tick = now => {
if (stopped) {
return
@@ -1183,25 +1241,12 @@ function startFaceClock() {
// 15fps is smooth at avatar scale and bounds SVG/DOM churn. The clock
// still uses rAF so Chromium can pause it when the window is occluded.
if (!document.hidden && now - lastPaint >= 1000 / 15) {
if (now - lastScan > 1000) {
scanFaces()
lastScan = now
}
const t = (now - t0) / 1000
const facesToPaint = observer ? visibleFaces : faces
for (const svg of facesToPaint) {
if (svg.isConnected) {
paintMathFace(svg, t)
}
}
paint(now)
lastPaint = now
}
// Dormancy: no faces mounted (BotFace wakes us on the next mount), or
// none visible (the observer wakes us when one scrolls in). Park the
// clock instead of burning frames + 1Hz whole-document shadow walks.
if (faces.length === 0 || (observer && visibleFaces.size === 0)) {
// Park instead of burning frames + 1Hz whole-document shadow walks.
if (idle()) {
dormant = true
return
@@ -1229,14 +1274,7 @@ function startFaceClock() {
rafId = 0
}
if (observer) {
observer.disconnect()
}
visibleFaces.clear()
observedFaces.clear()
faces = []
delete window.__hbFaceClock
teardownCaches()
}
window.__hbFaceClock = { stop, wake }
@@ -13,7 +13,7 @@ const end = pluginSource.indexOf('/**\n * Live math face.')
assert.ok(start > 0 && end > start, 'face clock functions present in plugin.js')
const clockSource = pluginSource.slice(start, end)
function load({ faces = [], intersectionObserver = true } = {}) {
function load({ budgetedLoop = undefined, faces = [], intersectionObserver = true } = {}) {
const rafQueue = new Map()
let rafSeq = 0
let observerInstance = null
@@ -61,7 +61,8 @@ function load({ faces = [], intersectionObserver = true } = {}) {
performance: { now: () => 0 },
IntersectionObserver: intersectionObserver ? FakeIntersectionObserver : undefined,
walkMathFaces: () => [...faces],
paintMathFace: () => undefined
paintMathFace: () => undefined,
createBudgetedLoop: budgetedLoop
}
vm.createContext(context)
vm.runInContext(`${clockSource}; __start = startFaceClock; __stop = stopFaceClock`, context)
@@ -150,3 +151,50 @@ test('unit: stopFaceClock cancels the frame, disconnects the observer, and clear
test('source: plugin registers face-clock teardown via ctx.onDispose', () => {
assert.match(pluginSource, /ctx\.onDispose\(stopFaceClock\)/)
})
test('unit: SDK path — clock delegates scheduling to createBudgetedLoop', () => {
const calls = { dispose: 0, wake: 0 }
let capturedDraw = null
let capturedOptions = null
const fakeLoop = {
dispose: () => {
calls.dispose += 1
},
isDormant: () => false,
wake: () => {
calls.wake += 1
}
}
const face = { isConnected: true }
const h = load({
faces: [face],
budgetedLoop: (draw, options) => {
capturedDraw = draw
capturedOptions = options
return fakeLoop
}
})
h.start()
assert.equal(typeof capturedDraw, 'function', 'draw handed to the SDK loop')
assert.equal(capturedOptions.fps, 15, '15fps budget requested')
assert.equal(typeof capturedOptions.idleWhen, 'function', 'idle predicate wired')
assert.equal(h.pending(), 0, 'no hand-rolled rAF scheduled on the SDK path')
// idleWhen reflects visible-face state: observed-but-not-visible = idle.
capturedDraw(1000) // triggers the initial scan + observation
assert.equal(capturedOptions.idleWhen(), true, 'no visible faces -> idle')
h.observer().emit([{ target: face, isIntersecting: true }])
assert.equal(capturedOptions.idleWhen(), false, 'visible face -> not idle')
assert.ok(calls.wake >= 1, 'visibility wakes the SDK loop')
// Re-entry (BotFace mount) wakes rather than re-initializing.
h.start()
assert.ok(calls.wake >= 2, 'startFaceClock re-entry wakes the SDK loop')
// Teardown disposes the loop and clears the handle.
h.stop()
assert.equal(calls.dispose, 1, 'stop disposes the SDK loop')
assert.equal(h.observer().disconnected, true, 'observer disconnected')
assert.equal(h.clock(), undefined, 'window handle removed')
})
+5
View File
@@ -586,6 +586,11 @@ export {
useI18n,
usePluginI18n
} from '@/i18n'
/** THE way to run a decorative rAF animation (avatars, shimmer, sprites):
* fps budget + hidden/minimized/unfocused pause + idle dormancy + teardown.
* Plugins must route animation clocks through this instead of raw rAF loops
* so a disabled plugin or an empty roster costs zero frames. */
export { type BudgetedLoop, type BudgetedLoopOptions, createBudgetedLoop } from '@/lib/budgeted-loop'
/** THE compact-number formatter — every user-facing count/token figure goes
* through here (1230 → "1.2k", 1_500_000 → "1.5M"). Don't hand-roll `/1000`. */
export { compactNumber } from '@/lib/format'