perf(office-ui): stop per-token full-tree renders, sleep hidden Phaser loop, cut broadcast churn
Company-mode UIs became unusably slow once work items and transcripts grew. Three presentation-layer fixes, none touching work progression: - App.tsx: buffer assistant_delta/thinking_delta store writes per task and flush every 80ms instead of once per token; any non-delta event for the same task flushes first so draft/clearDraft ordering is byte-identical. The unconditional per-event setUiTick (whole-app re-render per websocket event) is now a 300ms trailing throttle. - PhaserGame: display:none does not stop requestAnimationFrame, so the office scene kept burning CPU on every other page. The loop now sleeps when the office page is hidden and wakes (with a parent-bounds refresh) on return; bridge writes stay synchronous so no state is lost. - Kanban collab_sync debounce 0.2s -> 1.5s: the broadcaster is a trailing coalescer, so the final board state still always ships; each fire is a full-project snapshot build, which at 5/s dominated backend CPU on large projects. CommsPanel poll 8s -> 30s (comms_state_dirty push already drives freshness) and its interval no longer pins a stale onRefresh closure. Verified: tsc + vite build, App.test.tsx / workItemSessions structural tests, backend suites (company_review_flow incl. debounce push test, kanban_push_runtime, actor_runtime_company_mode, task_mode_contract, work_item_transition — 119 green), and canvas_smoke e2e against a real server with zero console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,12 +7,38 @@ import { OfficeScene } from './scenes/OfficeScene'
|
||||
|
||||
interface Props {
|
||||
bridge: GameBridge
|
||||
/** False while the office page is hidden — puts the render loop to sleep.
|
||||
Bridge calls still apply synchronously to scene state, so nothing is
|
||||
lost; only the per-frame update/render work stops. */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export function PhaserGame({ bridge }: Props) {
|
||||
export function PhaserGame({ bridge, active = true }: Props) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const gameRef = useRef<Phaser.Game | null>(null)
|
||||
const activeRef = useRef(active)
|
||||
activeRef.current = active
|
||||
|
||||
// display:none does NOT stop requestAnimationFrame — without this, the
|
||||
// whole game loop (physics, tweens, full canvas redraw) keeps burning CPU
|
||||
// while the user is on the Workspace/Org pages.
|
||||
useEffect(() => {
|
||||
const game = gameRef.current
|
||||
if (!game || !game.loop) return
|
||||
// TimeStep.running toggles false on sleep() while `started` stays true.
|
||||
if (active) {
|
||||
if (game.loop.started && !game.loop.running) {
|
||||
game.loop.wake()
|
||||
// Re-measure after display:none → visible; in RESIZE mode Phaser's
|
||||
// parent-size poll may still hold the stale hidden bounds.
|
||||
game.scale.getParentBounds()
|
||||
game.scale.refresh()
|
||||
}
|
||||
} else if (game.loop.running) {
|
||||
game.loop.sleep()
|
||||
}
|
||||
}, [active])
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrapperRef.current || !containerRef.current) return
|
||||
@@ -26,6 +52,11 @@ export function PhaserGame({ bridge }: Props) {
|
||||
config.scene = [BootScene, OfficeScene]
|
||||
const game = new Phaser.Game(config)
|
||||
game.registry.set('bridge', bridge)
|
||||
// The game is created lazily on first layout; if the page was switched
|
||||
// away before boot finished, park the loop immediately.
|
||||
game.events.once(Phaser.Core.Events.READY, () => {
|
||||
if (!activeRef.current && game.loop.running) game.loop.sleep()
|
||||
})
|
||||
gameRef.current = game
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user