feat(#115): virtual office visualization for agents (#127)

- Create OfficePanel with Office floor plan and Org Chart view modes
- Desk-style cards with status glow, emoji indicators, pulse animation for busy agents
- Agent detail modal with task stats, activity, session info
- Auto-refresh every 10 seconds for real-time updates
- Status summary in header (working/idle/error/away counts)
- Add OfficeIcon and office nav item in CORE group
- Register office route in page.tsx

Closes #115

Co-authored-by: bhavikprit <petrobhakti@gmail.com>
This commit is contained in:
Bhavik Patel 2026-03-04 05:03:24 +04:00 committed by GitHub
parent 720872a391
commit 90b712ea29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 351 additions and 0 deletions

View File

@ -30,6 +30,7 @@ import { IntegrationsPanel } from '@/components/panels/integrations-panel'
import { AlertRulesPanel } from '@/components/panels/alert-rules-panel'
import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel'
import { SuperAdminPanel } from '@/components/panels/super-admin-panel'
import { OfficePanel } from '@/components/panels/office-panel'
import { GitHubSyncPanel } from '@/components/panels/github-sync-panel'
import { ChatPanel } from '@/components/chat/chat-panel'
import { ErrorBoundary } from '@/components/ErrorBoundary'
@ -251,6 +252,8 @@ function ContentRouter({ tab }: { tab: string }) {
return <SettingsPanel />
case 'github':
return <GitHubSyncPanel />
case 'office':
return <OfficePanel />
case 'super-admin':
return <SuperAdminPanel />
default:

View File

@ -26,6 +26,7 @@ const navGroups: NavGroup[] = [
{ id: 'agents', label: 'Agents', icon: <AgentsIcon />, priority: true, requiresGateway: true },
{ id: 'tasks', label: 'Tasks', icon: <TasksIcon />, priority: true },
{ id: 'sessions', label: 'Sessions', icon: <SessionsIcon />, priority: false },
{ id: 'office', label: 'Office', icon: <OfficeIcon />, priority: false },
],
},
{
@ -623,3 +624,15 @@ function SettingsIcon() {
</svg>
)
}
function OfficeIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="12" height="10" rx="1" />
<path d="M2 7h12" />
<path d="M5 1v3M11 1v3" />
<rect x="4" y="9" width="3" height="3" rx="0.5" />
<rect x="9" y="9" width="3" height="3" rx="0.5" />
</svg>
)
}

View File

