From b779fbf4237fee171f9bad0f2d4680705fb57280 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:48:47 -0700 Subject: [PATCH] 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 --- .../desktop/src/plugins/hermes-bots/plugin.js | 96 +++++++++++++------ .../hermes-bots/tests/face-clock.test.mjs | 52 +++++++++- apps/desktop/src/sdk/index.ts | 5 + 3 files changed, 122 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 1881622a08..4ac606159c 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -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 } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/face-clock.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/face-clock.test.mjs index 1904279fa8..a1def1bee9 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/face-clock.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/face-clock.test.mjs @@ -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') +}) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index b914f76b7d..29a46a9ac8 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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'