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:
LZH-YS1998
2026-07-09 16:36:39 +08:00
parent 5aa57e69ee
commit e3bed49811
7 changed files with 327 additions and 184 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
CommsStatePayload,
CommsMessagePayload,
@@ -21,18 +21,23 @@ export function CommsPanel({
message,
onRefresh,
onReadMessage,
pollIntervalMs = 8000,
// Freshness is driven by the server's comms_state_dirty push (wsClient
// refetches immediately on it); this poll is only a slow safety net. Each
// tick makes the backend walk every role's comms workspace, so keep it rare.
pollIntervalMs = 30000,
embedded = false,
}: CommsPanelProps) {
const [selectedPath, setSelectedPath] = useState<string | null>(null)
// Auto-refresh
// Auto-refresh fallback. onRefresh goes through a ref so the interval
// always calls the latest callback (the old closure pinned a stale one).
const onRefreshRef = useRef(onRefresh)
onRefreshRef.current = onRefresh
useEffect(() => {
if (!pollIntervalMs) return
onRefresh()
const id = window.setInterval(() => onRefresh(), pollIntervalMs)
onRefreshRef.current()
const id = window.setInterval(() => onRefreshRef.current(), pollIntervalMs)
return () => window.clearInterval(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pollIntervalMs])
const totalUnread = useMemo(() => {