@ -0,0 +1,335 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useMissionControl, Agent } from '@/store'
type ViewMode = 'office' | 'org-chart'
interface Desk {
agent: Agent
row: number
col: number
}
const statusGlow: Record<string, string> = {
idle: 'shadow-green-500/40 border-green-500/60',
busy: 'shadow-yellow-500/40 border-yellow-500/60',
error: 'shadow-red-500/40 border-red-500/60',
offline: 'shadow-gray-500/20 border-gray-600/40',
}
const statusDot: Record<string, string> = {
idle: 'bg-green-500',
busy: 'bg-yellow-500',
error: 'bg-red-500',
offline: 'bg-gray-500',
}
const statusLabel: Record<string, string> = {
idle: 'Available',
busy: 'Working',
error: 'Error',
offline: 'Away',
}
const statusEmoji: Record<string, string> = {
idle: '☕',
busy: '💻',
error: '⚠️',
offline: '💤',
}
function getInitials(name: string): string {
return name
.split(/[\s_-]+/)
.filter(Boolean)
.map(w => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
function hashColor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash)
const colors = [
'bg-blue-600', 'bg-emerald-600', 'bg-violet-600', 'bg-amber-600',
'bg-rose-600', 'bg-cyan-600', 'bg-indigo-600', 'bg-teal-600',
'bg-orange-600', 'bg-pink-600', 'bg-lime-600', 'bg-fuchsia-600',
]
return colors[Math.abs(hash) % colors.length]
}
function formatLastSeen(ts?: number): string {
if (!ts) return 'Never seen'
const diff = Date.now() - ts * 1000
const m = Math.floor(diff / 60000)
if (m < 1) return 'Just now'
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
export function OfficePanel() {
const { agents } = useMissionControl()
const [localAgents, setLocalAgents] = useState<Agent[]>([])
const [viewMode, setViewMode] = useState<ViewMode>('office')
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
const [loading, setLoading] = useState(true)
const fetchAgents = useCallback(async () => {
try {
const res = await fetch('/api/agents')
if (res.ok) {
const data = await res.json()
setLocalAgents(data.agents || [])
}
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { fetchAgents() }, [fetchAgents])
useEffect(() => {
const interval = setInterval(fetchAgents, 10000)
return () => clearInterval(interval)
}, [fetchAgents])
const displayAgents = agents.length > 0 ? agents : localAgents
const counts = useMemo(() => {
const c = { idle: 0, busy: 0, error: 0, offline: 0 }
for (const a of displayAgents) c[a.status] = (c[a.status] || 0) + 1
return c
}, [displayAgents])
const desks: Desk[] = useMemo(() => {
const cols = Math.max(2, Math.ceil(Math.sqrt(displayAgents.length)))
return displayAgents.map((agent, i) => ({
agent,
row: Math.floor(i / cols),
col: i % cols,
}))
}, [displayAgents])
const roleGroups = useMemo(() => {
const groups = new Map<string, Agent[]>()
for (const a of displayAgents) {
const role = a.role || 'Unassigned'
if (!groups.has(role)) groups.set(role, [])
groups.get(role)!.push(a)
}
return groups
}, [displayAgents])
if (loading && displayAgents.length === 0) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
<span className="ml-3 text-muted-foreground">Loading office...</span>
</div>
)
}
return (
<div className="p-6 space-y-4">
<div className="border-b border-border pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Virtual Office</h1>
<p className="text-muted-foreground mt-1">See your agents at work in real time</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 text-xs text-muted-foreground mr-4">
{counts.busy > 0 && <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-yellow-500" />{counts.busy} working</span>}
{counts.idle > 0 && <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500" />{counts.idle} idle</span>}
{counts.error > 0 && <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500" />{counts.error} error</span>}
{counts.offline > 0 && <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-gray-500" />{counts.offline} away</span>}
</div>
<div className="flex rounded-md overflow-hidden border border-border">
<button
onClick={() => setViewMode('office')}
className={`px-3 py-1 text-sm transition-smooth ${viewMode === 'office' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
>
Office
</button>
<button
onClick={() => setViewMode('org-chart')}
className={`px-3 py-1 text-sm transition-smooth ${viewMode === 'org-chart' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
>
Org Chart
</button>
</div>
<button onClick={fetchAgents} className="px-3 py-1.5 text-sm bg-secondary text-muted-foreground rounded-md hover:bg-surface-2 transition-smooth">
Refresh
</button>
</div>
</div>
</div>
{displayAgents.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<div className="text-5xl mb-3">🏢</div>
<p className="text-lg">The office is empty</p>
<p className="text-sm mt-1">Add agents to see them appear here</p>
</div>
) : viewMode === 'office' ? (
<div className="relative">
<div className="bg-gradient-to-br from-surface-1/50 to-card rounded-xl border border-border p-6 min-h-[400px]">
<div className="absolute top-4 left-6 text-xs text-muted-foreground/50 uppercase tracking-widest font-medium">Main Floor</div>
<div className="mt-6 grid gap-6" style={{ gridTemplateColumns: `repeat(${Math.max(2, Math.ceil(Math.sqrt(displayAgents.length)))}, minmax(180px, 1fr))` }}>
{desks.map(({ agent }) => (
<div
key={agent.id}
onClick={() => setSelectedAgent(agent)}
className={`relative group cursor-pointer rounded-xl border-2 p-4 transition-all duration-300 hover:scale-[1.03] hover:z-10 shadow-lg ${statusGlow[agent.status]}`}
style={{ background: 'var(--card)' }}
>
<div className="absolute inset-x-3 bottom-0 h-1.5 bg-amber-900/20 rounded-t-sm" />
<div className="absolute -top-2 -right-2 text-lg" title={statusLabel[agent.status]}>
{statusEmoji[agent.status]}
</div>
<div className="flex items-center gap-3 mb-3">
<div className={`w-12 h-12 rounded-full ${hashColor(agent.name)} flex items-center justify-center text-white font-bold text-sm shrink-0 ring-2 ring-offset-2 ring-offset-card ${agent.status === 'busy' ? 'ring-yellow-500 animate-pulse' : agent.status === 'idle' ? 'ring-green-500' : agent.status === 'error' ? 'ring-red-500' : 'ring-gray-600'}`}>
{getInitials(agent.name)}
</div>
<div className="min-w-0">
<div className="font-semibold text-foreground text-sm truncate">{agent.name}</div>
<div className="text-xs text-muted-foreground truncate">{agent.role}</div>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1">
<span className={`w-1.5 h-1.5 rounded-full ${statusDot[agent.status]} ${agent.status === 'busy' ? 'animate-pulse' : ''}`} />
<span className="text-muted-foreground">{statusLabel[agent.status]}</span>
</span>
<span className="text-muted-foreground/60">{formatLastSeen(agent.last_seen)}</span>
</div>
{agent.last_activity && (
<div className="mt-2 text-[10px] text-muted-foreground/50 truncate italic">
{agent.last_activity}
</div>
)}
{agent.taskStats && agent.taskStats.in_progress > 0 && (
<div className="absolute -top-2 -left-2 w-5 h-5 bg-yellow-500 rounded-full flex items-center justify-center text-[10px] font-bold text-black">
{agent.taskStats.in_progress}
</div>
)}
</div>
))}
</div>
<div className="mt-8 flex items-center gap-4 text-[10px] text-muted-foreground/30">
<span>🪴</span>
<div className="flex-1 border-t border-dashed border-border/30" />
<span> Break room</span>
<div className="flex-1 border-t border-dashed border-border/30" />
<span>🪴</span>
</div>
</div>
</div>
) : (
<div className="space-y-6">
{[...roleGroups.entries()].map(([role, members]) => (
<div key={role} className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center gap-2 mb-4">
<div className="w-1 h-6 bg-primary rounded-full" />
<h3 className="font-semibold text-foreground">{role}</h3>
<span className="text-xs text-muted-foreground ml-1">({members.length})</span>
</div>
<div className="flex flex-wrap gap-3">
{members.map(agent => (
<div
key={agent.id}
onClick={() => setSelectedAgent(agent)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-all hover:scale-[1.02] ${statusGlow[agent.status]}`}
style={{ background: 'var(--card)' }}
>
<div className={`w-8 h-8 rounded-full ${hashColor(agent.name)} flex items-center justify-center text-white font-bold text-xs`}>
{getInitials(agent.name)}
</div>
<div>
<div className="text-sm font-medium text-foreground">{agent.name}</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span className={`w-1.5 h-1.5 rounded-full ${statusDot[agent.status]}`} />
{statusLabel[agent.status]}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{selectedAgent && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setSelectedAgent(null)}>
<div className="bg-card border border-border rounded-xl max-w-sm w-full p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className={`w-14 h-14 rounded-full ${hashColor(selectedAgent.name)} flex items-center justify-center text-white font-bold text-lg ring-2 ring-offset-2 ring-offset-card ${selectedAgent.status === 'busy' ? 'ring-yellow-500' : selectedAgent.status === 'idle' ? 'ring-green-500' : selectedAgent.status === 'error' ? 'ring-red-500' : 'ring-gray-600'}`}>
{getInitials(selectedAgent.name)}
</div>
<div>
<h3 className="text-lg font-bold text-foreground">{selectedAgent.name}</h3>
<p className="text-sm text-muted-foreground">{selectedAgent.role}</p>
</div>
</div>
<button onClick={() => setSelectedAgent(null)} className="text-muted-foreground hover:text-foreground text-xl">×</button>
</div>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<span className={`w-3 h-3 rounded-full ${statusDot[selectedAgent.status]}`} />
<span className="font-medium text-foreground">{statusLabel[selectedAgent.status]}</span>
<span className="text-muted-foreground ml-auto">{formatLastSeen(selectedAgent.last_seen)}</span>
</div>
{selectedAgent.last_activity && (
<div className="bg-secondary rounded-lg p-3">
<span className="text-xs text-muted-foreground block mb-1">Current Activity</span>
<span className="text-foreground text-sm">{selectedAgent.last_activity}</span>
</div>
)}
{selectedAgent.taskStats && (
<div className="grid grid-cols-4 gap-2">
<div className="text-center bg-secondary rounded-lg p-2">
<div className="text-lg font-bold text-foreground">{selectedAgent.taskStats.total}</div>
<div className="text-[10px] text-muted-foreground">Total</div>
</div>
<div className="text-center bg-secondary rounded-lg p-2">
<div className="text-lg font-bold text-blue-400">{selectedAgent.taskStats.assigned}</div>
<div className="text-[10px] text-muted-foreground">Assigned</div>
</div>
<div className="text-center bg-secondary rounded-lg p-2">
<div className="text-lg font-bold text-yellow-400">{selectedAgent.taskStats.in_progress}</div>
<div className="text-[10px] text-muted-foreground">Active</div>
</div>
<div className="text-center bg-secondary rounded-lg p-2">
<div className="text-lg font-bold text-green-400">{selectedAgent.taskStats.completed}</div>
<div className="text-[10px] text-muted-foreground">Done</div>
</div>
</div>
)}
{selectedAgent.session_key && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">Session:</span> <code className="font-mono">{selectedAgent.session_key}</code>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}