diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx index f18a852..c0831de 100644 --- a/src/app/[[...panel]]/page.tsx +++ b/src/app/[[...panel]]/page.tsx @@ -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 case 'github': return + case 'office': + return case 'super-admin': return default: diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx index 21fca2c..c208078 100644 --- a/src/components/layout/nav-rail.tsx +++ b/src/components/layout/nav-rail.tsx @@ -26,6 +26,7 @@ const navGroups: NavGroup[] = [ { id: 'agents', label: 'Agents', icon: , priority: true, requiresGateway: true }, { id: 'tasks', label: 'Tasks', icon: , priority: true }, { id: 'sessions', label: 'Sessions', icon: , priority: false }, + { id: 'office', label: 'Office', icon: , priority: false }, ], }, { @@ -623,3 +624,15 @@ function SettingsIcon() { ) } + +function OfficeIcon() { + return ( + + + + + + + + ) +} diff --git a/src/components/panels/office-panel.tsx b/src/components/panels/office-panel.tsx new file mode 100644 index 0000000..521301b --- /dev/null +++ b/src/components/panels/office-panel.tsx @@ -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 = { + 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 = { + idle: 'bg-green-500', + busy: 'bg-yellow-500', + error: 'bg-red-500', + offline: 'bg-gray-500', +} + +const statusLabel: Record = { + idle: 'Available', + busy: 'Working', + error: 'Error', + offline: 'Away', +} + +const statusEmoji: Record = { + 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([]) + const [viewMode, setViewMode] = useState('office') + const [selectedAgent, setSelectedAgent] = useState(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() + 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 ( +
+
+ Loading office... +
+ ) + } + + return ( +
+
+
+
+

Virtual Office

+

See your agents at work in real time

+
+
+
+ {counts.busy > 0 && {counts.busy} working} + {counts.idle > 0 && {counts.idle} idle} + {counts.error > 0 && {counts.error} error} + {counts.offline > 0 && {counts.offline} away} +
+
+ + +
+ +
+
+
+ + {displayAgents.length === 0 ? ( +
+
🏢
+

The office is empty

+

Add agents to see them appear here

+
+ ) : viewMode === 'office' ? ( +
+
+
Main Floor
+ +
+ {desks.map(({ agent }) => ( +
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)' }} + > +
+ +
+ {statusEmoji[agent.status]} +
+ +
+
+ {getInitials(agent.name)} +
+
+
{agent.name}
+
{agent.role}
+
+
+ +
+ + + {statusLabel[agent.status]} + + {formatLastSeen(agent.last_seen)} +
+ + {agent.last_activity && ( +
+ {agent.last_activity} +
+ )} + + {agent.taskStats && agent.taskStats.in_progress > 0 && ( +
+ {agent.taskStats.in_progress} +
+ )} +
+ ))} +
+ +
+ 🪴 +
+ ☕ Break room +
+ 🪴 +
+
+
+ ) : ( +
+ {[...roleGroups.entries()].map(([role, members]) => ( +
+
+
+

{role}

+ ({members.length}) +
+
+ {members.map(agent => ( +
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)' }} + > +
+ {getInitials(agent.name)} +
+
+
{agent.name}
+
+ + {statusLabel[agent.status]} +
+
+
+ ))} +
+
+ ))} +
+ )} + + {selectedAgent && ( +
setSelectedAgent(null)}> +
e.stopPropagation()}> +
+
+
+ {getInitials(selectedAgent.name)} +
+
+

{selectedAgent.name}

+

{selectedAgent.role}

+
+
+ +
+ +
+
+ + {statusLabel[selectedAgent.status]} + {formatLastSeen(selectedAgent.last_seen)} +
+ + {selectedAgent.last_activity && ( +
+ Current Activity + {selectedAgent.last_activity} +
+ )} + + {selectedAgent.taskStats && ( +
+
+
{selectedAgent.taskStats.total}
+
Total
+
+
+
{selectedAgent.taskStats.assigned}
+
Assigned
+
+
+
{selectedAgent.taskStats.in_progress}
+
Active
+
+
+
{selectedAgent.taskStats.completed}
+
Done
+
+
+ )} + + {selectedAgent.session_key && ( +
+ Session: {selectedAgent.session_key} +
+ )} +
+
+
+ )} +
+ ) +}