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:
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user