Files
OpenOPC/opc/plugins/office_ui/frontend_src/workspace/CommsPanel.tsx
T
LZH-YS1998 e3bed49811 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>
2026-07-09 16:36:39 +08:00

533 lines
15 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import type {
CommsStatePayload,
CommsMessagePayload,
CommsRolePayload,
CommsMessageItem,
} from '../lib/wsClient'
interface CommsPanelProps {
state: CommsStatePayload | null
message: CommsMessagePayload | null
onRefresh: () => void
onReadMessage: (path: string) => void
pollIntervalMs?: number
/** When true, renders without its own border/header (embedded in ContextPanel tab). */
embedded?: boolean
}
export function CommsPanel({
state,
message,
onRefresh,
onReadMessage,
// 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 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
onRefreshRef.current()
const id = window.setInterval(() => onRefreshRef.current(), pollIntervalMs)
return () => window.clearInterval(id)
}, [pollIntervalMs])
const totalUnread = useMemo(() => {
if (!state?.roles) return 0
return state.roles.reduce((acc, r) => acc + r.unread_count, 0)
}, [state?.roles])
const handleSelectMessage = (path: string) => {
setSelectedPath(path)
onReadMessage(path)
}
const wrapStyle: React.CSSProperties = embedded
? { fontSize: 13, color: 'var(--text)' }
: {
border: '1px solid var(--border, #333)',
borderRadius: 6,
background: 'var(--bg-primary, #1e1e1e)',
fontSize: 13,
}
return (
<div className="comms-panel" style={wrapStyle}>
{/* Toolbar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
borderBottom: '1px solid var(--border, #333)',
}}
>
<span style={{ fontWeight: 600, fontSize: 13, flex: 1 }}>
Agent Communications
</span>
{totalUnread > 0 && <Badge color="var(--accent, #3498db)">{totalUnread} unread</Badge>}
<button
onClick={onRefresh}
style={{
background: 'var(--surface, #2a2a2a)',
border: '1px solid var(--border, #444)',
color: 'var(--text-secondary, #aaa)',
fontSize: 11,
padding: '3px 10px',
cursor: 'pointer',
borderRadius: 4,
}}
>
Refresh
</button>
</div>
{/* Body */}
{!state ? (
<EmptyState>Loading communications...</EmptyState>
) : !state.available ? (
<EmptyState>Not available{state.reason ? `: ${state.reason}` : ''}</EmptyState>
) : state.empty ? (
<EmptyState>No communications yet. They will appear once agents start collaborating.</EmptyState>
) : (
<CommsBody
state={state}
selectedPath={selectedPath}
onSelectMessage={handleSelectMessage}
/>
)}
{/* Message viewer overlay */}
{message && (
<MessageViewer
message={message}
onClose={() => setSelectedPath(null)}
/>
)}
</div>
)
}
/* ── Body ── */
function CommsBody({
state,
selectedPath,
onSelectMessage,
}: {
state: CommsStatePayload
selectedPath: string | null
onSelectMessage: (path: string) => void
}) {
const hasRoles = (state.roles?.length ?? 0) > 0
const hasMeetings = (state.meetings?.length ?? 0) > 0
const hasFailures = (state.recent_failures?.length ?? 0) > 0
return (
<div style={{ padding: '4px 0' }}>
{hasFailures && (
<Section title="Failures">
{(state.recent_failures || []).map((f, i) => (
<div
key={`${f.recorded_at || 'f'}-${i}`}
style={{
padding: '6px 12px',
marginBottom: 4,
background: 'color-mix(in srgb, var(--red, #e74c3c) 6%, transparent)',
borderLeft: '3px solid var(--red, #e74c3c)',
}}
>
<div style={{ fontWeight: 600, fontSize: 12 }}>
{f.operation} &middot; {f.from_role} &rarr; {f.to_role}
</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary, #999)', marginTop: 2 }}>
{f.reason}
</div>
</div>
))}
</Section>
)}
{hasRoles && (
<Section title={`Inboxes (${state.roles!.length})`}>
{state.roles!.map((role) => (
<RoleSection
key={role.role_id}
role={role}
selectedPath={selectedPath}
onSelectMessage={onSelectMessage}
/>
))}
</Section>
)}
{hasMeetings && (
<Section title="Meetings">
{(state.meetings || []).map((m) => (
<div
key={m.meeting_id}
style={{
padding: '6px 12px',
marginBottom: 4,
borderLeft: `3px solid ${m.status === 'open' ? 'var(--accent, #3498db)' : 'var(--text-dim, #555)'}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Badge
color={m.status === 'open' ? 'var(--accent, #3498db)' : 'var(--text-dim, #555)'}
>
{m.status}
</Badge>
<strong style={{ fontSize: 12, flex: 1 }}>{m.topic}</strong>
<span style={{ fontSize: 11, color: 'var(--text-dim, #888)' }}>
{m.entry_count} entries
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary, #999)', marginTop: 3 }}>
by {m.organizer} &middot; {m.participants.join(', ')}
</div>
{m.decision && (
<div style={{ fontSize: 11, marginTop: 3, color: 'var(--green, #27ae60)' }}>
Decision: {m.decision}
</div>
)}
</div>
))}
</Section>
)}
{!hasRoles && !hasMeetings && !hasFailures && (
<EmptyState>No communication activity yet.</EmptyState>
)}
</div>
)
}
/* ── Role Section ── */
type MessageTab = 'unread' | 'history' | 'sent'
function RoleSection({
role,
selectedPath,
onSelectMessage,
}: {
role: CommsRolePayload
selectedPath: string | null
onSelectMessage: (path: string) => void
}) {
const [expanded, setExpanded] = useState(true)
const hasAnyMessages =
role.unread_count > 0 || (role.recent_seen?.length ?? 0) > 0 || (role.recent_outbox?.length ?? 0) > 0
const [activeTab, setActiveTab] = useState<MessageTab>(role.unread_count > 0 ? 'unread' : 'history')
const messages: CommsMessageItem[] = useMemo(() => {
switch (activeTab) {
case 'unread':
return role.recent_unread || []
case 'history':
return role.recent_seen || []
case 'sent':
return role.recent_outbox || []
default:
return []
}
}, [activeTab, role.recent_unread, role.recent_seen, role.recent_outbox])
const roleName = role.role_id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
return (
<div style={{ marginBottom: 2 }}>
{/* Role header */}
<div
onClick={() => setExpanded((e) => !e)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
cursor: 'pointer',
userSelect: 'none',
background: expanded ? 'var(--surface-hover, rgba(255,255,255,0.03))' : 'transparent',
borderRadius: 4,
}}
>
<span style={{ fontSize: 10, width: 10 }}>{expanded ? '▼' : '▶'}</span>
<span style={{ flex: 1, fontWeight: 600, fontSize: 12 }}>{roleName}</span>
{role.has_blocking && <Badge color="var(--red, #e74c3c)">BLOCKING</Badge>}
{role.unread_count > 0 && (
<Badge color="var(--accent, #3498db)">{role.unread_count} new</Badge>
)}
<span style={{ fontSize: 11, color: 'var(--text-dim, #666)' }}>
{role.seen_count} read &middot; {role.outbox_count} sent
</span>
</div>
{expanded && hasAnyMessages && (
<div style={{ paddingLeft: 12 }}>
{/* Sub-tabs */}
<div
style={{
display: 'flex',
gap: 2,
padding: '4px 0',
borderBottom: '1px solid var(--border, #333)',
marginBottom: 4,
}}
>
<TabButton active={activeTab === 'unread'} onClick={() => setActiveTab('unread')}>
Unread ({role.unread_count})
</TabButton>
<TabButton active={activeTab === 'history'} onClick={() => setActiveTab('history')}>
Read ({role.recent_seen?.length ?? 0})
</TabButton>
<TabButton active={activeTab === 'sent'} onClick={() => setActiveTab('sent')}>
Sent ({role.recent_outbox?.length ?? 0})
</TabButton>
</div>
{/* Message list */}
{messages.length === 0 ? (
<div style={{ padding: '8px 0', fontSize: 11, color: 'var(--text-dim, #666)' }}>
No messages.
</div>
) : (
<div style={{ maxHeight: 280, overflow: 'auto' }}>
{messages.map((m) => (
<MessageRow
key={m.message_id}
message={m}
selected={m.path === selectedPath}
onClick={() => onSelectMessage(m.path)}
/>
))}
</div>
)}
</div>
)}
</div>
)
}
/* ── Message Row ── */
function MessageRow({
message,
selected,
onClick,
}: {
message: CommsMessageItem
selected: boolean
onClick: () => void
}) {
const isSent = message.bucket === 'sent'
const direction = isSent
? `To: ${message.to || '?'}`
: `From: ${message.from}`
return (
<div
onClick={onClick}
style={{
padding: '5px 8px',
marginBottom: 2,
background: selected ? 'var(--accent-soft, #2c4a6b)' : 'transparent',
cursor: 'pointer',
borderRadius: 4,
borderLeft: message.blocking
? '3px solid var(--red, #e74c3c)'
: '3px solid transparent',
transition: 'background 0.1s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 11, color: 'var(--text-secondary, #aaa)', minWidth: 0, flex: 1 }}>
<strong>{direction}</strong>{' '}
<span style={{ color: 'var(--text, #ddd)' }}>{message.subject}</span>
</span>
{message.blocking && (
<span style={{ fontSize: 9, color: 'var(--red, #e74c3c)', fontWeight: 700 }}>BLK</span>
)}
<span style={{ fontSize: 10, color: 'var(--text-dim, #666)', flexShrink: 0 }}>
{message.sent_at?.slice(11, 19) || ''}
</span>
</div>
</div>
)
}
/* ── Message Viewer ── */
function MessageViewer({
message,
onClose,
}: {
message: CommsMessagePayload
onClose: () => void
}) {
return (
<div
style={{
margin: '8px 12px 12px',
padding: 12,
border: '1px solid var(--border, #444)',
borderRadius: 6,
background: 'var(--bg-secondary, #262626)',
maxHeight: 400,
overflow: 'auto',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<strong style={{ flex: 1, fontSize: 13 }}>
{(message.header.subject as string) || '(no subject)'}
</strong>
<button
onClick={onClose}
style={{
background: 'var(--surface, #333)',
border: '1px solid var(--border, #444)',
color: 'var(--text-secondary, #aaa)',
fontSize: 11,
padding: '2px 10px',
cursor: 'pointer',
borderRadius: 4,
}}
>
Close
</button>
</div>
<div
style={{
fontSize: 11,
color: 'var(--text-secondary, #999)',
marginBottom: 8,
display: 'flex',
gap: 8,
flexWrap: 'wrap',
}}
>
<span>
From: <strong>{(message.header.from as string) || '?'}</strong>
</span>
<span>
To: <strong>{(message.header.to as string) || '?'}</strong>
</span>
<span>{(message.header.sent_at as string) || ''}</span>
{message.header.blocking && (
<Badge color="var(--red, #e74c3c)">BLOCKING</Badge>
)}
</div>
<pre
style={{
margin: 0,
fontSize: 12,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'var(--text, #ddd)',
lineHeight: 1.5,
}}
>
{message.body}
</pre>
</div>
)
}
/* ── Shared UI helpers ── */
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: 4 }}>
<div
style={{
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: 0.6,
fontWeight: 700,
color: 'var(--text-dim, #777)',
padding: '8px 12px 4px',
}}
>
{title}
</div>
{children}
</div>
)
}
function Badge({
color,
children,
}: {
color: string
children: React.ReactNode
}) {
return (
<span
style={{
background: color,
color: 'white',
borderRadius: 10,
padding: '1px 7px',
fontSize: 10,
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{children}
</span>
)
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
style={{
background: active ? 'var(--accent-soft, #2c4a6b)' : 'transparent',
border: 'none',
color: active ? 'var(--text, #ddd)' : 'var(--text-secondary, #999)',
fontSize: 11,
padding: '3px 10px',
cursor: 'pointer',
borderRadius: 4,
fontWeight: active ? 600 : 400,
}}
>
{children}
</button>
)
}
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div
style={{
padding: '20px 16px',
fontSize: 12,
color: 'var(--text-secondary, #888)',
textAlign: 'center',
}}
>
{children}
</div>
)
}