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:
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user