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
+6 -1
View File
@@ -1391,7 +1391,12 @@ class CompanyWorkItemExecutor:
# and a single background coroutine coalesces + broadcasts.
self._kanban_dirty = False
self._kanban_broadcast_task = None
self._kanban_debounce_sec: float = 0.2
# Trailing debounce: the broadcaster always fires once more after the
# last dirty mark, so the final board state is never lost. Each fire
# runs a full build_collab_sync (whole-project snapshot), which is
# expensive on large projects — keep the window generous. UI-only;
# the state machine never waits on this broadcast.
self._kanban_debounce_sec: float = 1.5
self._runtime_invariant_issue_keys = set()
self.runtime = CompanyRuntime(
org_engine=org_engine,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-D7RDFfgf.js"></script>
<script type="module" crossorigin src="./assets/index-cOg8E2rt.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-CMqG6mW8.css">
</head>
+122 -20
View File
@@ -749,6 +749,65 @@ export default function App() {
return () => { bridge.off('agentSelected', handler) }
}, [])
// ── Runtime-delta coalescing ────────────────────────────────────────
// assistant_delta / thinking_delta arrive at token frequency; writing each
// one straight into the stores re-renders the whole app once per token.
// Buffer them per task and flush at most every 80ms. Any non-delta event
// for the same task flushes first, so store-write ordering (e.g. clearDraft
// on turn boundaries) is preserved exactly.
const pendingDeltaFlushRef = useRef<Map<string, {
draftText: string
draftIteration?: number
draftTurnId?: string
sessionPatch: Partial<import('./types/kanban').Session>
kanbanPatch: Partial<KanbanTask>
}>>(new Map())
const deltaFlushTimerRef = useRef<number | null>(null)
const flushPendingDeltas = useCallback((onlyTaskId?: string) => {
const pending = pendingDeltaFlushRef.current
if (pending.size === 0) return
const ids = onlyTaskId ? [onlyTaskId] : Array.from(pending.keys())
for (const taskId of ids) {
const entry = pending.get(taskId)
if (!entry) continue
pending.delete(taskId)
const ss = sessionStoreRef.current
if (entry.draftText) {
ss?.appendDraft(taskId, entry.draftText, entry.draftIteration, entry.draftTurnId)
}
ss?.updateSession(taskId, entry.sessionPatch)
if (Object.keys(entry.kanbanPatch).length > 0) {
boardStoreRef.current?.updateTask(taskId, entry.kanbanPatch)
}
}
if (pending.size === 0 && deltaFlushTimerRef.current !== null) {
window.clearTimeout(deltaFlushTimerRef.current)
deltaFlushTimerRef.current = null
}
}, [])
const scheduleDeltaFlush = useCallback(() => {
if (deltaFlushTimerRef.current !== null) return
deltaFlushTimerRef.current = window.setTimeout(() => {
deltaFlushTimerRef.current = null
flushPendingDeltas()
}, 80)
}, [flushPendingDeltas])
// uiTick only feeds the office-page visual memos (cards/offices/seats).
// A trailing 300ms throttle caps their refresh cost regardless of the
// websocket event rate; the office view is cosmetic, so ≤300ms staleness
// is invisible.
const uiTickTimerRef = useRef<number | null>(null)
const bumpUiTickThrottled = useCallback(() => {
if (uiTickTimerRef.current !== null) return
uiTickTimerRef.current = window.setTimeout(() => {
uiTickTimerRef.current = null
setUiTick(n => n + 1)
}, 300)
}, [])
useEffect(() => {
const client = new VisualSocketClient(wsUrl, {
onSnapshot: (data) => {
@@ -842,6 +901,11 @@ export default function App() {
const data = evt.data as Record<string, unknown>
const taskId = typeof data.task_id === 'string' ? data.task_id : ''
if (taskId) {
const isDeltaEvent = evt.type === 'assistant_delta' || evt.type === 'thinking_delta'
// Store-write ordering guarantee: everything buffered for this
// task lands before a non-delta event (turn boundaries call
// clearDraft; the draft must be flushed first, not after).
if (!isDeltaEvent) flushPendingDeltas(taskId)
const ss = sessionStoreRef.current
const bs = boardStoreRef.current
const existingSession = ss?.sessions.find(session => session.taskId === taskId)
@@ -857,7 +921,9 @@ export default function App() {
: typeof data.execution_turn_id === 'string' && data.execution_turn_id
? data.execution_turn_id
: undefined
if (projectionId && projectionId !== 'task_mode_execution' && !isTaskModeRuntime) {
const marksCompanyRuntime =
!!projectionId && projectionId !== 'task_mode_execution' && !isTaskModeRuntime
if (marksCompanyRuntime && !isDeltaEvent && existingSession?.isCompanyRuntime !== true) {
ss?.setCompanyRuntime(taskId, true)
}
@@ -866,29 +932,56 @@ export default function App() {
...(evt.type === 'member_inbox_updated' ? {} : { updatedAt: Date.now() }),
...sessionRuntimePatchFromPayload(data),
}
if (evt.type === 'turn_started') {
ss?.clearDraft(taskId)
} else if (evt.type === 'turn_completed' || evt.type === 'turn_failed' || evt.type === 'checkpoint_saved') {
ss?.clearDraft(taskId)
} else if (evt.type === 'assistant_delta' && typeof data.text === 'string' && data.text) {
ss?.appendDraft(
taskId,
data.text,
typeof data.iteration === 'number'
? data.iteration
: existingSession?.draftIteration,
turnId,
)
}
ss?.updateSession(taskId, runtimePartial)
const toolName = typeof data.tool_name === 'string' ? data.tool_name : undefined
bs?.updateTask(taskId, {
const kanbanPatch: Partial<KanbanTask> = {
...kanbanRuntimePatchFromPayload(data),
// currentTool is active-only (clears between tools); displayTool
// is sticky and only updates on a real, non-empty tool name.
...(toolName !== undefined ? { currentTool: toolName || undefined } : {}),
...(toolName ? { displayTool: toolName } : {}),
})
}
if (isDeltaEvent) {
const pending = pendingDeltaFlushRef.current
let entry = pending.get(taskId)
const deltaText = evt.type === 'assistant_delta' && typeof data.text === 'string'
? data.text
: ''
// A turn boundary inside the buffer would corrupt the draft
// reset logic (APPEND_DRAFT resets on turnId change) — flush
// the previous turn's chunk before starting a new one.
if (
entry && deltaText && entry.draftText &&
entry.draftTurnId !== undefined && turnId !== undefined &&
entry.draftTurnId !== turnId
) {
flushPendingDeltas(taskId)
entry = undefined
}
if (!entry) {
entry = { draftText: '', sessionPatch: {}, kanbanPatch: {} }
pending.set(taskId, entry)
}
if (deltaText) {
entry.draftText += deltaText
entry.draftIteration = typeof data.iteration === 'number'
? data.iteration
: entry.draftIteration ?? existingSession?.draftIteration
if (turnId !== undefined) entry.draftTurnId = turnId
}
entry.sessionPatch = {
...entry.sessionPatch,
...runtimePartial,
...(marksCompanyRuntime ? { isCompanyRuntime: true } : {}),
}
entry.kanbanPatch = { ...entry.kanbanPatch, ...kanbanPatch }
scheduleDeltaFlush()
} else {
if (evt.type === 'turn_started' || evt.type === 'turn_completed' || evt.type === 'turn_failed' || evt.type === 'checkpoint_saved') {
ss?.clearDraft(taskId)
}
ss?.updateSession(taskId, runtimePartial)
bs?.updateTask(taskId, kanbanPatch)
}
const skipDetailRefresh = (
isTaskModeRuntime && TASK_MODE_LOW_VALUE_RUNTIME_EVENTS.has(evt.type)
) || SESSION_DETAIL_REFRESH_LOW_VALUE_RUNTIME_EVENTS.has(evt.type)
@@ -908,7 +1001,7 @@ export default function App() {
}
}
setUiTick((n) => n + 1)
bumpUiTickThrottled()
} catch (e) { console.error('[onEvent] Error:', e, evt) }
},
onAck: (payload) => {
@@ -1838,6 +1931,15 @@ export default function App() {
timersRef.current.clear()
for (const tid of pendingSessionDetailRefreshRef.current.values()) clearTimeout(tid)
pendingSessionDetailRefreshRef.current.clear()
if (deltaFlushTimerRef.current !== null) {
window.clearTimeout(deltaFlushTimerRef.current)
deltaFlushTimerRef.current = null
}
pendingDeltaFlushRef.current.clear()
if (uiTickTimerRef.current !== null) {
window.clearTimeout(uiTickTimerRef.current)
uiTickTimerRef.current = null
}
}
}, [wsUrl])
@@ -2419,7 +2521,7 @@ export default function App() {
<main className={`main-grid${activePage !== 'office' ? ' hidden' : ''}${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
{/* Phaser Game Canvas */}
<section className="canvas-wrap">
<PhaserGame bridge={bridgeRef.current} />
<PhaserGame bridge={bridgeRef.current} active={activePage === 'office'} />
<button className="canvas-float-btn" onClick={() => setShowSubagents((v) => !v)} title={showSubagents ? 'Hide sub-agents' : 'Show sub-agents'}>
{showSubagents ? '👥' : '👤'}
</button>
@@ -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
}
@@ -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(() => {