nyk commited on
Commit
a7387de
·
unverified ·
2 Parent(s): 0c16cdf1bc4581

Merge pull request #212 from builderz-labs/feat-virtual-office-local-mode-upgrades

Browse files
Files changed (34) hide show
  1. public/office-sprites/cc0-hero/ATTRIBUTION-CC0-HERO.txt +4 -0
  2. public/office-sprites/cc0-hero/player.png +3 -0
  3. public/office-sprites/cc0-hero/player_full_animation.png +3 -0
  4. public/office-sprites/desk.svg +11 -0
  5. public/office-sprites/floor-tile.svg +7 -0
  6. public/office-sprites/kenney/ATTRIBUTION-KENNEY.txt +22 -0
  7. public/office-sprites/kenney/chairDesk.png +3 -0
  8. public/office-sprites/kenney/computerScreen.png +3 -0
  9. public/office-sprites/kenney/desk.png +3 -0
  10. public/office-sprites/kenney/floorFull.png +3 -0
  11. public/office-sprites/kenney/plantSmall1.png +3 -0
  12. public/office-sprites/kenney/plantSmall2.png +3 -0
  13. public/office-sprites/kenney/rugRectangle.png +3 -0
  14. public/office-sprites/kenney/tableCross.png +3 -0
  15. public/office-sprites/lounge-rug.svg +6 -0
  16. public/office-sprites/plant.svg +8 -0
  17. public/office-sprites/worker-base.svg +10 -0
  18. public/office-sprites/worker-idle-a.svg +10 -0
  19. public/office-sprites/worker-idle-b.svg +10 -0
  20. public/office-sprites/worker-type-a.svg +10 -0
  21. public/office-sprites/worker-type-b.svg +10 -0
  22. public/office-sprites/worker-walk-a.svg +10 -0
  23. public/office-sprites/worker-walk-b.svg +10 -0
  24. src/app/api/cron/route.ts +1 -13
  25. src/app/api/local/flight-deck/route.ts +124 -0
  26. src/app/api/local/terminal/route.ts +47 -0
  27. src/app/api/scheduler/route.ts +6 -3
  28. src/app/api/sessions/route.ts +64 -2
  29. src/components/panels/cron-management-panel.tsx +107 -16
  30. src/components/panels/office-panel.tsx +2025 -69
  31. src/components/panels/super-admin-panel.tsx +165 -52
  32. src/components/panels/webhook-panel.tsx +94 -0
  33. src/lib/codex-sessions.ts +219 -0
  34. src/lib/office-layout.ts +132 -0
public/office-sprites/cc0-hero/ATTRIBUTION-CC0-HERO.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Source: OpenGameArt - "Hero character sprite sheet" by Fry
2
+ URL: https://opengameart.org/content/hero-character-sprite-sheet
3
+ License: CC0 1.0 Universal
4
+ Notes: Used for Virtual Office worker character rendering.
public/office-sprites/cc0-hero/player.png ADDED

Git LFS Details

  • SHA256: 387cc6f1bf0b262ccb878fc330ba9b779799fbdeb99467e0f68fab37997d4d8b
  • Pointer size: 130 Bytes
  • Size of remote file: 14 kB
public/office-sprites/cc0-hero/player_full_animation.png ADDED

Git LFS Details

  • SHA256: 0d792990a72f6038308af32d2ecc01eedf9e7819c9f5c012ef06d38f9197fb82
  • Pointer size: 130 Bytes
  • Size of remote file: 50.6 kB
public/office-sprites/desk.svg ADDED
public/office-sprites/floor-tile.svg ADDED
public/office-sprites/kenney/ATTRIBUTION-KENNEY.txt ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ Furniture Pack (1.0) Exclusive
4
+
5
+ Created/distributed by Kenney (www.kenney.nl)
6
+
7
+ ------------------------------
8
+
9
+ License: (Creative Commons Zero, CC0)
10
+ http://creativecommons.org/publicdomain/zero/1.0/
11
+
12
+ This content is free to use in personal, educational and commercial projects.
13
+ Support us by crediting (Kenney or www.kenney.nl), this is not mandatory.
14
+
15
+ ------------------------------
16
+
17
+ Donate: http://support.kenney.nl
18
+ Request: http://request.kenney.nl
19
+ Patreon: http://patreon.com/kenney/
20
+
21
+ Follow on Twitter for updates:
22
+ @KenneyNL
public/office-sprites/kenney/chairDesk.png ADDED

Git LFS Details

  • SHA256: e9badea414e99f39b4700aa273518c18ec19125513cc453d5515ab9396215935
  • Pointer size: 128 Bytes
  • Size of remote file: 997 Bytes
public/office-sprites/kenney/computerScreen.png ADDED

Git LFS Details

  • SHA256: 231f7acf69961f83dcb33a55cd80eea81302db6f64318c7eda2a6aa24e5aad80
  • Pointer size: 128 Bytes
  • Size of remote file: 353 Bytes
public/office-sprites/kenney/desk.png ADDED

Git LFS Details

  • SHA256: 4864329b7d9fa5c1873021deef8c70aaa7bda930d8dd2bc525784da166d7f708
  • Pointer size: 128 Bytes
  • Size of remote file: 560 Bytes
public/office-sprites/kenney/floorFull.png ADDED

Git LFS Details

  • SHA256: 985779814feb09396bb0a468f6c4774efb1b5376f2ef9325cea21e8ebda298b1
  • Pointer size: 128 Bytes
  • Size of remote file: 795 Bytes
public/office-sprites/kenney/plantSmall1.png ADDED

Git LFS Details

  • SHA256: b8d547069932e3058936b3efc864774bdd49c82e3756af63ae4e69a254e00f89
  • Pointer size: 128 Bytes
  • Size of remote file: 257 Bytes
public/office-sprites/kenney/plantSmall2.png ADDED

Git LFS Details

  • SHA256: 0e27131f8bb0d81203bd100f88a1a18071f8e164f018f7f185bfa4040331da01
  • Pointer size: 128 Bytes
  • Size of remote file: 248 Bytes
public/office-sprites/kenney/rugRectangle.png ADDED

Git LFS Details

  • SHA256: 2fab23ff723490c079640489e3484ca610d3ad9b56652970df56eefbb55b5f53
  • Pointer size: 128 Bytes
  • Size of remote file: 418 Bytes
public/office-sprites/kenney/tableCross.png ADDED

Git LFS Details

  • SHA256: be0e9a160a109c57ed5299580781a48a4f9e1db5a8f3b75c490753731cf67447
  • Pointer size: 128 Bytes
  • Size of remote file: 512 Bytes
public/office-sprites/lounge-rug.svg ADDED
public/office-sprites/plant.svg ADDED
public/office-sprites/worker-base.svg ADDED
public/office-sprites/worker-idle-a.svg ADDED
public/office-sprites/worker-idle-b.svg ADDED
public/office-sprites/worker-type-a.svg ADDED
public/office-sprites/worker-type-b.svg ADDED
public/office-sprites/worker-walk-a.svg ADDED
public/office-sprites/worker-walk-b.svg ADDED
src/app/api/cron/route.ts CHANGED
@@ -95,18 +95,6 @@ async function saveCronFile(data: OpenClawCronFile): Promise<boolean> {
95
  }
96
  }
97
 
98
- /** Deduplicate jobs by name — keep the latest (by createdAtMs) per unique name */
99
- function deduplicateJobs(jobs: OpenClawCronJob[]): OpenClawCronJob[] {
100
- const latest = new Map<string, OpenClawCronJob>()
101
- for (const job of jobs) {
102
- const existing = latest.get(job.name)
103
- if (!existing || (job.createdAtMs ?? 0) > (existing.createdAtMs ?? 0)) {
104
- latest.set(job.name, job)
105
- }
106
- }
107
- return [...latest.values()]
108
- }
109
-
110
  function mapLastStatus(status?: string): 'success' | 'error' | 'running' | undefined {
111
  if (!status) return undefined
112
  const s = status.toLowerCase()
@@ -157,7 +145,7 @@ export async function GET(request: NextRequest) {
157
  return NextResponse.json({ jobs: [] })
158
  }
159
 
160
- const jobs = deduplicateJobs(cronFile.jobs).map(mapOpenClawJob)
161
  return NextResponse.json({ jobs })
162
  }
163
 
 
95
  }
96
  }
97
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  function mapLastStatus(status?: string): 'success' | 'error' | 'running' | undefined {
99
  if (!status) return undefined
100
  const s = status.toLowerCase()
 
145
  return NextResponse.json({ jobs: [] })
146
  }
147
 
148
+ const jobs = cronFile.jobs.map(mapOpenClawJob)
149
  return NextResponse.json({ jobs })
150
  }
151
 
src/app/api/local/flight-deck/route.ts ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { existsSync, statSync } from 'node:fs'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { runCommand } from '@/lib/command'
5
+
6
+ const DEFAULT_DOWNLOAD_URL = 'https://flightdeck.example.com/download'
7
+ const DEFAULT_INSTALL_PATHS = [
8
+ '/Applications/Flight Deck.app',
9
+ '/Applications/Flight Desk.app',
10
+ ]
11
+
12
+ function getConfiguredFlightDeckPath(): string | null {
13
+ const fromEnv = String(process.env.FLIGHT_DECK_PATH || '').trim()
14
+ return fromEnv || null
15
+ }
16
+
17
+ function getFlightDeckBaseUrl(): string {
18
+ const fromEnv = String(process.env.FLIGHT_DECK_URL || '').trim()
19
+ if (fromEnv) return fromEnv
20
+ return 'http://127.0.0.1:4177'
21
+ }
22
+
23
+ function getFlightDeckLaunchUrl(): string {
24
+ const fromEnv = String(process.env.FLIGHT_DECK_LAUNCH_URL || '').trim()
25
+ if (fromEnv) return fromEnv
26
+ return 'flightdeck://open'
27
+ }
28
+
29
+ function isInstalled(targetPath: string): boolean {
30
+ try {
31
+ return existsSync(targetPath) && statSync(targetPath).isDirectory()
32
+ } catch {
33
+ return false
34
+ }
35
+ }
36
+
37
+ function resolveFlightDeckInstallPath(): string | null {
38
+ const configured = getConfiguredFlightDeckPath()
39
+ if (configured && isInstalled(configured)) return configured
40
+ for (const candidate of DEFAULT_INSTALL_PATHS) {
41
+ if (isInstalled(candidate)) return candidate
42
+ }
43
+ return configured
44
+ }
45
+
46
+ /**
47
+ * GET /api/local/flight-deck
48
+ * Check Flight Deck local installation status.
49
+ */
50
+ export async function GET(request: NextRequest) {
51
+ const auth = requireRole(request, 'viewer')
52
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
53
+
54
+ const installPath = resolveFlightDeckInstallPath()
55
+ const installed = installPath ? isInstalled(installPath) : false
56
+
57
+ return NextResponse.json({
58
+ installed,
59
+ installPath: installPath || null,
60
+ appUrl: getFlightDeckBaseUrl(),
61
+ downloadUrl: DEFAULT_DOWNLOAD_URL,
62
+ })
63
+ }
64
+
65
+ /**
66
+ * POST /api/local/flight-deck
67
+ * Build a Flight Deck URL for the selected agent/session.
68
+ */
69
+ export async function POST(request: NextRequest) {
70
+ const auth = requireRole(request, 'operator')
71
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
72
+
73
+ const installPath = resolveFlightDeckInstallPath()
74
+ const installed = installPath ? isInstalled(installPath) : false
75
+ if (!installed) {
76
+ return NextResponse.json({
77
+ installed: false,
78
+ error: 'Flight Deck is not installed locally.',
79
+ installPath: installPath || null,
80
+ downloadUrl: DEFAULT_DOWNLOAD_URL,
81
+ }, { status: 404 })
82
+ }
83
+
84
+ const body = await request.json().catch(() => ({}))
85
+ const agent = typeof body?.agent === 'string' ? body.agent : ''
86
+ const session = typeof body?.session === 'string' ? body.session : ''
87
+
88
+ const webUrl = new URL(getFlightDeckBaseUrl())
89
+ webUrl.searchParams.set('source', 'mission-control')
90
+ if (agent) webUrl.searchParams.set('agent', agent)
91
+ if (session) webUrl.searchParams.set('session', session)
92
+
93
+ const launchUrl = new URL(getFlightDeckLaunchUrl())
94
+ launchUrl.searchParams.set('source', 'mission-control')
95
+ if (agent) launchUrl.searchParams.set('agent', agent)
96
+ if (session) launchUrl.searchParams.set('session', session)
97
+
98
+ try {
99
+ // Launch the native app directly; pass deep-link as payload.
100
+ await runCommand('open', ['-a', installPath!, launchUrl.toString()], { timeoutMs: 10_000 })
101
+ } catch (error: any) {
102
+ try {
103
+ // Fallback for apps registered as URL handlers.
104
+ await runCommand('open', [launchUrl.toString()], { timeoutMs: 10_000 })
105
+ } catch (fallbackError: any) {
106
+ return NextResponse.json({
107
+ installed: true,
108
+ launched: false,
109
+ error: fallbackError?.message || error?.message || 'Failed to launch Flight Deck app.',
110
+ fallbackUrl: webUrl.toString(),
111
+ downloadUrl: DEFAULT_DOWNLOAD_URL,
112
+ }, { status: 500 })
113
+ }
114
+ }
115
+
116
+ return NextResponse.json({
117
+ installed: true,
118
+ launched: true,
119
+ url: webUrl.toString(),
120
+ launchUrl: launchUrl.toString(),
121
+ })
122
+ }
123
+
124
+ export const dynamic = 'force-dynamic'
src/app/api/local/terminal/route.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { existsSync, statSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { requireRole } from '@/lib/auth'
5
+ import { runCommand } from '@/lib/command'
6
+
7
+ function isAllowedDirectory(input: string): boolean {
8
+ const cwd = resolve(input)
9
+ if (!cwd.startsWith('/')) return false
10
+ if (!(cwd.startsWith('/Users/') || cwd.startsWith('/tmp/') || cwd.startsWith('/var/folders/'))) {
11
+ return false
12
+ }
13
+ if (!existsSync(cwd)) return false
14
+ try {
15
+ return statSync(cwd).isDirectory()
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ /**
22
+ * POST /api/local/terminal
23
+ * Body: { cwd: string }
24
+ * Opens a new local Terminal window at the given working directory.
25
+ */
26
+ export async function POST(request: NextRequest) {
27
+ const auth = requireRole(request, 'operator')
28
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
29
+
30
+ const body = await request.json().catch(() => ({}))
31
+ const cwd = typeof body?.cwd === 'string' ? body.cwd.trim() : ''
32
+ if (!cwd) {
33
+ return NextResponse.json({ error: 'cwd is required' }, { status: 400 })
34
+ }
35
+ if (!isAllowedDirectory(cwd)) {
36
+ return NextResponse.json({ error: 'cwd must be an existing safe local directory' }, { status: 400 })
37
+ }
38
+
39
+ try {
40
+ await runCommand('open', ['-a', 'Terminal', cwd], { timeoutMs: 10_000 })
41
+ return NextResponse.json({ ok: true, message: `Opened Terminal at ${cwd}` })
42
+ } catch (error: any) {
43
+ return NextResponse.json({ error: error?.message || 'Failed to open Terminal' }, { status: 500 })
44
+ }
45
+ }
46
+
47
+ export const dynamic = 'force-dynamic'
src/app/api/scheduler/route.ts CHANGED
@@ -21,10 +21,13 @@ export async function POST(request: NextRequest) {
21
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
22
 
23
  const body = await request.json().catch(() => ({}))
24
- const taskId = body.task_id
 
25
 
26
- if (!taskId || !['auto_backup', 'auto_cleanup', 'agent_heartbeat'].includes(taskId)) {
27
- return NextResponse.json({ error: 'task_id required: auto_backup, auto_cleanup, or agent_heartbeat' }, { status: 400 })
 
 
28
  }
29
 
30
  const result = await triggerTask(taskId)
 
21
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
22
 
23
  const body = await request.json().catch(() => ({}))
24
+ const taskId = typeof body?.task_id === 'string' ? body.task_id : ''
25
+ const allowedTaskIds = new Set(getSchedulerStatus().map((task) => task.id))
26
 
27
+ if (!taskId || !allowedTaskIds.has(taskId)) {
28
+ return NextResponse.json({
29
+ error: `task_id required: ${Array.from(allowedTaskIds).join(', ')}`,
30
+ }, { status: 400 })
31
  }
32
 
33
  const result = await triggerTask(taskId)
src/app/api/sessions/route.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getAllGatewaySessions } from '@/lib/sessions'
3
  import { syncClaudeSessions } from '@/lib/claude-sessions'
 
4
  import { getDatabase } from '@/lib/db'
5
  import { requireRole } from '@/lib/auth'
6
  import { logger } from '@/lib/logger'
@@ -49,10 +50,12 @@ export async function GET(request: NextRequest) {
49
  return NextResponse.json({ sessions })
50
  }
51
 
52
- // Fallback: sync and read local Claude sessions from SQLite
53
  await syncClaudeSessions()
54
  const claudeSessions = getLocalClaudeSessions()
55
- return NextResponse.json({ sessions: claudeSessions })
 
 
56
  } catch (error) {
57
  logger.error({ err: error }, 'Sessions API error')
58
  return NextResponse.json({ sessions: [] })
@@ -89,6 +92,7 @@ function getLocalClaudeSessions() {
89
  toolUses: s.tool_uses || 0,
90
  estimatedCost: s.estimated_cost || 0,
91
  lastUserPrompt: s.last_user_prompt || null,
 
92
  }
93
  })
94
  } catch (err) {
@@ -97,6 +101,64 @@ function getLocalClaudeSessions() {
97
  }
98
  }
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  function formatTokens(n: number): string {
101
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`
102
  if (n >= 1000) return `${Math.round(n / 1000)}k`
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getAllGatewaySessions } from '@/lib/sessions'
3
  import { syncClaudeSessions } from '@/lib/claude-sessions'
4
+ import { scanCodexSessions } from '@/lib/codex-sessions'
5
  import { getDatabase } from '@/lib/db'
6
  import { requireRole } from '@/lib/auth'
7
  import { logger } from '@/lib/logger'
 
50
  return NextResponse.json({ sessions })
51
  }
52
 
53
+ // Fallback: sync and read local Claude + Codex sessions from disk/SQLite
54
  await syncClaudeSessions()
55
  const claudeSessions = getLocalClaudeSessions()
56
+ const codexSessions = getLocalCodexSessions()
57
+ const merged = mergeLocalSessions(claudeSessions, codexSessions)
58
+ return NextResponse.json({ sessions: merged })
59
  } catch (error) {
60
  logger.error({ err: error }, 'Sessions API error')
61
  return NextResponse.json({ sessions: [] })
 
92
  toolUses: s.tool_uses || 0,
93
  estimatedCost: s.estimated_cost || 0,
94
  lastUserPrompt: s.last_user_prompt || null,
95
+ workingDir: s.project_path || null,
96
  }
97
  })
98
  } catch (err) {
 
101
  }
102
  }
103
 
104
+ function getLocalCodexSessions() {
105
+ try {
106
+ const rows = scanCodexSessions(100)
107
+
108
+ return rows.map((s) => {
109
+ const total = s.totalTokens || (s.inputTokens + s.outputTokens)
110
+ const lastMsg = s.lastMessageAt ? new Date(s.lastMessageAt).getTime() : 0
111
+ const firstMsg = s.firstMessageAt ? new Date(s.firstMessageAt).getTime() : 0
112
+ return {
113
+ id: s.sessionId,
114
+ key: s.projectSlug || s.sessionId,
115
+ agent: s.projectSlug || 'codex-local',
116
+ kind: 'codex-cli',
117
+ age: formatAge(lastMsg),
118
+ model: s.model || 'codex',
119
+ tokens: `${formatTokens(s.inputTokens || 0)}/${formatTokens(s.outputTokens || 0)}`,
120
+ channel: 'local',
121
+ flags: [],
122
+ active: s.isActive,
123
+ startTime: firstMsg,
124
+ lastActivity: lastMsg,
125
+ source: 'local' as const,
126
+ userMessages: s.userMessages || 0,
127
+ assistantMessages: s.assistantMessages || 0,
128
+ toolUses: 0,
129
+ estimatedCost: 0,
130
+ lastUserPrompt: null,
131
+ totalTokens: total,
132
+ workingDir: s.projectPath || null,
133
+ }
134
+ })
135
+ } catch (err) {
136
+ logger.warn({ err }, 'Failed to read local Codex sessions')
137
+ return []
138
+ }
139
+ }
140
+
141
+ function mergeLocalSessions(
142
+ claudeSessions: Array<Record<string, any>>,
143
+ codexSessions: Array<Record<string, any>>,
144
+ ) {
145
+ const merged = [...claudeSessions, ...codexSessions]
146
+ const deduped = new Map<string, Record<string, any>>()
147
+
148
+ for (const session of merged) {
149
+ const id = String(session?.id || '')
150
+ if (!id) continue
151
+ const existing = deduped.get(id)
152
+ const currentActivity = Number(session?.lastActivity || 0)
153
+ const existingActivity = Number(existing?.lastActivity || 0)
154
+ if (!existing || currentActivity > existingActivity) deduped.set(id, session)
155
+ }
156
+
157
+ return Array.from(deduped.values())
158
+ .sort((a, b) => Number(b?.lastActivity || 0) - Number(a?.lastActivity || 0))
159
+ .slice(0, 100)
160
+ }
161
+
162
  function formatTokens(n: number): string {
163
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`
164
  if (n >= 1000) return `${Math.round(n / 1000)}k`
src/components/panels/cron-management-panel.tsx CHANGED
@@ -51,7 +51,8 @@ function formatDateLabel(date: Date): string {
51
  }
52
 
53
  export function CronManagementPanel() {
54
- const { cronJobs, setCronJobs } = useMissionControl()
 
55
  const [isLoading, setIsLoading] = useState(false)
56
  const [showAddForm, setShowAddForm] = useState(false)
57
  const [selectedJob, setSelectedJob] = useState<CronJob | null>(null)
@@ -90,15 +91,40 @@ export function CronManagementPanel() {
90
  const loadCronJobs = useCallback(async () => {
91
  setIsLoading(true)
92
  try {
93
- const response = await fetch('/api/cron?action=list')
94
- const data = await response.json()
95
- setCronJobs(data.jobs || [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  } catch (error) {
97
  log.error('Failed to load cron jobs:', error)
98
  } finally {
99
  setIsLoading(false)
100
  }
101
- }, [setCronJobs])
102
 
103
  useEffect(() => {
104
  loadCronJobs()
@@ -122,9 +148,44 @@ export function CronManagementPanel() {
122
  loadAvailableModels()
123
  }, [])
124
 
125
- const loadJobLogs = async (jobName: string) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  try {
127
- const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(jobName)}`)
128
  const data = await response.json()
129
  setJobLogs(data.logs || [])
130
  } catch (error) {
@@ -158,7 +219,24 @@ export function CronManagementPanel() {
158
  }
159
 
160
  const triggerJob = async (job: CronJob) => {
 
161
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  const response = await fetch('/api/cron', {
163
  method: 'POST',
164
  headers: { 'Content-Type': 'application/json' },
@@ -253,7 +331,7 @@ export function CronManagementPanel() {
253
 
254
  const handleJobSelect = (job: CronJob) => {
255
  setSelectedJob(job)
256
- loadJobLogs(job.name)
257
  }
258
 
259
  const getStatusColor = (status?: string) => {
@@ -426,7 +504,11 @@ export function CronManagementPanel() {
426
  <div className="flex flex-wrap items-center justify-between gap-3">
427
  <div>
428
  <h2 className="text-xl font-semibold">Calendar View</h2>
429
- <p className="text-sm text-muted-foreground">Interactive schedule across all matching cron jobs</p>
 
 
 
 
430
  </div>
431
  <div className="flex items-center gap-2">
432
  <button
@@ -647,10 +729,12 @@ export function CronManagementPanel() {
647
  </div>
648
  ) : (
649
  <div className="space-y-3 max-h-96 overflow-y-auto">
650
- {cronJobs.map((job, index) => (
651
- <div
652
- key={`${job.name}-${index}`}
653
- className={`border border-border rounded-lg p-4 cursor-pointer transition-colors ${
 
 
654
  selectedJob?.name === job.name
655
  ? 'bg-primary/10 border-primary/30'
656
  : 'hover:bg-secondary'
@@ -665,13 +749,15 @@ export function CronManagementPanel() {
665
 
666
  {/* Job Type Tag */}
667
  <span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${
 
668
  job.name.includes('backup') ? 'bg-green-500/20 text-green-400 border-green-500/30' :
669
  job.name.includes('alert') ? 'bg-orange-500/20 text-orange-400 border-orange-500/30' :
670
  job.name.includes('brief') ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
671
  job.name.includes('scan') ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' :
672
  'bg-muted-foreground/10 text-muted-foreground border-muted-foreground/20'
673
  }`}>
674
- {job.name.includes('backup') ? 'BACKUP' :
 
675
  job.name.includes('alert') ? 'ALERT' :
676
  job.name.includes('brief') ? 'BRIEF' :
677
  job.name.includes('scan') ? 'SCAN' :
@@ -712,11 +798,12 @@ export function CronManagementPanel() {
712
  e.stopPropagation()
713
  toggleJob(job)
714
  }}
 
715
  className={`px-2 py-1 text-xs rounded ${
716
  job.enabled
717
  ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
718
  : 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
719
- } transition-colors`}
720
  >
721
  {job.enabled ? 'Disable' : 'Enable'}
722
  </button>
@@ -734,6 +821,7 @@ export function CronManagementPanel() {
734
  e.stopPropagation()
735
  removeJob(job)
736
  }}
 
737
  className="px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors"
738
  >
739
  Remove
@@ -741,7 +829,7 @@ export function CronManagementPanel() {
741
  </div>
742
  </div>
743
  </div>
744
- ))}
745
  </div>
746
  )}
747
  </div>
@@ -763,6 +851,9 @@ export function CronManagementPanel() {
763
  <div><span className="text-muted-foreground">Model:</span> <code className="font-mono text-xs">{selectedJob.model}</code></div>
764
  )}
765
  <div><span className="text-muted-foreground">Status:</span> {selectedJob.enabled ? '🟢 Enabled' : '🔴 Disabled'}</div>
 
 
 
766
  {selectedJob.nextRun && (
767
  <div><span className="text-muted-foreground">Next run:</span> {new Date(selectedJob.nextRun).toLocaleString()}</div>
768
  )}
 
51
  }
52
 
53
  export function CronManagementPanel() {
54
+ const { cronJobs, setCronJobs, dashboardMode } = useMissionControl()
55
+ const isLocalMode = dashboardMode === 'local'
56
  const [isLoading, setIsLoading] = useState(false)
57
  const [showAddForm, setShowAddForm] = useState(false)
58
  const [selectedJob, setSelectedJob] = useState<CronJob | null>(null)
 
91
  const loadCronJobs = useCallback(async () => {
92
  setIsLoading(true)
93
  try {
94
+ const cronResponse = await fetch('/api/cron?action=list')
95
+ const cronData = await cronResponse.json()
96
+ const cronList = Array.isArray(cronData.jobs) ? cronData.jobs : []
97
+
98
+ if (!isLocalMode) {
99
+ setCronJobs(cronList)
100
+ return
101
+ }
102
+
103
+ const schedulerResponse = await fetch('/api/scheduler')
104
+ const schedulerData = await schedulerResponse.json()
105
+ const schedulerTasks = Array.isArray(schedulerData.tasks) ? schedulerData.tasks : []
106
+ const mappedSchedulerJobs: CronJob[] = schedulerTasks.map((task: any) => ({
107
+ id: task.id,
108
+ name: task.name || task.id || 'scheduler-task',
109
+ schedule: 'system-managed automation',
110
+ command: `Built-in local automation (${task.id || 'unknown'})`,
111
+ agentId: 'mission-control-local',
112
+ delivery: 'local',
113
+ enabled: task.running ? true : !!task.enabled,
114
+ lastRun: typeof task.lastRun === 'number' ? task.lastRun : undefined,
115
+ nextRun: typeof task.nextRun === 'number' ? task.nextRun : undefined,
116
+ lastStatus: task.running
117
+ ? 'running'
118
+ : (task.lastResult?.ok === false ? 'error' : (task.lastResult?.ok === true ? 'success' : undefined)),
119
+ }))
120
+
121
+ setCronJobs([...cronList, ...mappedSchedulerJobs])
122
  } catch (error) {
123
  log.error('Failed to load cron jobs:', error)
124
  } finally {
125
  setIsLoading(false)
126
  }
127
+ }, [isLocalMode, setCronJobs])
128
 
129
  useEffect(() => {
130
  loadCronJobs()
 
148
  loadAvailableModels()
149
  }, [])
150
 
151
+ const loadJobLogs = async (job: CronJob) => {
152
+ const isLocalAutomation = (job.delivery === 'local' && job.agentId === 'mission-control-local')
153
+ if (isLocalAutomation) {
154
+ const logs: Array<{ timestamp: number; message: string; level: string }> = []
155
+ if (job.lastRun) {
156
+ logs.push({
157
+ timestamp: job.lastRun,
158
+ message: `Last run recorded for ${job.name}`,
159
+ level: job.lastStatus === 'error' ? 'error' : 'info',
160
+ })
161
+ }
162
+ if (job.lastError) {
163
+ logs.push({
164
+ timestamp: job.lastRun || Date.now(),
165
+ message: `Error: ${job.lastError}`,
166
+ level: 'error',
167
+ })
168
+ }
169
+ if (job.nextRun) {
170
+ logs.push({
171
+ timestamp: Date.now(),
172
+ message: `Next scheduled run: ${new Date(job.nextRun).toLocaleString()}`,
173
+ level: 'info',
174
+ })
175
+ }
176
+ if (logs.length === 0) {
177
+ logs.push({
178
+ timestamp: Date.now(),
179
+ message: 'No scheduler telemetry available yet for this local automation task',
180
+ level: 'info',
181
+ })
182
+ }
183
+ setJobLogs(logs)
184
+ return
185
+ }
186
+
187
  try {
188
+ const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(job.name)}`)
189
  const data = await response.json()
190
  setJobLogs(data.logs || [])
191
  } catch (error) {
 
219
  }
220
 
221
  const triggerJob = async (job: CronJob) => {
222
+ const isLocalAutomation = (job.delivery === 'local' && job.agentId === 'mission-control-local')
223
  try {
224
+ if (isLocalAutomation) {
225
+ const response = await fetch('/api/scheduler', {
226
+ method: 'POST',
227
+ headers: { 'Content-Type': 'application/json' },
228
+ body: JSON.stringify({ task_id: job.id }),
229
+ })
230
+ const result = await response.json()
231
+ if (response.ok && result.ok) {
232
+ alert(`Local automation executed: ${result.message}`)
233
+ } else {
234
+ alert(`Local automation failed: ${result.error || result.message || 'Unknown error'}`)
235
+ }
236
+ await loadCronJobs()
237
+ return
238
+ }
239
+
240
  const response = await fetch('/api/cron', {
241
  method: 'POST',
242
  headers: { 'Content-Type': 'application/json' },
 
331
 
332
  const handleJobSelect = (job: CronJob) => {
333
  setSelectedJob(job)
334
+ loadJobLogs(job)
335
  }
336
 
337
  const getStatusColor = (status?: string) => {
 
504
  <div className="flex flex-wrap items-center justify-between gap-3">
505
  <div>
506
  <h2 className="text-xl font-semibold">Calendar View</h2>
507
+ <p className="text-sm text-muted-foreground">
508
+ {isLocalMode
509
+ ? 'Read-only schedule visibility across local cron jobs and automations'
510
+ : 'Interactive schedule across all matching cron jobs'}
511
+ </p>
512
  </div>
513
  <div className="flex items-center gap-2">
514
  <button
 
729
  </div>
730
  ) : (
731
  <div className="space-y-3 max-h-96 overflow-y-auto">
732
+ {cronJobs.map((job, index) => {
733
+ const isLocalAutomation = job.delivery === 'local' && job.agentId === 'mission-control-local'
734
+ return (
735
+ <div
736
+ key={`${job.name}-${index}`}
737
+ className={`border border-border rounded-lg p-4 cursor-pointer transition-colors ${
738
  selectedJob?.name === job.name
739
  ? 'bg-primary/10 border-primary/30'
740
  : 'hover:bg-secondary'
 
749
 
750
  {/* Job Type Tag */}
751
  <span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${
752
+ isLocalAutomation ? 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30' :
753
  job.name.includes('backup') ? 'bg-green-500/20 text-green-400 border-green-500/30' :
754
  job.name.includes('alert') ? 'bg-orange-500/20 text-orange-400 border-orange-500/30' :
755
  job.name.includes('brief') ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
756
  job.name.includes('scan') ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' :
757
  'bg-muted-foreground/10 text-muted-foreground border-muted-foreground/20'
758
  }`}>
759
+ {isLocalAutomation ? 'LOCAL AUTO' :
760
+ job.name.includes('backup') ? 'BACKUP' :
761
  job.name.includes('alert') ? 'ALERT' :
762
  job.name.includes('brief') ? 'BRIEF' :
763
  job.name.includes('scan') ? 'SCAN' :
 
798
  e.stopPropagation()
799
  toggleJob(job)
800
  }}
801
+ disabled={isLocalAutomation}
802
  className={`px-2 py-1 text-xs rounded ${
803
  job.enabled
804
  ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
805
  : 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
806
+ } transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
807
  >
808
  {job.enabled ? 'Disable' : 'Enable'}
809
  </button>
 
821
  e.stopPropagation()
822
  removeJob(job)
823
  }}
824
+ disabled={isLocalAutomation}
825
  className="px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors"
826
  >
827
  Remove
 
829
  </div>
830
  </div>
831
  </div>
832
+ )})}
833
  </div>
834
  )}
835
  </div>
 
851
  <div><span className="text-muted-foreground">Model:</span> <code className="font-mono text-xs">{selectedJob.model}</code></div>
852
  )}
853
  <div><span className="text-muted-foreground">Status:</span> {selectedJob.enabled ? '🟢 Enabled' : '🔴 Disabled'}</div>
854
+ {selectedJob.delivery === 'local' && selectedJob.agentId === 'mission-control-local' && (
855
+ <div><span className="text-muted-foreground">Source:</span> Local scheduler automation</div>
856
+ )}
857
  {selectedJob.nextRun && (
858
  <div><span className="text-muted-foreground">Next run:</span> {new Date(selectedJob.nextRun).toLocaleString()}</div>
859
  )}
src/components/panels/office-panel.tsx CHANGED
@@ -1,15 +1,129 @@
1
  'use client'
2
 
3
- import { useState, useEffect, useCallback, useMemo } from 'react'
 
 
4
  import { useMissionControl, Agent } from '@/store'
 
5
 
6
  type ViewMode = 'office' | 'org-chart'
7
  type OrgSegmentMode = 'category' | 'role' | 'status'
8
 
9
- interface Desk {
10
- agent: Agent
11
- row: number
12
- col: number
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
 
15
  const statusGlow: Record<string, string> = {
@@ -61,6 +175,14 @@ function hashColor(name: string): string {
61
  return colors[Math.abs(hash) % colors.length]
62
  }
63
 
 
 
 
 
 
 
 
 
64
  function formatLastSeen(ts?: number): string {
65
  if (!ts) return 'Never seen'
66
  const diff = Date.now() - ts * 1000
@@ -72,59 +194,1255 @@ function formatLastSeen(ts?: number): string {
72
  return `${Math.floor(h / 24)}d ago`
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  export function OfficePanel() {
76
- const { agents } = useMissionControl()
 
77
  const [localAgents, setLocalAgents] = useState<Agent[]>([])
 
78
  const [viewMode, setViewMode] = useState<ViewMode>('office')
79
  const [orgSegmentMode, setOrgSegmentMode] = useState<OrgSegmentMode>('category')
80
  const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  const [loading, setLoading] = useState(true)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
  const fetchAgents = useCallback(async () => {
 
 
 
84
  try {
85
- const res = await fetch('/api/agents')
86
- if (res.ok) {
87
- const data = await res.json()
88
- setLocalAgents(data.agents || [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  }
90
  } catch { /* ignore */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  setLoading(false)
92
- }, [])
93
 
94
  useEffect(() => { fetchAgents() }, [fetchAgents])
95
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  useEffect(() => {
97
  const interval = setInterval(fetchAgents, 10000)
98
  return () => clearInterval(interval)
99
  }, [fetchAgents])
100
 
101
- const displayAgents = agents.length > 0 ? agents : localAgents
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
  const counts = useMemo(() => {
104
  const c = { idle: 0, busy: 0, error: 0, offline: 0 }
105
- for (const a of displayAgents) c[a.status] = (c[a.status] || 0) + 1
106
  return c
107
- }, [displayAgents])
108
-
109
- const desks: Desk[] = useMemo(() => {
110
- const cols = Math.max(2, Math.ceil(Math.sqrt(displayAgents.length)))
111
- return displayAgents.map((agent, i) => ({
112
- agent,
113
- row: Math.floor(i / cols),
114
- col: i % cols,
115
- }))
116
- }, [displayAgents])
117
 
118
  const roleGroups = useMemo(() => {
119
  const groups = new Map<string, Agent[]>()
120
- for (const a of displayAgents) {
121
  const role = a.role || 'Unassigned'
122
  if (!groups.has(role)) groups.set(role, [])
123
  groups.get(role)!.push(a)
124
  }
125
  return groups
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  }, [displayAgents])
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  const categoryGroups = useMemo(() => {
129
  const groups = new Map<string, Agent[]>()
130
  const getCategory = (agent: Agent): string => {
@@ -137,7 +1455,7 @@ export function OfficePanel() {
137
  return 'Other'
138
  }
139
 
140
- for (const a of displayAgents) {
141
  const category = getCategory(a)
142
  if (!groups.has(category)) groups.set(category, [])
143
  groups.get(category)!.push(a)
@@ -154,11 +1472,11 @@ export function OfficePanel() {
154
  return a.localeCompare(b)
155
  })
156
  )
157
- }, [displayAgents])
158
 
159
  const statusGroups = useMemo(() => {
160
  const groups = new Map<string, Agent[]>()
161
- for (const a of displayAgents) {
162
  const key = statusLabel[a.status] || a.status
163
  if (!groups.has(key)) groups.set(key, [])
164
  groups.get(key)!.push(a)
@@ -175,7 +1493,7 @@ export function OfficePanel() {
175
  return a.localeCompare(b)
176
  })
177
  )
178
- }, [displayAgents])
179
 
180
  const orgGroups = useMemo(() => {
181
  if (orgSegmentMode === 'role') return roleGroups
@@ -183,11 +1501,13 @@ export function OfficePanel() {
183
  return categoryGroups
184
  }, [categoryGroups, orgSegmentMode, roleGroups, statusGroups])
185
 
186
- if (loading && displayAgents.length === 0) {
187
  return (
188
  <div className="flex items-center justify-center h-64">
189
  <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
190
- <span className="ml-3 text-muted-foreground">Loading office...</span>
 
 
191
  </div>
192
  )
193
  }
@@ -228,71 +1548,578 @@ export function OfficePanel() {
228
  </div>
229
  </div>
230
 
231
- {displayAgents.length === 0 ? (
232
  <div className="text-center py-16 text-muted-foreground">
233
  <div className="text-5xl mb-3">🏢</div>
234
  <p className="text-lg">The office is empty</p>
235
  <p className="text-sm mt-1">Add agents to see them appear here</p>
236
  </div>
237
  ) : viewMode === 'office' ? (
238
- <div className="relative">
239
- <div className="bg-gradient-to-br from-surface-1/50 to-card rounded-xl border border-border p-6 min-h-[400px]">
240
- <div className="absolute top-4 left-6 text-xs text-muted-foreground/50 uppercase tracking-widest font-medium">Main Floor</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
- <div className="mt-6 grid gap-6" style={{ gridTemplateColumns: `repeat(${Math.max(2, Math.ceil(Math.sqrt(displayAgents.length)))}, minmax(180px, 1fr))` }}>
243
- {desks.map(({ agent }) => (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  <div
245
- key={agent.id}
246
- onClick={() => setSelectedAgent(agent)}
247
- 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]}`}
248
- style={{ background: 'var(--card)' }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  >
250
- <div className="absolute inset-x-3 bottom-0 h-1.5 bg-amber-900/20 rounded-t-sm" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
- <div className="absolute -top-2 -right-2 text-lg" title={statusLabel[agent.status]}>
253
- {statusEmoji[agent.status]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  </div>
 
 
255
 
256
- <div className="flex items-center gap-3 mb-3">
257
- <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'}`}>
258
- {getInitials(agent.name)}
259
- </div>
260
- <div className="min-w-0">
261
- <div className="font-semibold text-foreground text-sm truncate">{agent.name}</div>
262
- <div className="text-xs text-muted-foreground truncate">{agent.role}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  </div>
264
  </div>
265
 
266
- <div className="flex items-center justify-between text-xs">
267
- <span className="flex items-center gap-1">
268
- <span className={`w-1.5 h-1.5 rounded-full ${statusDot[agent.status]} ${agent.status === 'busy' ? 'animate-pulse' : ''}`} />
269
- <span className="text-muted-foreground">{statusLabel[agent.status]}</span>
270
- </span>
271
- <span className="text-muted-foreground/60">{formatLastSeen(agent.last_seen)}</span>
272
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
- {agent.last_activity && (
275
- <div className="mt-2 text-[10px] text-muted-foreground/50 truncate italic">
276
- {agent.last_activity}
 
 
 
277
  </div>
278
  )}
279
 
280
- {agent.taskStats && agent.taskStats.in_progress > 0 && (
281
- <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">
282
- {agent.taskStats.in_progress}
 
 
 
283
  </div>
284
  )}
 
 
 
 
 
 
 
285
  </div>
286
  ))}
287
  </div>
288
 
289
- <div className="mt-8 flex items-center gap-4 text-[10px] text-muted-foreground/30">
290
- <span>🪴</span>
291
- <div className="flex-1 border-t border-dashed border-border/30" />
292
- <span>☕ Break room</span>
293
- <div className="flex-1 border-t border-dashed border-border/30" />
294
- <span>🪴</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  </div>
 
296
  </div>
297
  </div>
298
  ) : (
@@ -415,10 +2242,139 @@ export function OfficePanel() {
415
  <span className="font-medium">Session:</span> <code className="font-mono">{selectedAgent.session_key}</code>
416
  </div>
417
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  </div>
419
  </div>
420
  </div>
421
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  </div>
423
  )
424
  }
 
1
  'use client'
2
 
3
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
4
+ import type { MouseEvent, WheelEvent } from 'react'
5
+ import Image from 'next/image'
6
  import { useMissionControl, Agent } from '@/store'
7
+ import { buildOfficeLayout } from '@/lib/office-layout'
8
 
9
  type ViewMode = 'office' | 'org-chart'
10
  type OrgSegmentMode = 'category' | 'role' | 'status'
11
 
12
+ interface SessionAgentRow {
13
+ id: string
14
+ key: string
15
+ agent: string
16
+ kind: string
17
+ model: string
18
+ active: boolean
19
+ lastActivity?: number
20
+ workingDir?: string | null
21
+ }
22
+
23
+ interface SeatPosition {
24
+ seatKey: string
25
+ x: number
26
+ y: number
27
+ }
28
+
29
+ interface MovingWorker {
30
+ id: string
31
+ agentId: number
32
+ initials: string
33
+ colorClass: string
34
+ startX: number
35
+ startY: number
36
+ endX: number
37
+ endY: number
38
+ startedAt: number
39
+ durationMs: number
40
+ progress: number
41
+ path: Array<{ x: number; y: number }>
42
+ pathLengths: number[]
43
+ totalLength: number
44
+ destinationTile: string
45
+ }
46
+
47
+ type SidebarFilter = 'all' | 'working' | 'idle' | 'attention'
48
+
49
+ interface MapRoom {
50
+ id: string
51
+ label: string
52
+ x: number
53
+ y: number
54
+ w: number
55
+ h: number
56
+ style: string
57
+ }
58
+
59
+ interface MapProp {
60
+ id: string
61
+ x: number
62
+ y: number
63
+ w: number
64
+ h: number
65
+ style: string
66
+ border: string
67
+ }
68
+
69
+ interface LaunchToast {
70
+ kind: 'success' | 'info' | 'error'
71
+ title: string
72
+ detail: string
73
+ }
74
+
75
+ type OfficeAction = 'focus' | 'pair' | 'break'
76
+ type TimeTheme = 'dawn' | 'day' | 'dusk' | 'night'
77
+
78
+ type HotspotKind = 'room' | 'desk'
79
+
80
+ interface OfficeHotspot {
81
+ kind: HotspotKind
82
+ id: string
83
+ label: string
84
+ x: number
85
+ y: number
86
+ stats: string[]
87
+ }
88
+
89
+ interface OfficeEvent {
90
+ id: string
91
+ kind: 'action' | 'room' | 'desk'
92
+ message: string
93
+ at: number
94
+ severity: 'info' | 'warn' | 'good'
95
+ }
96
+
97
+ interface ThemePalette {
98
+ shell: string
99
+ gridLine: string
100
+ haze: string
101
+ glow: string
102
+ corridor: string
103
+ corridorStripe: string
104
+ atmosphere: string
105
+ shadowVeil: string
106
+ floorFilter: string
107
+ spriteFilter: string
108
+ roomTone: string
109
+ floorOpacityA: number
110
+ floorOpacityB: number
111
+ accentGlow: string
112
+ }
113
+
114
+ interface PersistedOfficePrefs {
115
+ version: 1
116
+ viewMode: ViewMode
117
+ sidebarFilter: SidebarFilter
118
+ localSessionFilter?: 'running' | 'not-running'
119
+ mapZoom: number
120
+ mapPan: { x: number; y: number }
121
+ timeTheme: TimeTheme
122
+ showSidebar: boolean
123
+ showMinimap: boolean
124
+ showEvents: boolean
125
+ roomLayout: MapRoom[]
126
+ mapProps: MapProp[]
127
  }
128
 
129
  const statusGlow: Record<string, string> = {
 
175
  return colors[Math.abs(hash) % colors.length]
176
  }
177
 
178
+ function hashNumber(value: string): number {
179
+ let hash = 0
180
+ for (let i = 0; i < value.length; i += 1) {
181
+ hash = value.charCodeAt(i) + ((hash << 5) - hash)
182
+ }
183
+ return Math.abs(hash)
184
+ }
185
+
186
  function formatLastSeen(ts?: number): string {
187
  if (!ts) return 'Never seen'
188
  const diff = Date.now() - ts * 1000
 
194
  return `${Math.floor(h / 24)}d ago`
195
  }
196
 
197
+ function easeInOut(progress: number): number {
198
+ if (progress <= 0) return 0
199
+ if (progress >= 1) return 1
200
+ return progress < 0.5
201
+ ? 2 * progress * progress
202
+ : 1 - Math.pow(-2 * progress + 2, 2) / 2
203
+ }
204
+
205
+ function getStatusEmote(status: Agent['status']): string {
206
+ if (status === 'busy') return '💼'
207
+ if (status === 'idle') return '☕'
208
+ if (status === 'error') return '⚠️'
209
+ return '💤'
210
+ }
211
+
212
+ function inferLocalRole(row: SessionAgentRow): string {
213
+ const context = [
214
+ String(row.agent || ''),
215
+ String(row.key || ''),
216
+ String(row.workingDir || ''),
217
+ String(row.kind || ''),
218
+ ].join(' ').toLowerCase()
219
+
220
+ if (/frontend|ui|ux|design|landing|web/.test(context)) return 'frontend-engineer'
221
+ if (/backend|api|server|platform|infra|ops|sre|deploy|k8s|docker/.test(context)) return 'ops-engineer'
222
+ if (/research|science|ml|ai|llm|data|analytics/.test(context)) return 'research-analyst'
223
+ if (/qa|test|e2e|spec|validation/.test(context)) return 'qa-engineer'
224
+ if (/product|pm|roadmap|strategy/.test(context)) return 'product-manager'
225
+ if (/codex|claude|agent/.test(context)) return 'software-engineer'
226
+ return row.kind || 'local-session'
227
+ }
228
+
229
+ function isInactiveLocalSession(agent: Agent): boolean {
230
+ return Boolean((agent.config as any)?.localSession) && agent.status !== 'busy'
231
+ }
232
+
233
+ const MAP_COLS = 24
234
+ const MAP_ROWS = 16
235
+
236
+ const ROOM_LAYOUT: MapRoom[] = [
237
+ { id: 'eng', label: 'Engineering', x: 16, y: 22, w: 28, h: 22, style: 'bg-[#2a3558]' },
238
+ { id: 'product', label: 'Product', x: 48, y: 22, w: 24, h: 22, style: 'bg-[#213a4d]' },
239
+ { id: 'ops', label: 'Operations', x: 16, y: 49, w: 24, h: 24, style: 'bg-[#2f2f52]' },
240
+ { id: 'research', label: 'Research', x: 44, y: 49, w: 22, h: 24, style: 'bg-[#2b334c]' },
241
+ { id: 'lounge', label: 'Lounge', x: 70, y: 49, w: 16, h: 24, style: 'bg-[#2e4438]' },
242
+ ]
243
+
244
+ const MAP_PROPS: MapProp[] = [
245
+ { id: 'desk-a', x: 22, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
246
+ { id: 'desk-b', x: 33, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
247
+ { id: 'desk-c', x: 52, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
248
+ { id: 'desk-d', x: 61, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
249
+ { id: 'desk-e', x: 22, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
250
+ { id: 'desk-f', x: 31, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
251
+ { id: 'desk-g', x: 48, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
252
+ { id: 'desk-h', x: 57, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
253
+ { id: 'plant-l', x: 14, y: 47, w: 3, h: 5, style: 'bg-emerald-400/60', border: 'border-emerald-200/35' },
254
+ { id: 'plant-r', x: 84, y: 47, w: 3, h: 5, style: 'bg-emerald-400/60', border: 'border-emerald-200/35' },
255
+ { id: 'kitchen', x: 72, y: 57, w: 12, h: 10, style: 'bg-[#334137]', border: 'border-[#88d4a3]/35' },
256
+ ]
257
+
258
+ const LOUNGE_WAYPOINTS = [
259
+ { x: 74, y: 60 },
260
+ { x: 79, y: 60 },
261
+ { x: 82, y: 66 },
262
+ { x: 76, y: 68 },
263
+ ]
264
+
265
+ function getPropSprite(propId: string): string {
266
+ if (propId === 'desk-a' || propId === 'desk-b' || propId === 'desk-e' || propId === 'desk-f') return '/office-sprites/kenney/desk.png'
267
+ if (propId.startsWith('desk-')) return '/office-sprites/kenney/tableCross.png'
268
+ if (propId === 'plant-l') return '/office-sprites/kenney/plantSmall1.png'
269
+ if (propId === 'plant-r') return '/office-sprites/kenney/plantSmall2.png'
270
+ if (propId === 'kitchen') return '/office-sprites/kenney/rugRectangle.png'
271
+ return ''
272
+ }
273
+
274
+ const HERO_SHEET_COLS = 6
275
+ const HERO_SHEET_ROWS = 7
276
+
277
+ function getWorkerHeroFrame(status: Agent['status'], isMoving: boolean, frame: number) {
278
+ const phase = frame % 2
279
+ const walkCol = phase === 0 ? 1 : 3
280
+ if (isMoving) return { col: walkCol, row: 3 } // side-walk row
281
+ if (status === 'busy') return { col: walkCol, row: 0 } // forward loop as typing proxy
282
+ if (status === 'error') return { col: 5, row: 6 }
283
+ return { col: phase === 0 ? 0 : 5, row: 0 } // idle pulse
284
+ }
285
+
286
+ interface WorkerVariant {
287
+ id: string
288
+ filter: string
289
+ accent: string
290
+ }
291
+
292
+ const WORKER_VARIANTS: WorkerVariant[] = [
293
+ { id: 'default', filter: 'none', accent: 'border-cyan-300/60' },
294
+ { id: 'warm', filter: 'hue-rotate(18deg) saturate(1.08)', accent: 'border-amber-300/60' },
295
+ { id: 'cool', filter: 'hue-rotate(-20deg) saturate(1.1)', accent: 'border-sky-300/60' },
296
+ { id: 'mint', filter: 'hue-rotate(42deg) saturate(1.08)', accent: 'border-emerald-300/60' },
297
+ { id: 'violet', filter: 'hue-rotate(64deg) saturate(1.12)', accent: 'border-violet-300/60' },
298
+ ]
299
+
300
+ function getWorkerVariant(name: string): WorkerVariant {
301
+ return WORKER_VARIANTS[hashNumber(name) % WORKER_VARIANTS.length]
302
+ }
303
+
304
+ function clamp(value: number, min: number, max: number) {
305
+ return Math.max(min, Math.min(max, value))
306
+ }
307
+
308
+ function toTile(xPercent: number, yPercent: number) {
309
+ const col = clamp(Math.round((xPercent / 100) * (MAP_COLS - 1)), 0, MAP_COLS - 1)
310
+ const row = clamp(Math.round((yPercent / 100) * (MAP_ROWS - 1)), 0, MAP_ROWS - 1)
311
+ return { col, row }
312
+ }
313
+
314
+ function tileToPercent(col: number, row: number) {
315
+ const x = (col / (MAP_COLS - 1)) * 100
316
+ const y = (row / (MAP_ROWS - 1)) * 100
317
+ return { x, y }
318
+ }
319
+
320
+ function buildWalkabilityGrid() {
321
+ const walkable: boolean[][] = Array.from({ length: MAP_ROWS }, () => Array.from({ length: MAP_COLS }, () => true))
322
+ // Border walls
323
+ for (let r = 0; r < MAP_ROWS; r += 1) {
324
+ walkable[r][0] = false
325
+ walkable[r][MAP_COLS - 1] = false
326
+ }
327
+ for (let c = 0; c < MAP_COLS; c += 1) {
328
+ walkable[0][c] = false
329
+ walkable[MAP_ROWS - 1][c] = false
330
+ }
331
+
332
+ // Block static furniture/obstacles so routes prefer corridor lanes.
333
+ const obstacleRects = [
334
+ { c1: 5, c2: 8, r1: 4, r2: 5 },
335
+ { c1: 9, c2: 12, r1: 4, r2: 5 },
336
+ { c1: 13, c2: 16, r1: 4, r2: 5 },
337
+ { c1: 17, c2: 20, r1: 4, r2: 5 },
338
+ { c1: 5, c2: 8, r1: 9, r2: 10 },
339
+ { c1: 9, c2: 12, r1: 9, r2: 10 },
340
+ { c1: 13, c2: 16, r1: 9, r2: 10 },
341
+ { c1: 17, c2: 20, r1: 9, r2: 10 },
342
+ { c1: 18, c2: 21, r1: 10, r2: 13 },
343
+ ]
344
+ for (const rect of obstacleRects) {
345
+ for (let r = rect.r1; r <= rect.r2; r += 1) {
346
+ for (let c = rect.c1; c <= rect.c2; c += 1) {
347
+ if (r >= 0 && r < MAP_ROWS && c >= 0 && c < MAP_COLS) walkable[r][c] = false
348
+ }
349
+ }
350
+ }
351
+
352
+ // Keep a central horizontal corridor open.
353
+ const corridorRow = 7
354
+ for (let c = 1; c < MAP_COLS - 1; c += 1) walkable[corridorRow][c] = true
355
+ return walkable
356
+ }
357
+
358
+ function tileKey(col: number, row: number): string {
359
+ return `${col},${row}`
360
+ }
361
+
362
+ function findGridPath(start: { col: number; row: number }, end: { col: number; row: number }, walkable: boolean[][]) {
363
+ const inBounds = (col: number, row: number) => row >= 0 && row < MAP_ROWS && col >= 0 && col < MAP_COLS
364
+ const key = (col: number, row: number) => `${col},${row}`
365
+ const parse = (k: string) => {
366
+ const [c, r] = k.split(',').map(Number)
367
+ return { col: c, row: r }
368
+ }
369
+
370
+ const open = new Set<string>([key(start.col, start.row)])
371
+ const cameFrom = new Map<string, string>()
372
+ const gScore = new Map<string, number>([[key(start.col, start.row), 0]])
373
+ const fScore = new Map<string, number>([[key(start.col, start.row), Math.abs(start.col - end.col) + Math.abs(start.row - end.row)]])
374
+
375
+ while (open.size > 0) {
376
+ let currentKey = ''
377
+ let lowest = Number.POSITIVE_INFINITY
378
+ for (const k of open) {
379
+ const f = fScore.get(k) ?? Number.POSITIVE_INFINITY
380
+ if (f < lowest) {
381
+ lowest = f
382
+ currentKey = k
383
+ }
384
+ }
385
+ if (!currentKey) break
386
+
387
+ const current = parse(currentKey)
388
+ if (current.col === end.col && current.row === end.row) {
389
+ const path = [current]
390
+ let ck = currentKey
391
+ while (cameFrom.has(ck)) {
392
+ ck = cameFrom.get(ck)!
393
+ path.push(parse(ck))
394
+ }
395
+ path.reverse()
396
+ return path
397
+ }
398
+
399
+ open.delete(currentKey)
400
+ const neighbors = [
401
+ { col: current.col + 1, row: current.row },
402
+ { col: current.col - 1, row: current.row },
403
+ { col: current.col, row: current.row + 1 },
404
+ { col: current.col, row: current.row - 1 },
405
+ ]
406
+
407
+ for (const n of neighbors) {
408
+ if (!inBounds(n.col, n.row)) continue
409
+ if (!walkable[n.row][n.col]) continue
410
+ const nk = key(n.col, n.row)
411
+ const tentative = (gScore.get(currentKey) ?? Number.POSITIVE_INFINITY) + 1
412
+ if (tentative >= (gScore.get(nk) ?? Number.POSITIVE_INFINITY)) continue
413
+ cameFrom.set(nk, currentKey)
414
+ gScore.set(nk, tentative)
415
+ fScore.set(nk, tentative + Math.abs(n.col - end.col) + Math.abs(n.row - end.row))
416
+ open.add(nk)
417
+ }
418
+ }
419
+
420
+ return [start, end]
421
+ }
422
+
423
+ function buildPath(startX: number, startY: number, endX: number, endY: number, blockedTiles: Set<string> = new Set()) {
424
+ const walkable = buildWalkabilityGrid()
425
+ const startTile = toTile(startX, startY)
426
+ const endTile = toTile(endX, endY)
427
+ for (const tile of blockedTiles) {
428
+ const [col, row] = tile.split(',').map(Number)
429
+ if (!Number.isFinite(col) || !Number.isFinite(row)) continue
430
+ if (row < 0 || row >= MAP_ROWS || col < 0 || col >= MAP_COLS) continue
431
+ walkable[row][col] = false
432
+ }
433
+ // Start/end must always be traversable.
434
+ walkable[startTile.row][startTile.col] = true
435
+ walkable[endTile.row][endTile.col] = true
436
+ const tilePath = findGridPath(startTile, endTile, walkable)
437
+ const path = tilePath.map((tile) => tileToPercent(tile.col, tile.row))
438
+ const pathLengths: number[] = [0]
439
+ let totalLength = 0
440
+ for (let i = 1; i < path.length; i += 1) {
441
+ const dx = path[i].x - path[i - 1].x
442
+ const dy = path[i].y - path[i - 1].y
443
+ totalLength += Math.hypot(dx, dy)
444
+ pathLengths.push(totalLength)
445
+ }
446
+ return { path, pathLengths, totalLength }
447
+ }
448
+
449
+ function pointAlongPath(path: Array<{ x: number; y: number }>, pathLengths: number[], totalLength: number, progress: number) {
450
+ if (path.length === 0) return { x: 0, y: 0 }
451
+ if (path.length === 1 || totalLength <= 0) return path[path.length - 1]
452
+ const target = totalLength * clamp(progress, 0, 1)
453
+ let idx = 1
454
+ while (idx < pathLengths.length && pathLengths[idx] < target) idx += 1
455
+ const prevIdx = Math.max(0, idx - 1)
456
+ const prevLen = pathLengths[prevIdx] ?? 0
457
+ const nextLen = pathLengths[Math.min(idx, pathLengths.length - 1)] ?? totalLength
458
+ const local = nextLen > prevLen ? (target - prevLen) / (nextLen - prevLen) : 0
459
+ const a = path[prevIdx]
460
+ const b = path[Math.min(idx, path.length - 1)]
461
+ return {
462
+ x: a.x + (b.x - a.x) * local,
463
+ y: a.y + (b.y - a.y) * local,
464
+ }
465
+ }
466
+
467
  export function OfficePanel() {
468
+ const { agents, dashboardMode, currentUser } = useMissionControl()
469
+ const isLocalMode = dashboardMode === 'local'
470
  const [localAgents, setLocalAgents] = useState<Agent[]>([])
471
+ const [sessionAgents, setSessionAgents] = useState<Agent[]>([])
472
  const [viewMode, setViewMode] = useState<ViewMode>('office')
473
  const [orgSegmentMode, setOrgSegmentMode] = useState<OrgSegmentMode>('category')
474
  const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
475
+ const [showFlightDeckModal, setShowFlightDeckModal] = useState(false)
476
+ const [flightDeckDownloadUrl, setFlightDeckDownloadUrl] = useState('https://flightdeck.example.com/download')
477
+ const [flightDeckLaunching, setFlightDeckLaunching] = useState(false)
478
+ const [launchToast, setLaunchToast] = useState<LaunchToast | null>(null)
479
+ const [selectedHotspot, setSelectedHotspot] = useState<OfficeHotspot | null>(null)
480
+ const [agentActionOverrides, setAgentActionOverrides] = useState<Map<number, OfficeAction>>(new Map())
481
+ const [officeEvents, setOfficeEvents] = useState<OfficeEvent[]>([])
482
+ const [roomLayoutState, setRoomLayoutState] = useState<MapRoom[]>(() => ROOM_LAYOUT.map((room) => ({ ...room })))
483
+ const [mapPropsState, setMapPropsState] = useState<MapProp[]>(() => MAP_PROPS.map((prop) => ({ ...prop })))
484
+ const [showSidebar, setShowSidebar] = useState(true)
485
+ const [showMinimap, setShowMinimap] = useState(true)
486
+ const [showEvents, setShowEvents] = useState(true)
487
+ const [localSessionFilter, setLocalSessionFilter] = useState<'running' | 'not-running'>('running')
488
  const [loading, setLoading] = useState(true)
489
+ const [localBootstrapping, setLocalBootstrapping] = useState(isLocalMode)
490
+ const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>('all')
491
+ const [spriteFrame, setSpriteFrame] = useState(0)
492
+ const [timeTheme, setTimeTheme] = useState<TimeTheme>('night')
493
+ const [mapZoom, setMapZoom] = useState(1)
494
+ const [mapPan, setMapPan] = useState({ x: 0, y: 0 })
495
+ const mapViewportRef = useRef<HTMLDivElement | null>(null)
496
+ const localBootstrapRetries = useRef(0)
497
+ const mapDragActiveRef = useRef(false)
498
+ const mapDragOriginRef = useRef({ x: 0, y: 0 })
499
+ const mapPanStartRef = useRef({ x: 0, y: 0 })
500
+ const prevStatusRef = useRef<Map<number, Agent['status']>>(new Map())
501
+ const transitionTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
502
+ const launchToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
503
+ const roamReturnTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
504
+ const movingAgentIdsRef = useRef<Set<number>>(new Set())
505
+ const movingWorkersRef = useRef<MovingWorker[]>([])
506
+ const renderedWorkersRef = useRef<Array<{ agent: Agent; x: number; y: number; zoneLabel: string; seatLabel: string; isMoving: boolean; direction: { dx: number; dy: number }; variant: WorkerVariant }>>([])
507
+ const [transitioningAgentIds, setTransitioningAgentIds] = useState<Set<number>>(new Set())
508
+ const previousSeatMapRef = useRef<Map<number, SeatPosition>>(new Map())
509
+ const [movingWorkers, setMovingWorkers] = useState<MovingWorker[]>([])
510
 
511
  const fetchAgents = useCallback(async () => {
512
+ let nextLocalAgents: Agent[] = []
513
+ let nextSessionAgents: Agent[] = []
514
+
515
  try {
516
+ const [agentRes, sessionRes] = await Promise.all([
517
+ fetch('/api/agents'),
518
+ isLocalMode ? fetch('/api/sessions') : Promise.resolve(null),
519
+ ])
520
+
521
+ if (agentRes.ok) {
522
+ const data = await agentRes.json()
523
+ nextLocalAgents = Array.isArray(data.agents) ? data.agents : []
524
+ setLocalAgents(nextLocalAgents)
525
+ }
526
+
527
+ if (isLocalMode && sessionRes?.ok) {
528
+ const sessionJson = await sessionRes.json().catch(() => ({}))
529
+ const rows = Array.isArray(sessionJson?.sessions) ? sessionJson.sessions as SessionAgentRow[] : []
530
+ const byAgent = new Map<string, Agent>()
531
+ let idx = 0
532
+
533
+ for (const row of rows) {
534
+ const name = String(row.agent || '').trim()
535
+ if (!name) continue
536
+ const existing = byAgent.get(name)
537
+ const nowSec = Math.floor(Date.now() / 1000)
538
+ const lastSeenSec = row.lastActivity ? Math.floor(row.lastActivity / 1000) : nowSec
539
+ const inferredRole = inferLocalRole(row)
540
+ const candidate: Agent = {
541
+ id: -5000 - idx,
542
+ name,
543
+ role: inferredRole,
544
+ status: row.active ? 'busy' : 'idle',
545
+ last_seen: lastSeenSec,
546
+ last_activity: `${row.kind || 'session'} · ${row.model || 'unknown model'}`,
547
+ session_key: row.key || row.id,
548
+ created_at: nowSec,
549
+ updated_at: nowSec,
550
+ config: {
551
+ localSession: {
552
+ sessionId: row.id,
553
+ key: row.key,
554
+ workingDir: row.workingDir || null,
555
+ kind: row.kind || 'session',
556
+ },
557
+ },
558
+ }
559
+
560
+ const existingLastSeen = existing?.last_seen || 0
561
+ const candidateLastSeen = candidate.last_seen || 0
562
+ const shouldReplace =
563
+ !existing ||
564
+ (existing.status !== 'busy' && candidate.status === 'busy') ||
565
+ (existing.status === candidate.status && candidateLastSeen > existingLastSeen)
566
+
567
+ if (shouldReplace) {
568
+ byAgent.set(name, candidate)
569
+ idx += 1
570
+ }
571
+ }
572
+
573
+ nextSessionAgents = Array.from(byAgent.values())
574
+ setSessionAgents(nextSessionAgents)
575
  }
576
  } catch { /* ignore */ }
577
+
578
+ if (isLocalMode) {
579
+ const hasAnyAgents = nextLocalAgents.length > 0 || nextSessionAgents.length > 0
580
+ if (hasAnyAgents) setLocalBootstrapping(false)
581
+ if (!hasAnyAgents && localBootstrapRetries.current < 5) {
582
+ localBootstrapRetries.current += 1
583
+ setLoading(true)
584
+ setTimeout(() => {
585
+ void fetchAgents()
586
+ }, 700)
587
+ return
588
+ }
589
+ }
590
+
591
  setLoading(false)
592
+ }, [isLocalMode])
593
 
594
  useEffect(() => { fetchAgents() }, [fetchAgents])
595
 
596
+ useEffect(() => {
597
+ if (!isLocalMode) {
598
+ setLocalBootstrapping(false)
599
+ return
600
+ }
601
+ setLocalBootstrapping(true)
602
+ const bootstrapTimer = setTimeout(() => {
603
+ setLocalBootstrapping(false)
604
+ }, 4500)
605
+ return () => clearTimeout(bootstrapTimer)
606
+ }, [isLocalMode])
607
+
608
  useEffect(() => {
609
  const interval = setInterval(fetchAgents, 10000)
610
  return () => clearInterval(interval)
611
  }, [fetchAgents])
612
 
613
+ useEffect(() => {
614
+ const interval = setInterval(() => {
615
+ setSpriteFrame((current) => (current + 1) % 2)
616
+ }, 380)
617
+ return () => clearInterval(interval)
618
+ }, [])
619
+
620
+ const displayAgents = useMemo(() => {
621
+ if (agents.length > 0) return agents
622
+ if (isLocalMode) {
623
+ const merged = new Map<string, Agent>()
624
+ for (const agent of [...sessionAgents, ...localAgents]) {
625
+ const key = String(agent.name || '').trim().toLowerCase()
626
+ if (!key) continue
627
+ const existing = merged.get(key)
628
+ if (!existing) {
629
+ merged.set(key, agent)
630
+ continue
631
+ }
632
+ const existingLastSeen = existing.last_seen || 0
633
+ const candidateLastSeen = agent.last_seen || 0
634
+ const shouldReplace =
635
+ (existing.status !== 'busy' && agent.status === 'busy') ||
636
+ (existing.status === agent.status && candidateLastSeen > existingLastSeen)
637
+ if (shouldReplace) merged.set(key, agent)
638
+ }
639
+ return Array.from(merged.values())
640
+ }
641
+ if (localAgents.length > 0) return localAgents
642
+ return []
643
+ }, [agents, isLocalMode, localAgents, sessionAgents])
644
+
645
+ const visibleDisplayAgents = useMemo(() => {
646
+ if (!isLocalMode) return displayAgents
647
+ if (localSessionFilter === 'not-running') {
648
+ return displayAgents.filter((agent) => isInactiveLocalSession(agent))
649
+ }
650
+ return displayAgents.filter((agent) => !isInactiveLocalSession(agent))
651
+ }, [displayAgents, isLocalMode, localSessionFilter])
652
 
653
  const counts = useMemo(() => {
654
  const c = { idle: 0, busy: 0, error: 0, offline: 0 }
655
+ for (const a of visibleDisplayAgents) c[a.status] = (c[a.status] || 0) + 1
656
  return c
657
+ }, [visibleDisplayAgents])
 
 
 
 
 
 
 
 
 
658
 
659
  const roleGroups = useMemo(() => {
660
  const groups = new Map<string, Agent[]>()
661
+ for (const a of visibleDisplayAgents) {
662
  const role = a.role || 'Unassigned'
663
  if (!groups.has(role)) groups.set(role, [])
664
  groups.get(role)!.push(a)
665
  }
666
  return groups
667
+ }, [visibleDisplayAgents])
668
+
669
+ const officeLayout = useMemo(() => buildOfficeLayout(visibleDisplayAgents), [visibleDisplayAgents])
670
+
671
+ const currentSeatMap = useMemo(() => {
672
+ const seatMap = new Map<number, SeatPosition>()
673
+ const zoneSeatTemplates: Record<string, Array<{ x: number; y: number }>> = {
674
+ engineering: [{ x: 24, y: 36 }, { x: 32, y: 36 }, { x: 24, y: 42 }, { x: 32, y: 42 }],
675
+ product: [{ x: 54, y: 36 }, { x: 62, y: 36 }, { x: 54, y: 42 }, { x: 62, y: 42 }],
676
+ operations: [{ x: 24, y: 64 }, { x: 32, y: 64 }, { x: 24, y: 70 }, { x: 32, y: 70 }],
677
+ research: [{ x: 50, y: 64 }, { x: 58, y: 64 }, { x: 50, y: 70 }, { x: 58, y: 70 }],
678
+ quality: [{ x: 58, y: 64 }, { x: 66, y: 64 }, { x: 58, y: 70 }, { x: 66, y: 70 }],
679
+ general: [{ x: 38, y: 45 }, { x: 46, y: 39 }, { x: 54, y: 45 }, { x: 62, y: 39 }, { x: 42, y: 52 }, { x: 58, y: 52 }],
680
+ }
681
+ const fallbackByZone: Record<string, string[]> = {
682
+ engineering: ['operations', 'general'],
683
+ product: ['research', 'general'],
684
+ operations: ['engineering', 'general'],
685
+ research: ['product', 'general'],
686
+ quality: ['research', 'general'],
687
+ general: ['general'],
688
+ }
689
+
690
+ const usageByZone = new Map<string, number>()
691
+ const pullSeat = (zoneId: string) => {
692
+ const templates = zoneSeatTemplates[zoneId] || zoneSeatTemplates.general
693
+ const used = usageByZone.get(zoneId) || 0
694
+ const chosen = templates[used % templates.length] || { x: 38, y: 47 }
695
+ const overflowBand = Math.floor(used / templates.length)
696
+ usageByZone.set(zoneId, used + 1)
697
+ return {
698
+ x: chosen.x,
699
+ y: chosen.y + overflowBand * 3.5,
700
+ }
701
+ }
702
+
703
+ for (let zoneIndex = 0; zoneIndex < officeLayout.length; zoneIndex += 1) {
704
+ const zone = officeLayout[zoneIndex].zone
705
+ const sortedWorkers = [...officeLayout[zoneIndex].workers].sort((a, b) => a.agent.name.localeCompare(b.agent.name))
706
+
707
+ for (const worker of sortedWorkers) {
708
+ const primaryTemplates = zoneSeatTemplates[zone.id] || zoneSeatTemplates.general
709
+ const primaryUsed = usageByZone.get(zone.id) || 0
710
+ const inPrimaryCapacity = primaryUsed < primaryTemplates.length * 2
711
+ const targetZone = inPrimaryCapacity ? zone.id : (fallbackByZone[zone.id] || ['general'])[0]
712
+ const seat = pullSeat(targetZone)
713
+ const x = clamp(seat.x, 8, 92)
714
+ const y = clamp(seat.y, 12, 92)
715
+ seatMap.set(worker.agent.id, {
716
+ seatKey: `${targetZone}:${worker.anchor.seatLabel}`,
717
+ x,
718
+ y,
719
+ })
720
+ }
721
+ }
722
+ return seatMap
723
+ }, [officeLayout])
724
+
725
+ const gameWorkers = useMemo(() => {
726
+ const workers: Array<{ agent: Agent; x: number; y: number; zoneLabel: string; seatLabel: string }> = []
727
+ for (let zoneIndex = 0; zoneIndex < officeLayout.length; zoneIndex += 1) {
728
+ const zone = officeLayout[zoneIndex]
729
+ for (const worker of zone.workers) {
730
+ const seat = currentSeatMap.get(worker.agent.id)
731
+ if (!seat) continue
732
+ workers.push({
733
+ agent: worker.agent,
734
+ x: seat.x,
735
+ y: seat.y,
736
+ zoneLabel: zone.zone.label,
737
+ seatLabel: worker.anchor.seatLabel,
738
+ })
739
+ }
740
+ }
741
+ return workers
742
+ }, [currentSeatMap, officeLayout])
743
+
744
+ const floorTiles = useMemo(() => {
745
+ const tiles: Array<{ id: string; x: number; y: number; w: number; h: number; sprite: boolean }> = []
746
+ const tileW = 100 / MAP_COLS
747
+ const tileH = 100 / MAP_ROWS
748
+ for (let row = 0; row < MAP_ROWS; row += 1) {
749
+ for (let col = 0; col < MAP_COLS; col += 1) {
750
+ tiles.push({
751
+ id: `tile-${row}-${col}`,
752
+ x: col * tileW,
753
+ y: row * tileH,
754
+ w: tileW,
755
+ h: tileH,
756
+ sprite: (row + col) % 2 === 0,
757
+ })
758
+ }
759
+ }
760
+ return tiles
761
+ }, [])
762
+
763
+ const movingPositionByAgent = useMemo(() => {
764
+ const positions = new Map<number, { x: number; y: number }>()
765
+ for (const worker of movingWorkers) {
766
+ const eased = easeInOut(worker.progress)
767
+ positions.set(
768
+ worker.agentId,
769
+ pointAlongPath(worker.path, worker.pathLengths, worker.totalLength, eased),
770
+ )
771
+ }
772
+ return positions
773
+ }, [movingWorkers])
774
+
775
+ const movingDirectionByAgent = useMemo(() => {
776
+ const directions = new Map<number, { dx: number; dy: number }>()
777
+ for (const worker of movingWorkers) {
778
+ directions.set(worker.agentId, {
779
+ dx: worker.endX - worker.startX,
780
+ dy: worker.endY - worker.startY,
781
+ })
782
+ }
783
+ return directions
784
+ }, [movingWorkers])
785
+
786
+ const renderedWorkers = useMemo(() => {
787
+ return gameWorkers.map((worker) => {
788
+ const movingPosition = movingPositionByAgent.get(worker.agent.id)
789
+ return {
790
+ ...worker,
791
+ x: movingPosition?.x ?? worker.x,
792
+ y: movingPosition?.y ?? worker.y,
793
+ isMoving: Boolean(movingPosition),
794
+ direction: movingDirectionByAgent.get(worker.agent.id) || { dx: 0, dy: 0 },
795
+ variant: getWorkerVariant(worker.agent.name),
796
+ }
797
+ })
798
+ }, [gameWorkers, movingDirectionByAgent, movingPositionByAgent])
799
+
800
+ const officePrefsKey = useMemo(() => {
801
+ const userPart = currentUser?.id ? `u${currentUser.id}` : `guest-${currentUser?.username || 'anon'}`
802
+ const pathPart = typeof window === 'undefined' ? 'server' : window.location.pathname.replace(/[^a-zA-Z0-9/_-]/g, '_')
803
+ return `mc-office-prefs:v1:${dashboardMode}:${userPart}:${pathPart}`
804
+ }, [currentUser?.id, currentUser?.username, dashboardMode])
805
+
806
+ useEffect(() => {
807
+ if (typeof window === 'undefined') return
808
+ try {
809
+ const raw = window.localStorage.getItem(officePrefsKey)
810
+ if (!raw) return
811
+ const prefs = JSON.parse(raw) as PersistedOfficePrefs
812
+ if (!prefs || prefs.version !== 1) return
813
+ setViewMode(prefs.viewMode || 'office')
814
+ setSidebarFilter(prefs.sidebarFilter || 'all')
815
+ setLocalSessionFilter(
816
+ prefs.localSessionFilter === 'not-running' ? 'not-running' : 'running',
817
+ )
818
+ setMapZoom(Number.isFinite(prefs.mapZoom) ? clamp(prefs.mapZoom, 0.8, 2.2) : 1)
819
+ setMapPan({
820
+ x: Number.isFinite(prefs.mapPan?.x) ? prefs.mapPan.x : 0,
821
+ y: Number.isFinite(prefs.mapPan?.y) ? prefs.mapPan.y : 0,
822
+ })
823
+ setTimeTheme(prefs.timeTheme || 'night')
824
+ setShowSidebar(prefs.showSidebar !== false)
825
+ setShowMinimap(prefs.showMinimap !== false)
826
+ setShowEvents(prefs.showEvents !== false)
827
+ if (Array.isArray(prefs.roomLayout) && prefs.roomLayout.length > 0) {
828
+ setRoomLayoutState(prefs.roomLayout.map((room) => ({ ...room })))
829
+ }
830
+ if (Array.isArray(prefs.mapProps) && prefs.mapProps.length > 0) {
831
+ setMapPropsState(prefs.mapProps.map((prop) => ({ ...prop })))
832
+ }
833
+ } catch {
834
+ // ignore corrupted local preferences
835
+ }
836
+ }, [officePrefsKey])
837
+
838
+ useEffect(() => {
839
+ if (typeof window === 'undefined') return
840
+ const payload: PersistedOfficePrefs = {
841
+ version: 1,
842
+ viewMode,
843
+ sidebarFilter,
844
+ localSessionFilter,
845
+ mapZoom,
846
+ mapPan,
847
+ timeTheme,
848
+ showSidebar,
849
+ showMinimap,
850
+ showEvents,
851
+ roomLayout: roomLayoutState,
852
+ mapProps: mapPropsState,
853
+ }
854
+ try {
855
+ window.localStorage.setItem(officePrefsKey, JSON.stringify(payload))
856
+ } catch {
857
+ // ignore storage failures
858
+ }
859
+ }, [
860
+ officePrefsKey,
861
+ mapPan,
862
+ mapPropsState,
863
+ mapZoom,
864
+ localSessionFilter,
865
+ roomLayoutState,
866
+ showEvents,
867
+ showMinimap,
868
+ showSidebar,
869
+ sidebarFilter,
870
+ timeTheme,
871
+ viewMode,
872
+ ])
873
+
874
+ useEffect(() => {
875
+ const updateThemeFromClock = () => {
876
+ const hour = new Date().getHours()
877
+ if (hour >= 6 && hour < 11) setTimeTheme('dawn')
878
+ else if (hour >= 11 && hour < 17) setTimeTheme('day')
879
+ else if (hour >= 17 && hour < 20) setTimeTheme('dusk')
880
+ else setTimeTheme('night')
881
+ }
882
+ updateThemeFromClock()
883
+ const interval = setInterval(updateThemeFromClock, 60_000)
884
+ return () => clearInterval(interval)
885
+ }, [])
886
+
887
+ const themePalette = useMemo<ThemePalette>(() => {
888
+ if (timeTheme === 'dawn') {
889
+ return {
890
+ shell: 'radial-gradient(circle at 20% 10%, rgba(255,177,108,0.52) 0, rgba(78,82,132,0.9) 48%, rgba(19,24,41,1) 100%)',
891
+ gridLine: 'rgba(255,212,166,0.2)',
892
+ haze: 'radial-gradient(circle at 52% 26%, rgba(255,205,146,0.34), transparent 62%)',
893
+ glow: 'linear-gradient(to bottom, rgba(255,238,210,0.16), transparent 35%, rgba(0,0,0,0.2))',
894
+ corridor: '#3f3f54',
895
+ corridorStripe: '#ffca95',
896
+ atmosphere: 'radial-gradient(circle at 15% 8%, rgba(255,191,122,0.34), transparent 46%), radial-gradient(circle at 82% 18%, rgba(255,224,184,0.18), transparent 40%)',
897
+ shadowVeil: 'linear-gradient(to bottom, rgba(27,22,35,0.15), rgba(13,17,33,0.38))',
898
+ floorFilter: 'hue-rotate(-8deg) saturate(1.02) brightness(1.1) contrast(1.03)',
899
+ spriteFilter: 'hue-rotate(-4deg) saturate(1.04) brightness(1.05)',
900
+ roomTone: 'linear-gradient(to bottom right, rgba(255,219,167,0.2), rgba(82,67,96,0.12))',
901
+ floorOpacityA: 0.95,
902
+ floorOpacityB: 0.8,
903
+ accentGlow: 'rgba(255,183,120,0.32)',
904
+ }
905
+ }
906
+ if (timeTheme === 'day') {
907
+ return {
908
+ shell: 'radial-gradient(circle at 20% 12%, rgba(164,203,255,0.48) 0, rgba(41,76,128,0.88) 46%, rgba(16,26,46,1) 100%)',
909
+ gridLine: 'rgba(183,218,255,0.24)',
910
+ haze: 'radial-gradient(circle at 52% 28%, rgba(196,236,255,0.25), transparent 58%)',
911
+ glow: 'linear-gradient(to bottom, rgba(255,255,255,0.14), transparent 30%, rgba(4,16,33,0.1))',
912
+ corridor: '#3a4258',
913
+ corridorStripe: '#b8d5ff',
914
+ atmosphere: 'radial-gradient(circle at 18% 5%, rgba(183,230,255,0.3), transparent 45%), radial-gradient(circle at 84% 16%, rgba(216,241,255,0.2), transparent 42%)',
915
+ shadowVeil: 'linear-gradient(to bottom, rgba(16,30,49,0.08), rgba(9,18,35,0.24))',
916
+ floorFilter: 'hue-rotate(6deg) saturate(1.08) brightness(1.2) contrast(1.04)',
917
+ spriteFilter: 'hue-rotate(4deg) saturate(1.08) brightness(1.08)',
918
+ roomTone: 'linear-gradient(to bottom right, rgba(196,236,255,0.18), rgba(81,116,171,0.08))',
919
+ floorOpacityA: 0.98,
920
+ floorOpacityB: 0.86,
921
+ accentGlow: 'rgba(176,232,255,0.3)',
922
+ }
923
+ }
924
+ if (timeTheme === 'dusk') {
925
+ return {
926
+ shell: 'radial-gradient(circle at 20% 10%, rgba(222,129,187,0.44) 0, rgba(45,44,91,0.92) 47%, rgba(12,14,30,1) 100%)',
927
+ gridLine: 'rgba(224,169,255,0.2)',
928
+ haze: 'radial-gradient(circle at 48% 30%, rgba(247,172,220,0.24), transparent 62%)',
929
+ glow: 'linear-gradient(to bottom, rgba(255,220,245,0.1), transparent 30%, rgba(0,0,0,0.24))',
930
+ corridor: '#413b58',
931
+ corridorStripe: '#d7b0ff',
932
+ atmosphere: 'radial-gradient(circle at 14% 10%, rgba(255,160,198,0.27), transparent 44%), radial-gradient(circle at 85% 18%, rgba(198,150,255,0.18), transparent 40%)',
933
+ shadowVeil: 'linear-gradient(to bottom, rgba(29,20,46,0.18), rgba(9,9,24,0.42))',
934
+ floorFilter: 'hue-rotate(20deg) saturate(1.05) brightness(0.95) contrast(1.05)',
935
+ spriteFilter: 'hue-rotate(18deg) saturate(1.08) brightness(0.98)',
936
+ roomTone: 'linear-gradient(to bottom right, rgba(244,164,209,0.17), rgba(88,62,126,0.16))',
937
+ floorOpacityA: 0.9,
938
+ floorOpacityB: 0.75,
939
+ accentGlow: 'rgba(232,141,206,0.27)',
940
+ }
941
+ }
942
+ return {
943
+ shell: 'radial-gradient(circle at 22% 10%, rgba(57,93,161,0.72) 0, rgba(12,20,38,0.95) 42%, rgba(8,12,22,1) 100%)',
944
+ gridLine: 'rgba(115,139,191,0.2)',
945
+ haze: 'radial-gradient(circle at 50% 30%, rgba(89,148,255,0.19), transparent 60%)',
946
+ glow: 'linear-gradient(to bottom, rgba(240,248,255,0.05), transparent 30%, rgba(0,0,0,0.24))',
947
+ corridor: '#303746',
948
+ corridorStripe: '#9cc2ff',
949
+ atmosphere: 'radial-gradient(circle at 16% 7%, rgba(93,141,255,0.26), transparent 45%), radial-gradient(circle at 82% 15%, rgba(133,169,255,0.16), transparent 42%)',
950
+ shadowVeil: 'linear-gradient(to bottom, rgba(8,13,25,0.34), rgba(5,8,18,0.56))',
951
+ floorFilter: 'hue-rotate(26deg) saturate(0.9) brightness(0.72) contrast(1.1)',
952
+ spriteFilter: 'hue-rotate(18deg) saturate(0.94) brightness(0.84)',
953
+ roomTone: 'linear-gradient(to bottom right, rgba(94,133,207,0.17), rgba(19,27,52,0.24))',
954
+ floorOpacityA: 0.84,
955
+ floorOpacityB: 0.66,
956
+ accentGlow: 'rgba(116,152,255,0.26)',
957
+ }
958
+ }, [timeTheme])
959
+
960
+ const nightSparkles = useMemo(
961
+ () =>
962
+ Array.from({ length: 14 }, (_, idx) => {
963
+ const seed = hashNumber(`night-${idx}`)
964
+ return {
965
+ id: idx,
966
+ x: 6 + (seed % 88),
967
+ y: 6 + ((seed >> 3) % 38),
968
+ delay: (seed % 7) * 0.4,
969
+ size: 2 + (seed % 3),
970
+ }
971
+ }),
972
+ [],
973
+ )
974
+
975
+ const heatmapPoints = useMemo(() => {
976
+ return renderedWorkers.map((worker) => {
977
+ const action = agentActionOverrides.get(worker.agent.id)
978
+ let intensity = worker.agent.status === 'busy' ? 0.95 : worker.agent.status === 'idle' ? 0.45 : 0.7
979
+ if (action === 'focus') intensity += 0.25
980
+ if (action === 'pair') intensity += 0.15
981
+ if (worker.isMoving) intensity += 0.2
982
+ const radius = worker.agent.status === 'busy' ? 14 : 10
983
+ const hue = worker.agent.status === 'busy' ? 'rgba(255,191,84,' : worker.agent.status === 'idle' ? 'rgba(88,220,139,' : 'rgba(120,189,255,'
984
+ return {
985
+ id: worker.agent.id,
986
+ x: worker.x,
987
+ y: worker.y,
988
+ radius,
989
+ color: `${hue}${Math.min(0.85, Math.max(0.2, intensity)).toFixed(2)})`,
990
+ }
991
+ })
992
+ }, [agentActionOverrides, renderedWorkers])
993
+
994
+ const rosterRows = useMemo(() => {
995
+ return gameWorkers.map(({ agent }) => {
996
+ const minutesIdle = agent.last_seen ? Math.floor((Date.now() / 1000 - agent.last_seen) / 60) : Number.POSITIVE_INFINITY
997
+ const needsAttention = isLocalMode && agent.status === 'idle' && minutesIdle >= 15
998
+ return {
999
+ agent,
1000
+ minutesIdle,
1001
+ needsAttention,
1002
+ }
1003
+ })
1004
+ }, [gameWorkers, isLocalMode])
1005
+
1006
+ const filteredRosterRows = useMemo(() => {
1007
+ if (sidebarFilter === 'all') return rosterRows
1008
+ if (sidebarFilter === 'working') return rosterRows.filter((row) => row.agent.status === 'busy')
1009
+ if (sidebarFilter === 'idle') return rosterRows.filter((row) => row.agent.status === 'idle')
1010
+ return rosterRows.filter((row) => row.needsAttention)
1011
+ }, [rosterRows, sidebarFilter])
1012
+
1013
+ const pathEdges = useMemo(() => {
1014
+ const edges: Array<{ x1: number; y1: number; x2: number; y2: number }> = []
1015
+ const zoneGroups = new Map<string, Array<{ x: number; y: number }>>()
1016
+ for (const worker of gameWorkers) {
1017
+ if (!zoneGroups.has(worker.zoneLabel)) zoneGroups.set(worker.zoneLabel, [])
1018
+ zoneGroups.get(worker.zoneLabel)!.push({ x: worker.x, y: worker.y })
1019
+ }
1020
+
1021
+ for (const points of zoneGroups.values()) {
1022
+ const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y)
1023
+ for (let i = 0; i < sorted.length - 1; i += 1) {
1024
+ edges.push({
1025
+ x1: sorted[i].x,
1026
+ y1: sorted[i].y + 2,
1027
+ x2: sorted[i + 1].x,
1028
+ y2: sorted[i + 1].y + 2,
1029
+ })
1030
+ }
1031
+ }
1032
+
1033
+ // Trunk corridor and vertical connectors to mimic an office hallway system.
1034
+ edges.push({ x1: 16, y1: 47, x2: 84, y2: 47 })
1035
+ edges.push({ x1: 30, y1: 33, x2: 30, y2: 47 })
1036
+ edges.push({ x1: 60, y1: 33, x2: 60, y2: 47 })
1037
+ edges.push({ x1: 28, y1: 47, x2: 28, y2: 68 })
1038
+ edges.push({ x1: 54, y1: 47, x2: 54, y2: 68 })
1039
+
1040
+ return edges
1041
+ }, [gameWorkers])
1042
+
1043
+ const enqueueMovement = useCallback(
1044
+ (agent: Agent, startX: number, startY: number, endX: number, endY: number, durationMs = 2200) => {
1045
+ const blockedTiles = new Set<string>()
1046
+ for (const worker of renderedWorkersRef.current) {
1047
+ if (worker.agent.id === agent.id) continue
1048
+ const tile = toTile(worker.x, worker.y)
1049
+ blockedTiles.add(tileKey(tile.col, tile.row))
1050
+ }
1051
+ for (const moving of movingWorkersRef.current) {
1052
+ if (moving.agentId === agent.id) continue
1053
+ blockedTiles.add(moving.destinationTile)
1054
+ }
1055
+ const destination = toTile(endX, endY)
1056
+ const movement: MovingWorker = {
1057
+ id: `${agent.id}-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
1058
+ agentId: agent.id,
1059
+ initials: getInitials(agent.name),
1060
+ colorClass: hashColor(agent.name),
1061
+ startX,
1062
+ startY,
1063
+ endX,
1064
+ endY,
1065
+ startedAt: Date.now(),
1066
+ durationMs,
1067
+ progress: 0,
1068
+ ...buildPath(startX, startY, endX, endY, blockedTiles),
1069
+ destinationTile: tileKey(destination.col, destination.row),
1070
+ }
1071
+ setMovingWorkers((current) => {
1072
+ if (current.some((item) => item.agentId === agent.id)) return current
1073
+ return [...current, movement]
1074
+ })
1075
+ },
1076
+ [],
1077
+ )
1078
+
1079
+ useEffect(() => {
1080
+ const prev = prevStatusRef.current
1081
+ const next = new Map<number, Agent['status']>()
1082
+ const toAnimate: number[] = []
1083
+
1084
+ for (const agent of displayAgents) {
1085
+ next.set(agent.id, agent.status)
1086
+ const prevStatus = prev.get(agent.id)
1087
+ if (prevStatus && prevStatus !== agent.status) {
1088
+ toAnimate.push(agent.id)
1089
+ }
1090
+ }
1091
+
1092
+ prevStatusRef.current = next
1093
+
1094
+ if (toAnimate.length === 0) return
1095
+ setTransitioningAgentIds((current) => {
1096
+ const updated = new Set(current)
1097
+ for (const id of toAnimate) updated.add(id)
1098
+ return updated
1099
+ })
1100
+
1101
+ for (const id of toAnimate) {
1102
+ const existingTimer = transitionTimersRef.current.get(id)
1103
+ if (existingTimer) clearTimeout(existingTimer)
1104
+ const timer = setTimeout(() => {
1105
+ setTransitioningAgentIds((current) => {
1106
+ const updated = new Set(current)
1107
+ updated.delete(id)
1108
+ return updated
1109
+ })
1110
+ transitionTimersRef.current.delete(id)
1111
+ }, 2200)
1112
+ transitionTimersRef.current.set(id, timer)
1113
+ }
1114
  }, [displayAgents])
1115
 
1116
+ useEffect(() => {
1117
+ const previous = previousSeatMapRef.current
1118
+
1119
+ for (const agent of displayAgents) {
1120
+ const currentSeat = currentSeatMap.get(agent.id)
1121
+ const previousSeat = previous.get(agent.id)
1122
+ if (!currentSeat || !previousSeat) continue
1123
+ if (currentSeat.seatKey === previousSeat.seatKey) continue
1124
+
1125
+ enqueueMovement(agent, previousSeat.x, previousSeat.y, currentSeat.x, currentSeat.y, 1800)
1126
+ }
1127
+
1128
+ previousSeatMapRef.current = currentSeatMap
1129
+ }, [currentSeatMap, displayAgents, enqueueMovement])
1130
+
1131
+ useEffect(() => {
1132
+ if (movingWorkers.length === 0) return
1133
+
1134
+ let rafId: number | null = null
1135
+ const step = () => {
1136
+ const now = Date.now()
1137
+ setMovingWorkers((current) => {
1138
+ if (current.length === 0) return current
1139
+ const updated = current
1140
+ .map((worker) => {
1141
+ const linear = (now - worker.startedAt) / worker.durationMs
1142
+ const progress = Math.max(0, Math.min(1, linear))
1143
+ return { ...worker, progress }
1144
+ })
1145
+ .filter((worker) => worker.progress < 1)
1146
+ return updated
1147
+ })
1148
+ rafId = window.requestAnimationFrame(step)
1149
+ }
1150
+
1151
+ rafId = window.requestAnimationFrame(step)
1152
+ return () => {
1153
+ if (rafId != null) window.cancelAnimationFrame(rafId)
1154
+ }
1155
+ }, [movingWorkers.length])
1156
+
1157
+ useEffect(() => {
1158
+ movingWorkersRef.current = movingWorkers
1159
+ movingAgentIdsRef.current = new Set(movingWorkers.map((worker) => worker.agentId))
1160
+ }, [movingWorkers])
1161
+
1162
+ useEffect(() => {
1163
+ renderedWorkersRef.current = renderedWorkers
1164
+ }, [renderedWorkers])
1165
+
1166
+ const pushOfficeEvent = useCallback((event: Omit<OfficeEvent, 'id' | 'at'>) => {
1167
+ const next: OfficeEvent = {
1168
+ ...event,
1169
+ id: `${Date.now()}-${Math.floor(Math.random() * 1000)}`,
1170
+ at: Date.now(),
1171
+ }
1172
+ setOfficeEvents((current) => [next, ...current].slice(0, 12))
1173
+ }, [])
1174
+
1175
+ useEffect(() => {
1176
+ if (!isLocalMode) return
1177
+ const interval = setInterval(() => {
1178
+ const activeMovingIds = movingAgentIdsRef.current
1179
+ const idleCandidates = renderedWorkersRef.current
1180
+ .filter((worker) => worker.agent.status === 'idle' && !worker.isMoving && !activeMovingIds.has(worker.agent.id))
1181
+ .sort((a, b) => a.agent.name.localeCompare(b.agent.name))
1182
+ .slice(0, 2)
1183
+
1184
+ if (idleCandidates.length === 0) return
1185
+ const cycle = Math.floor(Date.now() / 14_000)
1186
+
1187
+ for (const worker of idleCandidates) {
1188
+ const waypoint = LOUNGE_WAYPOINTS[(hashNumber(worker.agent.name) + cycle) % LOUNGE_WAYPOINTS.length]
1189
+ enqueueMovement(worker.agent, worker.x, worker.y, waypoint.x, waypoint.y, 2200)
1190
+
1191
+ const existingReturnTimer = roamReturnTimersRef.current.get(worker.agent.id)
1192
+ if (existingReturnTimer) clearTimeout(existingReturnTimer)
1193
+ const returnTimer = setTimeout(() => {
1194
+ const seat = currentSeatMap.get(worker.agent.id)
1195
+ if (seat) {
1196
+ enqueueMovement(worker.agent, waypoint.x, waypoint.y, seat.x, seat.y, 2200)
1197
+ }
1198
+ roamReturnTimersRef.current.delete(worker.agent.id)
1199
+ }, 2700)
1200
+ roamReturnTimersRef.current.set(worker.agent.id, returnTimer)
1201
+ }
1202
+ }, 14_000)
1203
+ return () => clearInterval(interval)
1204
+ }, [currentSeatMap, enqueueMovement, isLocalMode])
1205
+
1206
+ useEffect(() => {
1207
+ const interval = setInterval(() => {
1208
+ const workers = renderedWorkersRef.current
1209
+ if (workers.length === 0) return
1210
+ const sample = workers[Math.floor(Math.random() * workers.length)]
1211
+ const mood = sample.agent.status === 'busy' ? 'good' : sample.agent.status === 'idle' ? 'warn' : 'info'
1212
+ pushOfficeEvent({
1213
+ kind: 'room',
1214
+ severity: mood,
1215
+ message: `${sample.zoneLabel}: ${sample.agent.name} status is ${statusLabel[sample.agent.status].toLowerCase()}.`,
1216
+ })
1217
+ }, 22000)
1218
+ return () => clearInterval(interval)
1219
+ }, [pushOfficeEvent])
1220
+
1221
+ useEffect(() => {
1222
+ const timers = transitionTimersRef.current
1223
+ const roamTimers = roamReturnTimersRef.current
1224
+ return () => {
1225
+ for (const timer of timers.values()) clearTimeout(timer)
1226
+ timers.clear()
1227
+ for (const timer of roamTimers.values()) clearTimeout(timer)
1228
+ roamTimers.clear()
1229
+ if (launchToastTimerRef.current) {
1230
+ clearTimeout(launchToastTimerRef.current)
1231
+ launchToastTimerRef.current = null
1232
+ }
1233
+ }
1234
+ }, [])
1235
+
1236
+ const showLaunchToast = (toast: LaunchToast) => {
1237
+ setLaunchToast(toast)
1238
+ if (launchToastTimerRef.current) {
1239
+ clearTimeout(launchToastTimerRef.current)
1240
+ }
1241
+ launchToastTimerRef.current = setTimeout(() => {
1242
+ setLaunchToast(null)
1243
+ launchToastTimerRef.current = null
1244
+ }, 5000)
1245
+ }
1246
+
1247
+ const executeAgentAction = useCallback((agent: Agent, action: OfficeAction) => {
1248
+ setAgentActionOverrides((current) => {
1249
+ const next = new Map(current)
1250
+ next.set(agent.id, action)
1251
+ return next
1252
+ })
1253
+
1254
+ if (action === 'focus') {
1255
+ pushOfficeEvent({ kind: 'action', severity: 'good', message: `${agent.name} is now in deep focus mode.` })
1256
+ return
1257
+ }
1258
+
1259
+ if (action === 'pair') {
1260
+ const partner = renderedWorkersRef.current.find((worker) => worker.agent.id !== agent.id)?.agent
1261
+ pushOfficeEvent({
1262
+ kind: 'action',
1263
+ severity: 'info',
1264
+ message: partner
1265
+ ? `${agent.name} started a pairing session with ${partner.name}.`
1266
+ : `${agent.name} started a solo pairing prep session.`,
1267
+ })
1268
+ return
1269
+ }
1270
+
1271
+ const worker = renderedWorkersRef.current.find((item) => item.agent.id === agent.id)
1272
+ const waypoint = LOUNGE_WAYPOINTS[hashNumber(agent.name) % LOUNGE_WAYPOINTS.length]
1273
+ if (worker) {
1274
+ enqueueMovement(agent, worker.x, worker.y, waypoint.x, waypoint.y, 2200)
1275
+ pushOfficeEvent({ kind: 'action', severity: 'warn', message: `${agent.name} is taking a short lounge break.` })
1276
+ return
1277
+ }
1278
+ pushOfficeEvent({ kind: 'action', severity: 'warn', message: `${agent.name} requested a break.` })
1279
+ }, [enqueueMovement, pushOfficeEvent])
1280
+
1281
+ const openFlightDeck = async (agent: Agent) => {
1282
+ setFlightDeckLaunching(true)
1283
+ try {
1284
+ const res = await fetch('/api/local/flight-deck', {
1285
+ method: 'POST',
1286
+ headers: { 'Content-Type': 'application/json' },
1287
+ body: JSON.stringify({
1288
+ agent: agent.name,
1289
+ session: agent.session_key || '',
1290
+ }),
1291
+ })
1292
+ const json = await res.json().catch(() => ({}))
1293
+ if (!res.ok || json?.installed === false) {
1294
+ if (typeof json?.downloadUrl === 'string' && json.downloadUrl) {
1295
+ setFlightDeckDownloadUrl(json.downloadUrl)
1296
+ }
1297
+ setShowFlightDeckModal(true)
1298
+ showLaunchToast({
1299
+ kind: 'info',
1300
+ title: 'Flight Deck not installed',
1301
+ detail: 'Install Flight Deck to open this session.',
1302
+ })
1303
+ return
1304
+ }
1305
+ if (!json?.launched) {
1306
+ // Fallback for environments where native launch fails.
1307
+ if (typeof json?.fallbackUrl === 'string' && json.fallbackUrl) {
1308
+ window.open(json.fallbackUrl, '_blank', 'noopener,noreferrer')
1309
+ showLaunchToast({
1310
+ kind: 'info',
1311
+ title: 'Opened browser fallback',
1312
+ detail: 'Native launch failed, opened Flight Deck web fallback.',
1313
+ })
1314
+ return
1315
+ }
1316
+ showLaunchToast({
1317
+ kind: 'error',
1318
+ title: 'Flight Deck launch failed',
1319
+ detail: json?.error || 'Unable to launch Flight Deck for this session.',
1320
+ })
1321
+ return
1322
+ }
1323
+ showLaunchToast({
1324
+ kind: 'success',
1325
+ title: 'Opened in Flight Deck',
1326
+ detail: 'Launched native Flight Deck app for this session.',
1327
+ })
1328
+ } catch {
1329
+ setShowFlightDeckModal(true)
1330
+ showLaunchToast({
1331
+ kind: 'error',
1332
+ title: 'Flight Deck request failed',
1333
+ detail: 'Could not reach local launch endpoint.',
1334
+ })
1335
+ } finally {
1336
+ setFlightDeckLaunching(false)
1337
+ }
1338
+ }
1339
+
1340
+ const resetMapView = () => {
1341
+ setMapZoom(1)
1342
+ setMapPan({ x: 0, y: 0 })
1343
+ }
1344
+
1345
+ const onMapWheel = (event: WheelEvent<HTMLDivElement>) => {
1346
+ event.preventDefault()
1347
+ const delta = event.deltaY > 0 ? -0.08 : 0.08
1348
+ setMapZoom((current) => Math.min(2.2, Math.max(0.8, Number((current + delta).toFixed(2)))))
1349
+ }
1350
+
1351
+ const onMapMouseDown = (event: MouseEvent<HTMLDivElement>) => {
1352
+ mapDragActiveRef.current = true
1353
+ mapDragOriginRef.current = { x: event.clientX, y: event.clientY }
1354
+ mapPanStartRef.current = { ...mapPan }
1355
+ }
1356
+
1357
+ const onMapMouseMove = (event: MouseEvent<HTMLDivElement>) => {
1358
+ if (!mapDragActiveRef.current) return
1359
+ const dx = event.clientX - mapDragOriginRef.current.x
1360
+ const dy = event.clientY - mapDragOriginRef.current.y
1361
+ setMapPan({
1362
+ x: mapPanStartRef.current.x + dx,
1363
+ y: mapPanStartRef.current.y + dy,
1364
+ })
1365
+ }
1366
+
1367
+ const endMapDrag = () => {
1368
+ mapDragActiveRef.current = false
1369
+ }
1370
+
1371
+ const focusMapPoint = useCallback(
1372
+ (xPercent: number, yPercent: number) => {
1373
+ const viewport = mapViewportRef.current
1374
+ if (!viewport) return
1375
+ const rect = viewport.getBoundingClientRect()
1376
+ const nextPanX = rect.width / 2 - (xPercent / 100) * rect.width * mapZoom
1377
+ const nextPanY = rect.height / 2 - (yPercent / 100) * rect.height * mapZoom
1378
+ setMapPan({ x: nextPanX, y: nextPanY })
1379
+ },
1380
+ [mapZoom],
1381
+ )
1382
+
1383
+ const nudgeSelectedHotspot = useCallback((dx: number, dy: number) => {
1384
+ if (!selectedHotspot) return
1385
+ if (selectedHotspot.kind === 'room') {
1386
+ setRoomLayoutState((current) =>
1387
+ current.map((room) => {
1388
+ if (room.id !== selectedHotspot.id) return room
1389
+ return {
1390
+ ...room,
1391
+ x: clamp(room.x + dx, 2, 94 - room.w),
1392
+ y: clamp(room.y + dy, 8, 94 - room.h),
1393
+ }
1394
+ }),
1395
+ )
1396
+ setSelectedHotspot((current) =>
1397
+ current ? { ...current, x: clamp(current.x + dx, 2, 98), y: clamp(current.y + dy, 8, 98) } : current,
1398
+ )
1399
+ return
1400
+ }
1401
+ setMapPropsState((current) =>
1402
+ current.map((prop) => {
1403
+ if (prop.id !== selectedHotspot.id) return prop
1404
+ return {
1405
+ ...prop,
1406
+ x: clamp(prop.x + dx, 2, 98 - prop.w),
1407
+ y: clamp(prop.y + dy, 8, 98 - prop.h),
1408
+ }
1409
+ }),
1410
+ )
1411
+ setSelectedHotspot((current) =>
1412
+ current ? { ...current, x: clamp(current.x + dx, 2, 98), y: clamp(current.y + dy, 8, 98) } : current,
1413
+ )
1414
+ }, [selectedHotspot])
1415
+
1416
+ const resizeSelectedRoom = useCallback((dw: number, dh: number) => {
1417
+ if (!selectedHotspot || selectedHotspot.kind !== 'room') return
1418
+ setRoomLayoutState((current) =>
1419
+ current.map((room) => {
1420
+ if (room.id !== selectedHotspot.id) return room
1421
+ const nextW = clamp(room.w + dw, 10, 40)
1422
+ const nextH = clamp(room.h + dh, 10, 36)
1423
+ return {
1424
+ ...room,
1425
+ w: nextW,
1426
+ h: nextH,
1427
+ x: clamp(room.x, 2, 98 - nextW),
1428
+ y: clamp(room.y, 8, 98 - nextH),
1429
+ }
1430
+ }),
1431
+ )
1432
+ }, [selectedHotspot])
1433
+
1434
+ const resetOfficeLayout = useCallback(() => {
1435
+ setRoomLayoutState(ROOM_LAYOUT.map((room) => ({ ...room })))
1436
+ setMapPropsState(MAP_PROPS.map((prop) => ({ ...prop })))
1437
+ setMapZoom(1)
1438
+ setMapPan({ x: 0, y: 0 })
1439
+ setShowSidebar(true)
1440
+ setShowMinimap(true)
1441
+ setShowEvents(true)
1442
+ setSelectedHotspot(null)
1443
+ pushOfficeEvent({ kind: 'room', severity: 'info', message: 'Office layout reset to defaults.' })
1444
+ }, [pushOfficeEvent])
1445
+
1446
  const categoryGroups = useMemo(() => {
1447
  const groups = new Map<string, Agent[]>()
1448
  const getCategory = (agent: Agent): string => {
 
1455
  return 'Other'
1456
  }
1457
 
1458
+ for (const a of visibleDisplayAgents) {
1459
  const category = getCategory(a)
1460
  if (!groups.has(category)) groups.set(category, [])
1461
  groups.get(category)!.push(a)
 
1472
  return a.localeCompare(b)
1473
  })
1474
  )
1475
+ }, [visibleDisplayAgents])
1476
 
1477
  const statusGroups = useMemo(() => {
1478
  const groups = new Map<string, Agent[]>()
1479
+ for (const a of visibleDisplayAgents) {
1480
  const key = statusLabel[a.status] || a.status
1481
  if (!groups.has(key)) groups.set(key, [])
1482
  groups.get(key)!.push(a)
 
1493
  return a.localeCompare(b)
1494
  })
1495
  )
1496
+ }, [visibleDisplayAgents])
1497
 
1498
  const orgGroups = useMemo(() => {
1499
  if (orgSegmentMode === 'role') return roleGroups
 
1501
  return categoryGroups
1502
  }, [categoryGroups, orgSegmentMode, roleGroups, statusGroups])
1503
 
1504
+ if ((loading || (isLocalMode && localBootstrapping)) && visibleDisplayAgents.length === 0) {
1505
  return (
1506
  <div className="flex items-center justify-center h-64">
1507
  <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
1508
+ <span className="ml-3 text-muted-foreground">
1509
+ {isLocalMode ? 'Scanning local sessions...' : 'Loading office...'}
1510
+ </span>
1511
  </div>
1512
  )
1513
  }
 
1548
  </div>
1549
  </div>
1550
 
1551
+ {visibleDisplayAgents.length === 0 ? (
1552
  <div className="text-center py-16 text-muted-foreground">
1553
  <div className="text-5xl mb-3">🏢</div>
1554
  <p className="text-lg">The office is empty</p>
1555
  <p className="text-sm mt-1">Add agents to see them appear here</p>
1556
  </div>
1557
  ) : viewMode === 'office' ? (
1558
+ <div className={`grid grid-cols-1 ${showSidebar ? 'xl:grid-cols-[220px_1fr]' : 'xl:grid-cols-1'} gap-4`}>
1559
+ {showSidebar && (
1560
+ <div className="rounded-xl border border-border bg-[#1a1f2d] text-slate-100 p-3 h-fit">
1561
+ <div className="flex items-center justify-between mb-2">
1562
+ <div className="text-xs font-semibold tracking-wider">TEAMY</div>
1563
+ <div className="text-[10px] text-slate-300">{visibleDisplayAgents.length} online</div>
1564
+ </div>
1565
+ <div className="mb-2 flex flex-wrap gap-1.5">
1566
+ {([
1567
+ { key: 'all', label: 'All' },
1568
+ { key: 'working', label: 'Working' },
1569
+ { key: 'idle', label: 'Idle' },
1570
+ { key: 'attention', label: 'Needs Attention' },
1571
+ ] as Array<{ key: SidebarFilter; label: string }>).map((item) => (
1572
+ <button
1573
+ key={item.key}
1574
+ onClick={() => setSidebarFilter(item.key)}
1575
+ className={`px-2 py-1 rounded text-[10px] border transition-smooth ${
1576
+ sidebarFilter === item.key
1577
+ ? 'bg-primary/25 border-primary/40 text-primary-foreground'
1578
+ : 'bg-black/20 border-white/10 text-slate-300 hover:bg-black/35'
1579
+ }`}
1580
+ >
1581
+ {item.label}
1582
+ </button>
1583
+ ))}
1584
+ </div>
1585
+ {isLocalMode && (
1586
+ <div className="mb-2 flex gap-1.5">
1587
+ <button
1588
+ onClick={() => setLocalSessionFilter('running')}
1589
+ className={`flex-1 rounded border px-2 py-1 text-[10px] transition-smooth ${
1590
+ localSessionFilter === 'running'
1591
+ ? 'bg-primary/25 border-primary/40 text-primary-foreground'
1592
+ : 'bg-black/20 border-white/10 text-slate-300 hover:bg-black/35'
1593
+ }`}
1594
+ >
1595
+ Running
1596
+ </button>
1597
+ <button
1598
+ onClick={() => setLocalSessionFilter('not-running')}
1599
+ className={`flex-1 rounded border px-2 py-1 text-[10px] transition-smooth ${
1600
+ localSessionFilter === 'not-running'
1601
+ ? 'bg-amber-500/15 border-amber-500/60 text-amber-200'
1602
+ : 'bg-black/20 border-white/10 text-slate-300 hover:bg-black/35'
1603
+ }`}
1604
+ >
1605
+ Not Running
1606
+ </button>
1607
+ </div>
1608
+ )}
1609
+ <div className="space-y-2 max-h-[560px] overflow-y-auto pr-1">
1610
+ {filteredRosterRows.map(({ agent, minutesIdle, needsAttention }) => (
1611
+ <button
1612
+ key={agent.id}
1613
+ onClick={() => {
1614
+ setSelectedAgent(agent)
1615
+ const worker = renderedWorkers.find((item) => item.agent.id === agent.id)
1616
+ if (worker) focusMapPoint(worker.x, worker.y)
1617
+ }}
1618
+ className={`w-full flex items-center gap-2 rounded-lg p-2 text-left transition-smooth ${
1619
+ needsAttention
1620
+ ? 'bg-amber-500/12 border border-amber-400/60 hover:bg-amber-500/20'
1621
+ : 'bg-black/20 border border-white/5 hover:bg-black/35'
1622
+ }`}
1623
+ >
1624
+ <span className={`w-6 h-6 rounded ${hashColor(agent.name)} flex items-center justify-center text-[10px] font-bold text-white`}>
1625
+ {getInitials(agent.name)}
1626
+ </span>
1627
+ <span className="min-w-0 flex-1">
1628
+ <span className="block text-xs font-medium truncate">{agent.name}</span>
1629
+ <span className="block text-[10px] text-slate-300 truncate">{agent.role}</span>
1630
+ <span className="block text-[9px] text-slate-400 truncate">
1631
+ {agent.last_activity || 'No recent activity'}
1632
+ </span>
1633
+ </span>
1634
+ <span className="flex flex-col items-end gap-1">
1635
+ <span className={`w-2 h-2 rounded-full ${statusDot[agent.status]}`} />
1636
+ <span className={`text-[9px] ${needsAttention ? 'text-amber-300 font-semibold' : 'text-slate-400'}`}>
1637
+ {agent.status === 'busy' ? 'active' : `${minutesIdle}m idle`}
1638
+ </span>
1639
+ </span>
1640
+ </button>
1641
+ ))}
1642
+ {filteredRosterRows.length === 0 && (
1643
+ <div className="text-[11px] text-slate-400 px-1 py-2">No workers in this filter.</div>
1644
+ )}
1645
+ </div>
1646
+ </div>
1647
+ )}
1648
 
1649
+ <div
1650
+ ref={mapViewportRef}
1651
+ className="relative rounded-xl border border-slate-700/70 overflow-hidden min-h-[560px] cursor-grab active:cursor-grabbing shadow-[0_20px_60px_rgba(0,0,0,0.55)]"
1652
+ style={{
1653
+ backgroundColor: '#0b1220',
1654
+ backgroundImage: `${themePalette.shell}, linear-gradient(90deg, ${themePalette.gridLine} 1px, transparent 1px), linear-gradient(${themePalette.gridLine} 1px, transparent 1px)`,
1655
+ backgroundSize: 'auto, 64px 64px, 64px 64px',
1656
+ }}
1657
+ onWheel={onMapWheel}
1658
+ onMouseDown={onMapMouseDown}
1659
+ onMouseMove={onMapMouseMove}
1660
+ onMouseUp={endMapDrag}
1661
+ onMouseLeave={endMapDrag}
1662
+ >
1663
+ <div className="absolute inset-0 pointer-events-none z-0" style={{ backgroundImage: themePalette.haze }} />
1664
+ <div className="absolute inset-0 pointer-events-none z-0" style={{ backgroundImage: themePalette.glow }} />
1665
+ <div className="absolute inset-0 pointer-events-none z-0" style={{ backgroundImage: themePalette.atmosphere, mixBlendMode: 'screen', opacity: 0.9 }} />
1666
+ <div className="absolute inset-0 pointer-events-none z-0" style={{ backgroundImage: themePalette.shadowVeil }} />
1667
+ {timeTheme === 'dawn' && (
1668
+ <div
1669
+ className="absolute inset-0 pointer-events-none z-[2]"
1670
+ style={{
1671
+ background: `linear-gradient(115deg, transparent 8%, ${themePalette.accentGlow} 24%, transparent 42%)`,
1672
+ mixBlendMode: 'screen',
1673
+ animation: 'mcSunSweep 17s ease-in-out infinite',
1674
+ }}
1675
+ />
1676
+ )}
1677
+ {timeTheme === 'day' && (
1678
+ <>
1679
  <div
1680
+ className="absolute inset-0 pointer-events-none z-[2]"
1681
+ style={{
1682
+ background: `linear-gradient(112deg, transparent 10%, ${themePalette.accentGlow} 24%, transparent 44%)`,
1683
+ mixBlendMode: 'screen',
1684
+ animation: 'mcSunSweep 16s ease-in-out infinite',
1685
+ }}
1686
+ />
1687
+ <div
1688
+ className="absolute inset-0 pointer-events-none z-[2]"
1689
+ style={{
1690
+ background: 'linear-gradient(96deg, transparent 24%, rgba(255,255,255,0.15) 38%, transparent 58%)',
1691
+ mixBlendMode: 'screen',
1692
+ animation: 'mcSunSweepReverse 20s ease-in-out infinite',
1693
+ }}
1694
+ />
1695
+ </>
1696
+ )}
1697
+ {timeTheme === 'dusk' && (
1698
+ <div
1699
+ className="absolute inset-0 pointer-events-none z-[2]"
1700
+ style={{
1701
+ background: `radial-gradient(circle at 50% 22%, ${themePalette.accentGlow} 0, transparent 56%)`,
1702
+ mixBlendMode: 'screen',
1703
+ animation: 'mcDuskPulse 7.5s ease-in-out infinite',
1704
+ }}
1705
+ />
1706
+ )}
1707
+ {timeTheme === 'night' && (
1708
+ <>
1709
+ <div
1710
+ className="absolute inset-0 pointer-events-none z-[2]"
1711
+ style={{
1712
+ background: `radial-gradient(circle at 18% 12%, ${themePalette.accentGlow} 0, transparent 44%), radial-gradient(circle at 82% 16%, rgba(138,178,255,0.2) 0, transparent 42%)`,
1713
+ mixBlendMode: 'screen',
1714
+ animation: 'mcNightBloom 8.5s ease-in-out infinite',
1715
+ }}
1716
+ />
1717
+ {nightSparkles.map((spark) => (
1718
+ <div
1719
+ key={`spark-${spark.id}`}
1720
+ className="absolute pointer-events-none z-[2] rounded-full bg-white/80"
1721
+ style={{
1722
+ left: `${spark.x}%`,
1723
+ top: `${spark.y}%`,
1724
+ width: `${spark.size}px`,
1725
+ height: `${spark.size}px`,
1726
+ boxShadow: '0 0 8px rgba(180,210,255,0.9)',
1727
+ animation: `mcTwinkle 2.6s ease-in-out ${spark.delay}s infinite`,
1728
+ }}
1729
+ />
1730
+ ))}
1731
+ </>
1732
+ )}
1733
+
1734
+ <div className="absolute left-[8%] top-[8%] rounded-md bg-black/55 border border-white/10 text-slate-100 text-xs px-2 py-1 font-mono z-30">
1735
+ MAIN FLOOR
1736
+ </div>
1737
+ <div className="absolute right-3 top-3 z-30 flex items-center gap-1 rounded-md bg-black/50 border border-white/10 text-white/90 px-2 py-1">
1738
+ <button onClick={() => setMapZoom((z) => Math.max(0.8, Number((z - 0.1).toFixed(2))))} className="text-xs px-1.5 py-0.5 hover:bg-white/10 rounded">-</button>
1739
+ <span className="text-[11px] w-10 text-center">{Math.round(mapZoom * 100)}%</span>
1740
+ <button onClick={() => setMapZoom((z) => Math.min(2.2, Number((z + 0.1).toFixed(2))))} className="text-xs px-1.5 py-0.5 hover:bg-white/10 rounded">+</button>
1741
+ <button onClick={resetMapView} className="text-[11px] px-1.5 py-0.5 hover:bg-white/10 rounded">Reset</button>
1742
+ </div>
1743
+ <div className="absolute right-3 top-12 z-30 flex items-center gap-1 rounded-md bg-black/50 border border-white/10 text-white/90 px-2 py-1">
1744
+ {(['dawn', 'day', 'dusk', 'night'] as TimeTheme[]).map((item) => (
1745
+ <button
1746
+ key={item}
1747
+ onClick={() => setTimeTheme(item)}
1748
+ className={`text-[10px] px-1.5 py-0.5 rounded uppercase ${timeTheme === item ? 'bg-white/20 text-white' : 'hover:bg-white/10 text-slate-300'}`}
1749
  >
1750
+ {item}
1751
+ </button>
1752
+ ))}
1753
+ </div>
1754
+ <div className="absolute left-3 top-3 z-30 flex items-center gap-1 rounded-md bg-black/50 border border-white/10 text-white/90 px-2 py-1">
1755
+ <button onClick={() => setShowSidebar((v) => !v)} className="text-[10px] px-1.5 py-0.5 rounded hover:bg-white/10">{showSidebar ? 'Hide Sidebar' : 'Show Sidebar'}</button>
1756
+ <button onClick={() => setShowMinimap((v) => !v)} className="text-[10px] px-1.5 py-0.5 rounded hover:bg-white/10">{showMinimap ? 'Hide Minimap' : 'Show Minimap'}</button>
1757
+ <button onClick={() => setShowEvents((v) => !v)} className="text-[10px] px-1.5 py-0.5 rounded hover:bg-white/10">{showEvents ? 'Hide Events' : 'Show Events'}</button>
1758
+ <button onClick={resetOfficeLayout} className="text-[10px] px-1.5 py-0.5 rounded hover:bg-white/10">Reset Layout</button>
1759
+ </div>
1760
+
1761
+ <div
1762
+ className="absolute inset-0 origin-top-left"
1763
+ style={{ transform: `translate(${mapPan.x}px, ${mapPan.y}px) scale(${mapZoom})` }}
1764
+ >
1765
+ <div className="absolute inset-0 z-0">
1766
+ {floorTiles.map((tile) => (
1767
+ <div
1768
+ key={tile.id}
1769
+ className="absolute border border-[#7fa4ff]/[0.06]"
1770
+ style={{
1771
+ left: `${tile.x}%`,
1772
+ top: `${tile.y}%`,
1773
+ width: `${tile.w}%`,
1774
+ height: `${tile.h}%`,
1775
+ backgroundImage: `url('/office-sprites/kenney/floorFull.png')`,
1776
+ backgroundSize: '100% 100%',
1777
+ opacity: tile.sprite ? themePalette.floorOpacityA : themePalette.floorOpacityB,
1778
+ filter: themePalette.floorFilter,
1779
+ }}
1780
+ />
1781
+ ))}
1782
+ </div>
1783
+
1784
+ {/* Corridor base */}
1785
+ <div className="absolute left-[14%] top-[45%] w-[72%] h-[6%] border-y border-[#95b8ff]/25 shadow-[0_0_30px_rgba(61,139,255,0.25)]" style={{ backgroundColor: themePalette.corridor }} />
1786
+ <div className="absolute left-[14%] top-[47.6%] w-[72%] h-[0.7%]" style={{ backgroundColor: themePalette.corridorStripe }} />
1787
 
1788
+ <div className="absolute inset-0 pointer-events-none z-[1]">
1789
+ {heatmapPoints.map((point) => (
1790
+ <div
1791
+ key={`heat-${point.id}`}
1792
+ className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full blur-xl"
1793
+ style={{
1794
+ left: `${point.x}%`,
1795
+ top: `${point.y}%`,
1796
+ width: `${point.radius * 2}px`,
1797
+ height: `${point.radius * 2}px`,
1798
+ background: `radial-gradient(circle, ${point.color} 0%, rgba(0,0,0,0) 72%)`,
1799
+ }}
1800
+ />
1801
+ ))}
1802
+ </div>
1803
+
1804
+ {/* Zone rooms */}
1805
+ {roomLayoutState.map((room) => (
1806
+ <div
1807
+ key={room.id}
1808
+ className={`absolute border border-[#8ea6d9]/35 ${room.style} shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04),0_8px_24px_rgba(0,0,0,0.3)]`}
1809
+ style={{
1810
+ left: `${room.x}%`,
1811
+ top: `${room.y}%`,
1812
+ width: `${room.w}%`,
1813
+ height: `${room.h}%`,
1814
+ backgroundImage: `linear-gradient(to bottom right, rgba(255,255,255,0.04), rgba(0,0,0,0.1)), url('/office-sprites/kenney/floorFull.png')`,
1815
+ backgroundSize: 'auto, 22% 22%',
1816
+ filter: themePalette.floorFilter,
1817
+ }}
1818
+ onClick={(event) => {
1819
+ event.stopPropagation()
1820
+ const activeInRoom = renderedWorkers.filter((worker) => worker.zoneLabel === room.label).length
1821
+ setSelectedHotspot({
1822
+ kind: 'room',
1823
+ id: room.id,
1824
+ label: room.label,
1825
+ x: room.x + room.w / 2,
1826
+ y: room.y + room.h / 2,
1827
+ stats: [
1828
+ `${activeInRoom} workers present`,
1829
+ `${Math.round(room.w * room.h)} tile area`,
1830
+ 'Click worker to inspect session',
1831
+ ],
1832
+ })
1833
+ pushOfficeEvent({
1834
+ kind: 'room',
1835
+ severity: 'info',
1836
+ message: `${room.label} room inspected (${activeInRoom} workers).`,
1837
+ })
1838
+ }}
1839
+ >
1840
+ <div className="absolute inset-0 pointer-events-none" style={{ backgroundImage: `${themePalette.roomTone}, linear-gradient(to bottom right, rgba(255,255,255,0.08), transparent 45%)` }} />
1841
+ <div className="absolute left-2 top-1 rounded bg-black/55 border border-white/10 text-white text-[9px] px-1.5 py-0.5 font-mono uppercase tracking-wide">
1842
+ {room.label}
1843
  </div>
1844
+ </div>
1845
+ ))}
1846
 
1847
+ {/* Props / furniture */}
1848
+ {mapPropsState.map((prop) => (
1849
+ <div
1850
+ key={prop.id}
1851
+ className={`absolute relative border ${prop.style} ${prop.border} shadow-[0_0_12px_rgba(108,164,255,0.18)] overflow-hidden`}
1852
+ style={{ left: `${prop.x}%`, top: `${prop.y}%`, width: `${prop.w}%`, height: `${prop.h}%` }}
1853
+ onClick={(event) => {
1854
+ event.stopPropagation()
1855
+ const nearest = renderedWorkers
1856
+ .slice()
1857
+ .sort((a, b) => Math.hypot(a.x - prop.x, a.y - prop.y) - Math.hypot(b.x - prop.x, b.y - prop.y))[0]
1858
+ setSelectedHotspot({
1859
+ kind: 'desk',
1860
+ id: prop.id,
1861
+ label: prop.id.replace(/^desk-/, 'Desk ').replace(/^plant-/, 'Plant ').replace(/^kitchen$/, 'Lounge Rug'),
1862
+ x: prop.x + prop.w / 2,
1863
+ y: prop.y + prop.h / 2,
1864
+ stats: [
1865
+ nearest ? `Nearest worker: ${nearest.agent.name}` : 'No nearby worker',
1866
+ `Footprint ${prop.w.toFixed(1)}x${prop.h.toFixed(1)}`,
1867
+ 'Use action buttons in agent modal',
1868
+ ],
1869
+ })
1870
+ pushOfficeEvent({
1871
+ kind: 'desk',
1872
+ severity: 'info',
1873
+ message: `${prop.id} inspected${nearest ? ` near ${nearest.agent.name}` : ''}.`,
1874
+ })
1875
+ }}
1876
+ >
1877
+ <Image
1878
+ src={getPropSprite(prop.id)}
1879
+ alt=""
1880
+ aria-hidden="true"
1881
+ fill
1882
+ unoptimized
1883
+ className="object-contain opacity-95"
1884
+ style={{ imageRendering: 'pixelated', filter: themePalette.spriteFilter }}
1885
+ draggable={false}
1886
+ />
1887
+ </div>
1888
+ ))}
1889
+
1890
+ <svg className="absolute inset-0 w-full h-full pointer-events-none" aria-hidden="true">
1891
+ {pathEdges.map((edge, idx) => (
1892
+ <line
1893
+ key={`edge-${idx}`}
1894
+ x1={`${edge.x1}%`}
1895
+ y1={`${edge.y1}%`}
1896
+ x2={`${edge.x2}%`}
1897
+ y2={`${edge.y2}%`}
1898
+ stroke="rgba(170, 203, 255, 0.42)"
1899
+ strokeWidth="2"
1900
+ strokeDasharray="4 6"
1901
+ />
1902
+ ))}
1903
+ </svg>
1904
+
1905
+ {renderedWorkers.map(({ agent, x, y, zoneLabel, seatLabel, isMoving, direction }) => (
1906
+ <div key={agent.id}>
1907
+ <div
1908
+ className="absolute -translate-x-1/2 pointer-events-none"
1909
+ style={{ left: `${x}%`, top: `calc(${y}% - 14px)` }}
1910
+ >
1911
+ <Image
1912
+ src="/office-sprites/kenney/chairDesk.png"
1913
+ alt=""
1914
+ aria-hidden="true"
1915
+ width={22}
1916
+ height={21}
1917
+ unoptimized
1918
+ className="w-6 h-6 object-contain opacity-90"
1919
+ style={{ imageRendering: 'pixelated' }}
1920
+ draggable={false}
1921
+ />
1922
+ </div>
1923
+ <div
1924
+ className="absolute -translate-x-1/2 pointer-events-none"
1925
+ style={{ left: `${x}%`, top: `calc(${y}% - 56px)` }}
1926
+ >
1927
+ <div className="relative w-16 h-9">
1928
+ <Image
1929
+ src="/office-sprites/kenney/desk.png"
1930
+ alt=""
1931
+ aria-hidden="true"
1932
+ width={64}
1933
+ height={32}
1934
+ unoptimized
1935
+ className="w-16 h-9 object-contain opacity-95"
1936
+ style={{ imageRendering: 'pixelated', filter: themePalette.spriteFilter }}
1937
+ draggable={false}
1938
+ />
1939
+ <Image
1940
+ src="/office-sprites/kenney/computerScreen.png"
1941
+ alt=""
1942
+ aria-hidden="true"
1943
+ width={20}
1944
+ height={6}
1945
+ unoptimized
1946
+ className="absolute left-1/2 -translate-x-1/2 top-[6px] w-7 h-2 object-contain opacity-95"
1947
+ style={{ imageRendering: 'pixelated', filter: themePalette.spriteFilter }}
1948
+ draggable={false}
1949
+ />
1950
  </div>
1951
  </div>
1952
 
1953
+ <button
1954
+ onClick={() => setSelectedAgent(agent)}
1955
+ className="absolute -translate-x-1/2 -translate-y-1/2 transition-all duration-500 hover:scale-110"
1956
+ style={{ left: `${x}%`, top: `${y}%` }}
1957
+ >
1958
+ <div className="absolute -top-7 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full bg-black/70 border border-white/10 text-white text-[11px] px-2 py-0.5 shadow-[0_0_12px_rgba(0,0,0,0.4)]">
1959
+ <span className={`inline-block w-2 h-2 rounded-full ${statusDot[agent.status]} mr-1`} />
1960
+ {agent.name}
1961
+ </div>
1962
+ <div className="absolute -top-12 left-1/2 -translate-x-1/2 text-sm">
1963
+ <span className={`${agent.status === 'busy' ? 'animate-bounce' : 'animate-pulse'}`}>{getStatusEmote(agent.status)}</span>
1964
+ </div>
1965
+ <div className="relative w-8 h-12 mx-auto">
1966
+ <div
1967
+ className={`absolute inset-0 ${transitioningAgentIds.has(agent.id) || isMoving ? 'animate-pulse' : ''}`}
1968
+ style={{
1969
+ backgroundImage: `url('/office-sprites/cc0-hero/player_full_animation.png')`,
1970
+ backgroundRepeat: 'no-repeat',
1971
+ backgroundSize: `${HERO_SHEET_COLS * 100}% ${HERO_SHEET_ROWS * 100}%`,
1972
+ backgroundPosition: (() => {
1973
+ const frame = getWorkerHeroFrame(agent.status, isMoving, spriteFrame)
1974
+ const xPct = (frame.col / (HERO_SHEET_COLS - 1)) * 100
1975
+ const yPct = (frame.row / (HERO_SHEET_ROWS - 1)) * 100
1976
+ return `${xPct}% ${yPct}%`
1977
+ })(),
1978
+ imageRendering: 'pixelated',
1979
+ filter: themePalette.spriteFilter,
1980
+ transform: isMoving && Math.abs(direction.dx) > Math.abs(direction.dy) && direction.dx < 0 ? 'scaleX(-1)' : undefined,
1981
+ transformOrigin: 'center',
1982
+ }}
1983
+ />
1984
+ <div className={`absolute left-[8px] top-[14px] w-4 h-3 ${hashColor(agent.name)} border border-black/60`} />
1985
+ </div>
1986
+ {!isMoving && <div className="text-[9px] text-slate-300 font-mono mt-0.5">#{seatLabel}</div>}
1987
+ </button>
1988
 
1989
+ {agentActionOverrides.has(agent.id) && (
1990
+ <div
1991
+ className="absolute -translate-x-1/2 text-[9px] px-1.5 py-0.5 rounded bg-black/70 border border-white/15 text-cyan-200"
1992
+ style={{ left: `${x}%`, top: `calc(${y}% - 24px)` }}
1993
+ >
1994
+ {agentActionOverrides.get(agent.id)}
1995
  </div>
1996
  )}
1997
 
1998
+ {(transitioningAgentIds.has(agent.id) || isMoving) && (
1999
+ <div
2000
+ className="absolute -translate-x-1/2 text-[9px] text-slate-200/85 font-medium px-1.5 py-0.5 rounded bg-black/45 border border-white/10"
2001
+ style={{ left: `${x}%`, top: `calc(${y}% + 22px)` }}
2002
+ >
2003
+ moving
2004
  </div>
2005
  )}
2006
+
2007
+ <div
2008
+ className="absolute text-[9px] text-slate-500/70 font-mono pointer-events-none"
2009
+ style={{ left: `${x}%`, top: `calc(${y}% + 38px)` }}
2010
+ >
2011
+ {zoneLabel}
2012
+ </div>
2013
  </div>
2014
  ))}
2015
  </div>
2016
 
2017
+ {showMinimap && (
2018
+ <div
2019
+ className="absolute right-3 bottom-3 z-30 w-44 h-28 rounded-md border border-white/15 bg-[#0b1220]/85 backdrop-blur-sm p-1.5"
2020
+ onMouseDown={(event) => event.stopPropagation()}
2021
+ onClick={(event) => {
2022
+ event.stopPropagation()
2023
+ const target = event.currentTarget
2024
+ const rect = target.getBoundingClientRect()
2025
+ const x = clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100)
2026
+ const y = clamp(((event.clientY - rect.top) / rect.height) * 100, 0, 100)
2027
+ focusMapPoint(x, y)
2028
+ }}
2029
+ >
2030
+ <div className="text-[9px] text-slate-300 uppercase tracking-wider mb-1">Minimap</div>
2031
+ <div className="relative w-full h-[calc(100%-16px)] rounded-sm overflow-hidden border border-white/10 bg-[#111a2f]">
2032
+ {roomLayoutState.map((room) => (
2033
+ <div
2034
+ key={`mini-${room.id}`}
2035
+ className="absolute border border-white/15 bg-white/10"
2036
+ style={{ left: `${room.x}%`, top: `${room.y}%`, width: `${room.w}%`, height: `${room.h}%` }}
2037
+ />
2038
+ ))}
2039
+ <div className="absolute left-[14%] top-[47%] w-[72%] h-[4%] bg-[#6f80a7]" />
2040
+ {renderedWorkers.map((worker) => (
2041
+ <button
2042
+ key={`mini-worker-${worker.agent.id}`}
2043
+ className={`absolute w-2.5 h-2.5 rounded-full -translate-x-1/2 -translate-y-1/2 ${hashColor(worker.agent.name)} border border-black/40`}
2044
+ style={{ left: `${worker.x}%`, top: `${worker.y}%` }}
2045
+ onClick={(event) => {
2046
+ event.stopPropagation()
2047
+ setSelectedAgent(worker.agent)
2048
+ focusMapPoint(worker.x, worker.y)
2049
+ }}
2050
+ title={worker.agent.name}
2051
+ />
2052
+ ))}
2053
+ </div>
2054
+ </div>
2055
+ )}
2056
+
2057
+ {showEvents && (
2058
+ <div
2059
+ className="absolute left-3 bottom-3 z-30 w-72 rounded-md border border-white/15 bg-[#0b1220]/88 backdrop-blur-sm p-2.5 space-y-2"
2060
+ onWheel={(event) => event.stopPropagation()}
2061
+ >
2062
+ <div className="text-[10px] text-slate-300 uppercase tracking-wider">Office Events</div>
2063
+ <div className="flex items-center gap-2 text-[10px] text-slate-400">
2064
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-300" />Busy Heat</span>
2065
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-300" />Idle Heat</span>
2066
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-sky-300" />Other</span>
2067
+ </div>
2068
+ <div className="space-y-1.5 max-h-36 overflow-y-auto pr-1" onWheel={(event) => event.stopPropagation()}>
2069
+ {officeEvents.length === 0 && (
2070
+ <div className="text-[11px] text-slate-500">No events yet. Click a room/desk or run an action.</div>
2071
+ )}
2072
+ {officeEvents.map((event) => (
2073
+ <div key={event.id} className="text-[11px] rounded px-2 py-1 bg-black/35 border border-white/10">
2074
+ <div className="flex items-center justify-between gap-2">
2075
+ <span
2076
+ className={`uppercase text-[9px] ${
2077
+ event.severity === 'good'
2078
+ ? 'text-emerald-300'
2079
+ : event.severity === 'warn'
2080
+ ? 'text-amber-300'
2081
+ : 'text-sky-300'
2082
+ }`}
2083
+ >
2084
+ {event.kind}
2085
+ </span>
2086
+ <span className="text-slate-500 text-[9px]">{formatLastSeen(Math.floor(event.at / 1000))}</span>
2087
+ </div>
2088
+ <div className="text-slate-200">{event.message}</div>
2089
+ </div>
2090
+ ))}
2091
+ </div>
2092
+ {selectedHotspot && (
2093
+ <div className="rounded border border-white/10 bg-black/35 p-2">
2094
+ <div className="flex items-center justify-between">
2095
+ <div className="text-[11px] font-semibold text-white">{selectedHotspot.label}</div>
2096
+ <div className="text-[9px] uppercase text-slate-400">{selectedHotspot.kind}</div>
2097
+ </div>
2098
+ <div className="mt-1.5 space-y-1">
2099
+ {selectedHotspot.stats.map((line) => (
2100
+ <div key={line} className="text-[10px] text-slate-300">{line}</div>
2101
+ ))}
2102
+ </div>
2103
+ <div className="mt-2 grid grid-cols-3 gap-1">
2104
+ <button onClick={() => nudgeSelectedHotspot(0, -1)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Up</button>
2105
+ <button onClick={() => nudgeSelectedHotspot(-1, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Left</button>
2106
+ <button onClick={() => nudgeSelectedHotspot(1, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Right</button>
2107
+ <button onClick={() => nudgeSelectedHotspot(0, 1)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Down</button>
2108
+ <button onClick={() => nudgeSelectedHotspot(-0.5, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Fine -X</button>
2109
+ <button onClick={() => nudgeSelectedHotspot(0.5, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Fine +X</button>
2110
+ </div>
2111
+ {selectedHotspot.kind === 'room' && (
2112
+ <div className="mt-1.5 grid grid-cols-2 gap-1">
2113
+ <button onClick={() => resizeSelectedRoom(1, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Wider</button>
2114
+ <button onClick={() => resizeSelectedRoom(-1, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Narrower</button>
2115
+ <button onClick={() => resizeSelectedRoom(0, 1)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Taller</button>
2116
+ <button onClick={() => resizeSelectedRoom(0, -1)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Shorter</button>
2117
+ </div>
2118
+ )}
2119
+ </div>
2120
+ )}
2121
  </div>
2122
+ )}
2123
  </div>
2124
  </div>
2125
  ) : (
 
2242
  <span className="font-medium">Session:</span> <code className="font-mono">{selectedAgent.session_key}</code>
2243
  </div>
2244
  )}
2245
+
2246
+ <div className="pt-1">
2247
+ <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1.5">Quick Actions</div>
2248
+ <div className="grid grid-cols-3 gap-1.5">
2249
+ <button
2250
+ onClick={() => executeAgentAction(selectedAgent, 'focus')}
2251
+ className="h-8 px-2 rounded border border-border bg-secondary text-[11px] hover:bg-surface-2"
2252
+ >
2253
+ Focus
2254
+ </button>
2255
+ <button
2256
+ onClick={() => executeAgentAction(selectedAgent, 'pair')}
2257
+ className="h-8 px-2 rounded border border-border bg-secondary text-[11px] hover:bg-surface-2"
2258
+ >
2259
+ Pair
2260
+ </button>
2261
+ <button
2262
+ onClick={() => executeAgentAction(selectedAgent, 'break')}
2263
+ className="h-8 px-2 rounded border border-border bg-secondary text-[11px] hover:bg-surface-2"
2264
+ >
2265
+ Break
2266
+ </button>
2267
+ </div>
2268
+ </div>
2269
+
2270
+ {isLocalMode && (
2271
+ <div className="pt-1">
2272
+ <button
2273
+ onClick={() => openFlightDeck(selectedAgent)}
2274
+ disabled={flightDeckLaunching}
2275
+ className="w-full h-9 px-3 rounded-md border border-border bg-secondary text-foreground text-xs hover:bg-surface-2 transition-smooth"
2276
+ >
2277
+ {flightDeckLaunching ? 'Opening Flight Deck...' : 'Open in Flight Deck'}
2278
+ </button>
2279
+ <div className="text-[10px] text-muted-foreground mt-1">
2280
+ Private/pro companion app for session deep-dive
2281
+ </div>
2282
+ </div>
2283
+ )}
2284
+ </div>
2285
+ </div>
2286
+ </div>
2287
+ )}
2288
+
2289
+ {showFlightDeckModal && (
2290
+ <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[60] p-4" onClick={() => setShowFlightDeckModal(false)}>
2291
+ <div className="bg-card border border-border rounded-xl max-w-md w-full p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
2292
+ <div className="flex items-start justify-between gap-3">
2293
+ <div>
2294
+ <h3 className="text-lg font-semibold text-foreground">Flight Deck Required</h3>
2295
+ <p className="text-sm text-muted-foreground mt-1">
2296
+ Flight Deck is the private/pro companion app for Mission Control.
2297
+ </p>
2298
+ </div>
2299
+ <button
2300
+ onClick={() => setShowFlightDeckModal(false)}
2301
+ className="text-muted-foreground hover:text-foreground text-xl"
2302
+ >
2303
+ ×
2304
+ </button>
2305
+ </div>
2306
+
2307
+ <div className="mt-4 rounded-lg border border-border bg-secondary/40 p-3 text-sm text-muted-foreground">
2308
+ It looks like Flight Deck is not installed on this machine.
2309
+ Install it to open agent sessions with richer controls and diagnostics.
2310
+ </div>
2311
+
2312
+ <div className="mt-5 flex items-center justify-end gap-2">
2313
+ <button
2314
+ onClick={() => setShowFlightDeckModal(false)}
2315
+ className="h-9 px-3 rounded-md border border-border text-sm text-foreground hover:bg-secondary/60 transition-smooth"
2316
+ >
2317
+ Maybe Later
2318
+ </button>
2319
+ <a
2320
+ href={flightDeckDownloadUrl}
2321
+ target="_blank"
2322
+ rel="noreferrer"
2323
+ className="h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-smooth inline-flex items-center"
2324
+ >
2325
+ Download Flight Deck
2326
+ </a>
2327
+ </div>
2328
+ </div>
2329
+ </div>
2330
+ )}
2331
+
2332
+ {launchToast && (
2333
+ <div className="fixed right-4 bottom-4 z-[70] max-w-sm rounded-lg border border-border bg-card/95 backdrop-blur px-4 py-3 shadow-2xl">
2334
+ <div className="flex items-start gap-2">
2335
+ <span
2336
+ className={`mt-1 inline-block h-2.5 w-2.5 rounded-full ${
2337
+ launchToast.kind === 'success'
2338
+ ? 'bg-green-400'
2339
+ : launchToast.kind === 'info'
2340
+ ? 'bg-blue-400'
2341
+ : 'bg-red-400'
2342
+ }`}
2343
+ />
2344
+ <div>
2345
+ <div className="text-sm font-semibold text-foreground">{launchToast.title}</div>
2346
+ <div className="text-xs text-muted-foreground mt-0.5">{launchToast.detail}</div>
2347
  </div>
2348
  </div>
2349
  </div>
2350
  )}
2351
+ <style jsx>{`
2352
+ @keyframes mcSunSweep {
2353
+ 0% { transform: translateX(-10%) translateY(-2%); opacity: 0.34; }
2354
+ 50% { transform: translateX(8%) translateY(2%); opacity: 0.56; }
2355
+ 100% { transform: translateX(-10%) translateY(-2%); opacity: 0.34; }
2356
+ }
2357
+ @keyframes mcSunSweepReverse {
2358
+ 0% { transform: translateX(8%) translateY(2%); opacity: 0.18; }
2359
+ 50% { transform: translateX(-8%) translateY(-2%); opacity: 0.32; }
2360
+ 100% { transform: translateX(8%) translateY(2%); opacity: 0.18; }
2361
+ }
2362
+ @keyframes mcDuskPulse {
2363
+ 0% { opacity: 0.28; transform: scale(1); }
2364
+ 50% { opacity: 0.52; transform: scale(1.03); }
2365
+ 100% { opacity: 0.28; transform: scale(1); }
2366
+ }
2367
+ @keyframes mcNightBloom {
2368
+ 0% { opacity: 0.25; }
2369
+ 50% { opacity: 0.5; }
2370
+ 100% { opacity: 0.25; }
2371
+ }
2372
+ @keyframes mcTwinkle {
2373
+ 0% { opacity: 0.25; transform: scale(0.9); }
2374
+ 50% { opacity: 1; transform: scale(1.15); }
2375
+ 100% { opacity: 0.25; transform: scale(0.9); }
2376
+ }
2377
+ `}</style>
2378
  </div>
2379
  )
2380
  }
src/components/panels/super-admin-panel.tsx CHANGED
@@ -63,16 +63,32 @@ interface GatewayOption {
63
  is_primary?: number
64
  }
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  const TENANT_PAGE_SIZE = 8
67
  const JOB_PAGE_SIZE = 8
68
 
69
  export function SuperAdminPanel() {
70
- const { currentUser } = useMissionControl()
 
71
 
72
  const [tenants, setTenants] = useState<TenantRow[]>([])
73
  const [jobs, setJobs] = useState<ProvisionJob[]>([])
74
  const [selectedJobId, setSelectedJobId] = useState<number | null>(null)
75
  const [selectedJobEvents, setSelectedJobEvents] = useState<ProvisionEvent[]>([])
 
76
  const [loading, setLoading] = useState(true)
77
  const [error, setError] = useState<string | null>(null)
78
  const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null)
@@ -123,25 +139,96 @@ export function SuperAdminPanel() {
123
 
124
  const load = useCallback(async () => {
125
  try {
126
- const [tenantsRes, jobsRes, gatewaysRes] = await Promise.all([
127
  fetch('/api/super/tenants', { cache: 'no-store' }),
128
  fetch('/api/super/provision-jobs?limit=250', { cache: 'no-store' }),
129
  fetch('/api/gateways', { cache: 'no-store' }),
 
130
  ])
131
 
132
  const tenantsJson = await tenantsRes.json().catch(() => ({}))
133
  const jobsJson = await jobsRes.json().catch(() => ({}))
134
  const gatewaysJson = await gatewaysRes.json().catch(() => ({}))
 
135
 
136
  if (!tenantsRes.ok) throw new Error(tenantsJson?.error || 'Failed to load tenants')
137
  if (!jobsRes.ok) throw new Error(jobsJson?.error || 'Failed to load provision jobs')
138
 
139
- const tenantRows = Array.isArray(tenantsJson?.tenants) ? tenantsJson.tenants : []
140
- const jobRows = Array.isArray(jobsJson?.jobs) ? jobsJson.jobs : []
141
  const gatewayRows = Array.isArray(gatewaysJson?.gateways) ? gatewaysJson.gateways : []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
  setTenants(tenantRows)
144
  setJobs(jobRows)
 
145
  setGatewayOptions(gatewayRows.map((g: any) => ({ id: Number(g.id), name: String(g.name), status: g.status, is_primary: g.is_primary })))
146
  setGatewayLoadError(gatewaysRes.ok ? null : (gatewaysJson?.error || 'Failed to load gateways'))
147
  setError(null)
@@ -150,9 +237,16 @@ export function SuperAdminPanel() {
150
  } finally {
151
  setLoading(false)
152
  }
153
- }, [])
154
 
155
  const loadJobDetail = useCallback(async (jobId: number) => {
 
 
 
 
 
 
 
156
  try {
157
  const res = await fetch(`/api/super/provision-jobs/${jobId}`, { cache: 'no-store' })
158
  const json = await res.json().catch(() => ({}))
@@ -163,7 +257,7 @@ export function SuperAdminPanel() {
163
  } catch (e: any) {
164
  showFeedback(false, e?.message || 'Failed to load job details')
165
  }
166
- }, [])
167
 
168
  useEffect(() => {
169
  load()
@@ -406,7 +500,9 @@ export function SuperAdminPanel() {
406
  <div>
407
  <h2 className="text-lg font-semibold text-foreground">Super Mission Control</h2>
408
  <p className="text-sm text-muted-foreground">
409
- Multi-tenant provisioning control plane with approval gates and safer destructive actions.
 
 
410
  </p>
411
  </div>
412
  <div className="flex items-center gap-2">
@@ -645,21 +741,27 @@ export function SuperAdminPanel() {
645
  )}
646
  </td>
647
  <td className="px-3 py-2 text-right relative">
648
- <button
649
- onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
650
- className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
651
- >
652
- Actions
653
- </button>
654
- {openActionMenu === menuKey && (
655
- <div className="absolute right-3 top-10 z-20 w-44 rounded-md border border-border bg-card shadow-xl text-left">
656
  <button
657
- onClick={() => openDecommissionDialog(tenant)}
658
- className="w-full px-3 py-2 text-xs text-red-300 hover:bg-red-500/10"
659
  >
660
- Queue Decommission
661
  </button>
662
- </div>
 
 
 
 
 
 
 
 
 
 
663
  )}
664
  </td>
665
  </tr>
@@ -758,42 +860,53 @@ export function SuperAdminPanel() {
758
  <div>Appr: {job.approved_by || '-'}</div>
759
  </td>
760
  <td className="px-3 py-2 text-right relative">
761
- <button
762
- onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
763
- className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
764
- >
765
- Actions
766
- </button>
767
- {openActionMenu === menuKey && (
768
- <div className="absolute right-3 top-10 z-20 w-40 rounded-md border border-border bg-card shadow-xl text-left">
769
- <button
770
- onClick={() => loadJobDetail(job.id)}
771
- className="w-full px-3 py-2 text-xs text-foreground hover:bg-secondary/40"
772
- >
773
- View events
774
- </button>
775
- <button
776
- onClick={() => setJobState(job.id, 'approve')}
777
- disabled={busyJobId === job.id || !['queued', 'rejected', 'failed'].includes(job.status)}
778
- className="w-full px-3 py-2 text-xs text-emerald-400 hover:bg-emerald-500/10 disabled:opacity-40"
779
- >
780
- Approve
781
- </button>
782
- <button
783
- onClick={() => setJobState(job.id, 'reject')}
784
- disabled={busyJobId === job.id || !['queued', 'approved', 'failed'].includes(job.status)}
785
- className="w-full px-3 py-2 text-xs text-amber-400 hover:bg-amber-500/10 disabled:opacity-40"
786
- >
787
- Reject
788
- </button>
789
  <button
790
- onClick={() => runJob(job.id)}
791
- disabled={busyJobId === job.id || job.status !== 'approved'}
792
- className="w-full px-3 py-2 text-xs text-primary hover:bg-primary/10 disabled:opacity-40"
793
  >
794
- {busyJobId === job.id ? 'Running...' : 'Run'}
795
  </button>
796
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
797
  )}
798
  </td>
799
  </tr>
 
63
  is_primary?: number
64
  }
65
 
66
+ interface SchedulerTask {
67
+ id: string
68
+ name: string
69
+ enabled: boolean
70
+ lastRun: number | null
71
+ nextRun: number
72
+ running: boolean
73
+ lastResult?: {
74
+ ok: boolean
75
+ message: string
76
+ timestamp: number
77
+ }
78
+ }
79
+
80
  const TENANT_PAGE_SIZE = 8
81
  const JOB_PAGE_SIZE = 8
82
 
83
  export function SuperAdminPanel() {
84
+ const { currentUser, dashboardMode } = useMissionControl()
85
+ const isLocal = dashboardMode === 'local'
86
 
87
  const [tenants, setTenants] = useState<TenantRow[]>([])
88
  const [jobs, setJobs] = useState<ProvisionJob[]>([])
89
  const [selectedJobId, setSelectedJobId] = useState<number | null>(null)
90
  const [selectedJobEvents, setSelectedJobEvents] = useState<ProvisionEvent[]>([])
91
+ const [localJobEvents, setLocalJobEvents] = useState<Record<number, ProvisionEvent[]>>({})
92
  const [loading, setLoading] = useState(true)
93
  const [error, setError] = useState<string | null>(null)
94
  const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null)
 
139
 
140
  const load = useCallback(async () => {
141
  try {
142
+ const [tenantsRes, jobsRes, gatewaysRes, schedulerRes] = await Promise.all([
143
  fetch('/api/super/tenants', { cache: 'no-store' }),
144
  fetch('/api/super/provision-jobs?limit=250', { cache: 'no-store' }),
145
  fetch('/api/gateways', { cache: 'no-store' }),
146
+ isLocal ? fetch('/api/scheduler', { cache: 'no-store' }) : Promise.resolve(null),
147
  ])
148
 
149
  const tenantsJson = await tenantsRes.json().catch(() => ({}))
150
  const jobsJson = await jobsRes.json().catch(() => ({}))
151
  const gatewaysJson = await gatewaysRes.json().catch(() => ({}))
152
+ const schedulerJson = schedulerRes ? await schedulerRes.json().catch(() => ({})) : {}
153
 
154
  if (!tenantsRes.ok) throw new Error(tenantsJson?.error || 'Failed to load tenants')
155
  if (!jobsRes.ok) throw new Error(jobsJson?.error || 'Failed to load provision jobs')
156
 
157
+ let tenantRows = Array.isArray(tenantsJson?.tenants) ? tenantsJson.tenants : []
158
+ let jobRows = Array.isArray(jobsJson?.jobs) ? jobsJson.jobs : []
159
  const gatewayRows = Array.isArray(gatewaysJson?.gateways) ? gatewaysJson.gateways : []
160
+ const schedulerTasks: SchedulerTask[] = Array.isArray(schedulerJson?.tasks) ? schedulerJson.tasks : []
161
+ const localEvents: Record<number, ProvisionEvent[]> = {}
162
+
163
+ if (isLocal) {
164
+ if (tenantRows.length === 0) {
165
+ const primaryGateway = gatewayRows.find((gw: any) => Number(gw?.is_primary) === 1)
166
+ const now = Math.floor(Date.now() / 1000)
167
+ tenantRows = [{
168
+ id: -1,
169
+ slug: 'local-system',
170
+ display_name: 'Local Mission Control',
171
+ linux_user: currentUser?.username || 'local',
172
+ created_by: 'local',
173
+ owner_gateway: primaryGateway?.name || 'local',
174
+ status: 'active',
175
+ plan_tier: 'local',
176
+ gateway_port: Number(primaryGateway?.port || 0) || null,
177
+ dashboard_port: null,
178
+ created_at: now,
179
+ latest_job_id: null,
180
+ latest_job_status: null,
181
+ }]
182
+ }
183
+
184
+ if (jobRows.length === 0 && schedulerTasks.length > 0) {
185
+ jobRows = schedulerTasks.map((task, index) => {
186
+ const id = -1000 - index
187
+ const status = task.running
188
+ ? 'running'
189
+ : (!task.enabled ? 'cancelled' : (task.lastResult?.ok === false ? 'failed' : (task.lastRun ? 'completed' : 'queued')))
190
+ const eventRows: ProvisionEvent[] = []
191
+ if (task.lastResult) {
192
+ eventRows.push({
193
+ id: id * -10,
194
+ level: task.lastResult.ok ? 'info' : 'error',
195
+ step_key: task.id,
196
+ message: task.lastResult.message,
197
+ created_at: Math.floor(task.lastResult.timestamp / 1000),
198
+ })
199
+ }
200
+ eventRows.push({
201
+ id: id * -10 + 1,
202
+ level: 'info',
203
+ step_key: task.id,
204
+ message: `Next run: ${new Date(task.nextRun).toLocaleString()}`,
205
+ created_at: Math.floor(Date.now() / 1000),
206
+ })
207
+ localEvents[id] = eventRows
208
+
209
+ const lastRunSec = task.lastRun ? Math.floor(task.lastRun / 1000) : null
210
+ return {
211
+ id,
212
+ tenant_id: -1,
213
+ tenant_slug: 'local-system',
214
+ tenant_display_name: 'Local Mission Control',
215
+ job_type: 'automation',
216
+ status,
217
+ dry_run: 1,
218
+ requested_by: 'scheduler',
219
+ approved_by: null,
220
+ started_at: lastRunSec,
221
+ completed_at: status !== 'running' ? lastRunSec : null,
222
+ error_text: task.lastResult?.ok === false ? task.lastResult.message : null,
223
+ created_at: lastRunSec || Math.floor(task.nextRun / 1000),
224
+ } as ProvisionJob
225
+ })
226
+ }
227
+ }
228
 
229
  setTenants(tenantRows)
230
  setJobs(jobRows)
231
+ setLocalJobEvents(localEvents)
232
  setGatewayOptions(gatewayRows.map((g: any) => ({ id: Number(g.id), name: String(g.name), status: g.status, is_primary: g.is_primary })))
233
  setGatewayLoadError(gatewaysRes.ok ? null : (gatewaysJson?.error || 'Failed to load gateways'))
234
  setError(null)
 
237
  } finally {
238
  setLoading(false)
239
  }
240
+ }, [currentUser?.username, isLocal])
241
 
242
  const loadJobDetail = useCallback(async (jobId: number) => {
243
+ if (isLocal && jobId < 0) {
244
+ setSelectedJobId(jobId)
245
+ setSelectedJobEvents(localJobEvents[jobId] || [])
246
+ setActiveTab('events')
247
+ return
248
+ }
249
+
250
  try {
251
  const res = await fetch(`/api/super/provision-jobs/${jobId}`, { cache: 'no-store' })
252
  const json = await res.json().catch(() => ({}))
 
257
  } catch (e: any) {
258
  showFeedback(false, e?.message || 'Failed to load job details')
259
  }
260
+ }, [isLocal, localJobEvents])
261
 
262
  useEffect(() => {
263
  load()
 
500
  <div>
501
  <h2 className="text-lg font-semibold text-foreground">Super Mission Control</h2>
502
  <p className="text-sm text-muted-foreground">
503
+ {isLocal
504
+ ? 'Local control plane view over scheduler automations and runtime state.'
505
+ : 'Multi-tenant provisioning control plane with approval gates and safer destructive actions.'}
506
  </p>
507
  </div>
508
  <div className="flex items-center gap-2">
 
741
  )}
742
  </td>
743
  <td className="px-3 py-2 text-right relative">
744
+ {isLocal && tenant.id < 0 ? (
745
+ <span className="text-[11px] text-muted-foreground">Local read-only</span>
746
+ ) : (
747
+ <>
 
 
 
 
748
  <button
749
+ onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
750
+ className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
751
  >
752
+ Actions
753
  </button>
754
+ {openActionMenu === menuKey && (
755
+ <div className="absolute right-3 top-10 z-20 w-44 rounded-md border border-border bg-card shadow-xl text-left">
756
+ <button
757
+ onClick={() => openDecommissionDialog(tenant)}
758
+ className="w-full px-3 py-2 text-xs text-red-300 hover:bg-red-500/10"
759
+ >
760
+ Queue Decommission
761
+ </button>
762
+ </div>
763
+ )}
764
+ </>
765
  )}
766
  </td>
767
  </tr>
 
860
  <div>Appr: {job.approved_by || '-'}</div>
861
  </td>
862
  <td className="px-3 py-2 text-right relative">
863
+ {isLocal && job.id < 0 ? (
864
+ <button
865
+ onClick={() => loadJobDetail(job.id)}
866
+ className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
867
+ >
868
+ View
869
+ </button>
870
+ ) : (
871
+ <>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
872
  <button
873
+ onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
874
+ className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
 
875
  >
876
+ Actions
877
  </button>
878
+ {openActionMenu === menuKey && (
879
+ <div className="absolute right-3 top-10 z-20 w-40 rounded-md border border-border bg-card shadow-xl text-left">
880
+ <button
881
+ onClick={() => loadJobDetail(job.id)}
882
+ className="w-full px-3 py-2 text-xs text-foreground hover:bg-secondary/40"
883
+ >
884
+ View events
885
+ </button>
886
+ <button
887
+ onClick={() => setJobState(job.id, 'approve')}
888
+ disabled={busyJobId === job.id || !['queued', 'rejected', 'failed'].includes(job.status)}
889
+ className="w-full px-3 py-2 text-xs text-emerald-400 hover:bg-emerald-500/10 disabled:opacity-40"
890
+ >
891
+ Approve
892
+ </button>
893
+ <button
894
+ onClick={() => setJobState(job.id, 'reject')}
895
+ disabled={busyJobId === job.id || !['queued', 'approved', 'failed'].includes(job.status)}
896
+ className="w-full px-3 py-2 text-xs text-amber-400 hover:bg-amber-500/10 disabled:opacity-40"
897
+ >
898
+ Reject
899
+ </button>
900
+ <button
901
+ onClick={() => runJob(job.id)}
902
+ disabled={busyJobId === job.id || job.status !== 'approved'}
903
+ className="w-full px-3 py-2 text-xs text-primary hover:bg-primary/10 disabled:opacity-40"
904
+ >
905
+ {busyJobId === job.id ? 'Running...' : 'Run'}
906
+ </button>
907
+ </div>
908
+ )}
909
+ </>
910
  )}
911
  </td>
912
  </tr>
src/components/panels/webhook-panel.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useState, useEffect, useCallback } from 'react'
4
  import { useSmartPoll } from '@/lib/use-smart-poll'
 
5
 
6
  interface Webhook {
7
  id: number
@@ -33,6 +34,16 @@ interface Delivery {
33
  created_at: number
34
  }
35
 
 
 
 
 
 
 
 
 
 
 
36
  const AVAILABLE_EVENTS = [
37
  { value: '*', label: 'All events', description: 'Receive all event types' },
38
  { value: 'agent.error', label: 'Agent error', description: 'Agent enters error state' },
@@ -48,7 +59,10 @@ const AVAILABLE_EVENTS = [
48
  ]
49
 
50
  export function WebhookPanel() {
 
 
51
  const [webhooks, setWebhooks] = useState<Webhook[]>([])
 
52
  const [deliveries, setDeliveries] = useState<Delivery[]>([])
53
  const [loading, setLoading] = useState(false)
54
  const [error, setError] = useState('')
@@ -57,6 +71,7 @@ export function WebhookPanel() {
57
  const [testingId, setTestingId] = useState<number | null>(null)
58
  const [testResult, setTestResult] = useState<any>(null)
59
  const [newSecret, setNewSecret] = useState<string | null>(null)
 
60
 
61
  const fetchWebhooks = useCallback(async () => {
62
  try {
@@ -88,9 +103,30 @@ export function WebhookPanel() {
88
  } catch { /* silent */ }
89
  }, [selectedWebhook])
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  useEffect(() => { fetchWebhooks() }, [fetchWebhooks])
92
  useEffect(() => { fetchDeliveries() }, [fetchDeliveries])
 
93
  useSmartPoll(fetchWebhooks, 60000, { pauseWhenDisconnected: true })
 
94
 
95
  async function handleCreate(form: { name: string; url: string; events: string[] }) {
96
  try {
@@ -142,6 +178,29 @@ export function WebhookPanel() {
142
  }
143
  }
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  function formatTime(ts: number) {
146
  return new Date(ts * 1000).toLocaleString(undefined, {
147
  month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
@@ -223,6 +282,41 @@ export function WebhookPanel() {
223
 
224
  {/* Webhook list */}
225
  <div className="space-y-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  {loading && webhooks.length === 0 ? (
227
  <div className="space-y-2">
228
  {[...Array(3)].map((_, i) => <div key={i} className="h-16 rounded-lg shimmer" />)}
 
2
 
3
  import { useState, useEffect, useCallback } from 'react'
4
  import { useSmartPoll } from '@/lib/use-smart-poll'
5
+ import { useMissionControl } from '@/store'
6
 
7
  interface Webhook {
8
  id: number
 
34
  created_at: number
35
  }
36
 
37
+ interface SchedulerTask {
38
+ id: string
39
+ name: string
40
+ enabled: boolean
41
+ lastRun: number | null
42
+ nextRun: number | null
43
+ running: boolean
44
+ lastResult?: { ok: boolean; message: string; timestamp: number }
45
+ }
46
+
47
  const AVAILABLE_EVENTS = [
48
  { value: '*', label: 'All events', description: 'Receive all event types' },
49
  { value: 'agent.error', label: 'Agent error', description: 'Agent enters error state' },
 
59
  ]
60
 
61
  export function WebhookPanel() {
62
+ const { dashboardMode } = useMissionControl()
63
+ const isLocalMode = dashboardMode === 'local'
64
  const [webhooks, setWebhooks] = useState<Webhook[]>([])
65
+ const [webhookAutomations, setWebhookAutomations] = useState<SchedulerTask[]>([])
66
  const [deliveries, setDeliveries] = useState<Delivery[]>([])
67
  const [loading, setLoading] = useState(false)
68
  const [error, setError] = useState('')
 
71
  const [testingId, setTestingId] = useState<number | null>(null)
72
  const [testResult, setTestResult] = useState<any>(null)
73
  const [newSecret, setNewSecret] = useState<string | null>(null)
74
+ const [runningAutomationId, setRunningAutomationId] = useState<string | null>(null)
75
 
76
  const fetchWebhooks = useCallback(async () => {
77
  try {
 
103
  } catch { /* silent */ }
104
  }, [selectedWebhook])
105
 
106
+ const fetchWebhookAutomations = useCallback(async () => {
107
+ if (!isLocalMode) {
108
+ setWebhookAutomations([])
109
+ return
110
+ }
111
+ try {
112
+ const res = await fetch('/api/scheduler')
113
+ if (!res.ok) return
114
+ const data = await res.json()
115
+ const tasks = Array.isArray(data.tasks) ? data.tasks : []
116
+ const webhookTasks = tasks.filter((task: SchedulerTask) =>
117
+ typeof task.id === 'string' && task.id.includes('webhook')
118
+ )
119
+ setWebhookAutomations(webhookTasks)
120
+ } catch {
121
+ // Keep UI usable if scheduler endpoint is unavailable.
122
+ }
123
+ }, [isLocalMode])
124
+
125
  useEffect(() => { fetchWebhooks() }, [fetchWebhooks])
126
  useEffect(() => { fetchDeliveries() }, [fetchDeliveries])
127
+ useEffect(() => { fetchWebhookAutomations() }, [fetchWebhookAutomations])
128
  useSmartPoll(fetchWebhooks, 60000, { pauseWhenDisconnected: true })
129
+ useSmartPoll(fetchWebhookAutomations, 60000, { pauseWhenDisconnected: true })
130
 
131
  async function handleCreate(form: { name: string; url: string; events: string[] }) {
132
  try {
 
178
  }
179
  }
180
 
181
+ async function handleRunAutomation(taskId: string) {
182
+ setRunningAutomationId(taskId)
183
+ try {
184
+ const res = await fetch('/api/scheduler', {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({ task_id: taskId }),
188
+ })
189
+ const data = await res.json()
190
+ setTestResult({
191
+ success: !!data.ok && res.ok,
192
+ error: data.error || (!data.ok ? data.message : null),
193
+ duration_ms: undefined,
194
+ status_code: res.status,
195
+ })
196
+ await fetchWebhookAutomations()
197
+ } catch {
198
+ setTestResult({ success: false, error: 'Failed to run local automation' })
199
+ } finally {
200
+ setRunningAutomationId(null)
201
+ }
202
+ }
203
+
204
  function formatTime(ts: number) {
205
  return new Date(ts * 1000).toLocaleString(undefined, {
206
  month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
 
282
 
283
  {/* Webhook list */}
284
  <div className="space-y-2">
285
+ {isLocalMode && webhookAutomations.length > 0 && (
286
+ <div className="rounded-lg border border-cyan-500/30 bg-cyan-500/5 p-3">
287
+ <h3 className="text-sm font-semibold text-cyan-200">Local Webhook Automations</h3>
288
+ <p className="text-2xs text-cyan-300/80 mt-0.5 mb-2">
289
+ Local scheduler tasks that support webhook delivery and retries
290
+ </p>
291
+ <div className="space-y-2">
292
+ {webhookAutomations.map((task) => (
293
+ <div key={task.id} className="rounded border border-cyan-500/20 bg-background/30 p-2.5">
294
+ <div className="flex items-center justify-between gap-2">
295
+ <div className="min-w-0">
296
+ <div className="flex items-center gap-2">
297
+ <span className={`w-2 h-2 rounded-full ${task.running ? 'bg-blue-400' : task.enabled ? 'bg-green-500' : 'bg-muted-foreground/40'}`} />
298
+ <span className="text-xs font-medium text-foreground truncate">{task.name}</span>
299
+ <span className="px-1.5 py-0.5 text-[10px] rounded bg-cyan-500/15 text-cyan-300 font-mono">{task.id}</span>
300
+ </div>
301
+ <div className="text-2xs text-muted-foreground mt-1">
302
+ {task.nextRun ? `Next run ${formatTime(task.nextRun / 1000)}` : 'No next run scheduled'}
303
+ {task.lastResult?.message ? ` · ${task.lastResult.message}` : ''}
304
+ </div>
305
+ </div>
306
+ <button
307
+ onClick={() => handleRunAutomation(task.id)}
308
+ disabled={runningAutomationId === task.id}
309
+ className="h-7 px-2.5 text-2xs font-medium text-cyan-300 hover:text-cyan-200 hover:bg-cyan-500/10 rounded transition-smooth disabled:opacity-50"
310
+ >
311
+ {runningAutomationId === task.id ? 'Running...' : 'Run'}
312
+ </button>
313
+ </div>
314
+ </div>
315
+ ))}
316
+ </div>
317
+ </div>
318
+ )}
319
+
320
  {loading && webhooks.length === 0 ? (
321
  <div className="space-y-2">
322
  {[...Array(3)].map((_, i) => <div key={i} className="h-16 rounded-lg shimmer" />)}
src/lib/codex-sessions.ts ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readdirSync, readFileSync, statSync } from 'fs'
2
+ import { basename, join } from 'path'
3
+ import { config } from './config'
4
+ import { logger } from './logger'
5
+
6
+ const ACTIVE_THRESHOLD_MS = 5 * 60 * 1000
7
+ const DEFAULT_FILE_SCAN_LIMIT = 120
8
+
9
+ export interface CodexSessionStats {
10
+ sessionId: string
11
+ projectSlug: string
12
+ projectPath: string | null
13
+ model: string | null
14
+ userMessages: number
15
+ assistantMessages: number
16
+ inputTokens: number
17
+ outputTokens: number
18
+ totalTokens: number
19
+ firstMessageAt: string | null
20
+ lastMessageAt: string | null
21
+ isActive: boolean
22
+ }
23
+
24
+ interface ParsedFile {
25
+ path: string
26
+ mtimeMs: number
27
+ }
28
+
29
+ function asObject(value: unknown): Record<string, unknown> | null {
30
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
31
+ return value as Record<string, unknown>
32
+ }
33
+
34
+ function asString(value: unknown): string | null {
35
+ return typeof value === 'string' && value.trim().length > 0 ? value : null
36
+ }
37
+
38
+ function asNumber(value: unknown): number | null {
39
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
40
+ }
41
+
42
+ function deriveSessionId(filePath: string): string {
43
+ const name = basename(filePath, '.jsonl')
44
+ const match = name.match(/([0-9a-f]{8,}-[0-9a-f-]{8,})$/i)
45
+ return match?.[1] || name
46
+ }
47
+
48
+ function listRecentCodexSessionFiles(limit: number): ParsedFile[] {
49
+ const root = join(config.homeDir, '.codex', 'sessions')
50
+ const files: ParsedFile[] = []
51
+ const stack = [root]
52
+
53
+ while (stack.length > 0) {
54
+ const dir = stack.pop()
55
+ if (!dir) continue
56
+
57
+ let entries: string[]
58
+ try {
59
+ entries = readdirSync(dir)
60
+ } catch {
61
+ continue
62
+ }
63
+
64
+ for (const entry of entries) {
65
+ const fullPath = join(dir, entry)
66
+ let stat
67
+ try {
68
+ stat = statSync(fullPath)
69
+ } catch {
70
+ continue
71
+ }
72
+
73
+ if (stat.isDirectory()) {
74
+ stack.push(fullPath)
75
+ continue
76
+ }
77
+
78
+ if (!stat.isFile() || !fullPath.endsWith('.jsonl')) continue
79
+ files.push({ path: fullPath, mtimeMs: stat.mtimeMs })
80
+ }
81
+ }
82
+
83
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs)
84
+ return files.slice(0, Math.max(1, limit))
85
+ }
86
+
87
+ function parseCodexSessionFile(filePath: string): CodexSessionStats | null {
88
+ let content: string
89
+ try {
90
+ content = readFileSync(filePath, 'utf-8')
91
+ } catch {
92
+ return null
93
+ }
94
+
95
+ const lines = content.split('\n').filter(Boolean)
96
+ if (lines.length === 0) return null
97
+
98
+ let sessionId = deriveSessionId(filePath)
99
+ let projectPath: string | null = null
100
+ let model: string | null = null
101
+ let userMessages = 0
102
+ let assistantMessages = 0
103
+ let inputTokens = 0
104
+ let outputTokens = 0
105
+ let totalTokens = 0
106
+ let firstMessageAt: string | null = null
107
+ let lastMessageAt: string | null = null
108
+
109
+ for (const line of lines) {
110
+ let parsed: unknown
111
+ try {
112
+ parsed = JSON.parse(line)
113
+ } catch {
114
+ continue
115
+ }
116
+
117
+ const entry = asObject(parsed)
118
+ if (!entry) continue
119
+
120
+ const timestamp = asString(entry.timestamp)
121
+ if (timestamp) {
122
+ if (!firstMessageAt) firstMessageAt = timestamp
123
+ lastMessageAt = timestamp
124
+ }
125
+
126
+ const entryType = asString(entry.type)
127
+ const payload = asObject(entry.payload)
128
+
129
+ if (entryType === 'session_meta' && payload) {
130
+ const metaId = asString(payload.id)
131
+ if (metaId) sessionId = metaId
132
+
133
+ const cwd = asString(payload.cwd)
134
+ if (cwd) projectPath = cwd
135
+
136
+ const metaModel = asString(payload.model)
137
+ if (metaModel) model = metaModel
138
+
139
+ const startedAt = asString(payload.timestamp)
140
+ if (startedAt && !firstMessageAt) firstMessageAt = startedAt
141
+ continue
142
+ }
143
+
144
+ if (entryType === 'response_item' && payload) {
145
+ const payloadType = asString(payload.type)
146
+ const role = asString(payload.role)
147
+ if (payloadType === 'message' && role === 'user') userMessages++
148
+ if (payloadType === 'message' && role === 'assistant') assistantMessages++
149
+ continue
150
+ }
151
+
152
+ if (entryType === 'event_msg' && payload) {
153
+ const msgType = asString(payload.type)
154
+ if (msgType !== 'token_count') continue
155
+
156
+ const info = asObject(payload.info)
157
+ const totals = info ? asObject(info.total_token_usage) : null
158
+ if (totals) {
159
+ const inTokens = asNumber(totals.input_tokens) || 0
160
+ const cached = asNumber(totals.cached_input_tokens) || 0
161
+ const outTokens = asNumber(totals.output_tokens) || 0
162
+ const allTokens = asNumber(totals.total_tokens) || (inTokens + cached + outTokens)
163
+ inputTokens = Math.max(inputTokens, inTokens + cached)
164
+ outputTokens = Math.max(outputTokens, outTokens)
165
+ totalTokens = Math.max(totalTokens, allTokens)
166
+ }
167
+
168
+ const limits = asObject(payload.rate_limits)
169
+ const limitName = limits ? asString(limits.limit_name) : null
170
+ if (!model && limitName) model = limitName
171
+ }
172
+ }
173
+
174
+ if (!lastMessageAt && !firstMessageAt) return null
175
+
176
+ const projectSlug = projectPath
177
+ ? basename(projectPath)
178
+ : 'codex-local'
179
+ const lastMessageMs = lastMessageAt ? new Date(lastMessageAt).getTime() : 0
180
+ const isActive = lastMessageMs > 0 && (Date.now() - lastMessageMs) < ACTIVE_THRESHOLD_MS
181
+
182
+ return {
183
+ sessionId,
184
+ projectSlug,
185
+ projectPath,
186
+ model,
187
+ userMessages,
188
+ assistantMessages,
189
+ inputTokens,
190
+ outputTokens,
191
+ totalTokens,
192
+ firstMessageAt,
193
+ lastMessageAt: lastMessageAt || firstMessageAt,
194
+ isActive,
195
+ }
196
+ }
197
+
198
+ export function scanCodexSessions(limit = DEFAULT_FILE_SCAN_LIMIT): CodexSessionStats[] {
199
+ try {
200
+ const files = listRecentCodexSessionFiles(limit)
201
+ const sessions: CodexSessionStats[] = []
202
+
203
+ for (const file of files) {
204
+ const parsed = parseCodexSessionFile(file.path)
205
+ if (parsed) sessions.push(parsed)
206
+ }
207
+
208
+ sessions.sort((a, b) => {
209
+ const aTs = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0
210
+ const bTs = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0
211
+ return bTs - aTs
212
+ })
213
+
214
+ return sessions
215
+ } catch (err) {
216
+ logger.warn({ err }, 'Failed to scan Codex sessions')
217
+ return []
218
+ }
219
+ }
src/lib/office-layout.ts ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Agent } from '@/store'
2
+
3
+ export type OfficeZoneType = 'engineering' | 'operations' | 'research' | 'product' | 'quality' | 'general'
4
+
5
+ export interface OfficeZoneDefinition {
6
+ id: OfficeZoneType
7
+ label: string
8
+ icon: string
9
+ accentClass: string
10
+ roleKeywords: string[]
11
+ }
12
+
13
+ export interface WorkstationAnchor {
14
+ deskId: string
15
+ seatLabel: string
16
+ row: number
17
+ col: number
18
+ x: number
19
+ y: number
20
+ }
21
+
22
+ export interface ZonedAgent {
23
+ agent: Agent
24
+ anchor: WorkstationAnchor
25
+ }
26
+
27
+ export interface OfficeZoneLayout {
28
+ zone: OfficeZoneDefinition
29
+ workers: ZonedAgent[]
30
+ }
31
+
32
+ export const OFFICE_ZONES: OfficeZoneDefinition[] = [
33
+ {
34
+ id: 'engineering',
35
+ label: 'Engineering Bay',
36
+ icon: '🧑‍💻',
37
+ accentClass: 'border-cyan-500/30 bg-cyan-500/10',
38
+ roleKeywords: ['engineer', 'dev', 'frontend', 'backend', 'fullstack', 'software'],
39
+ },
40
+ {
41
+ id: 'operations',
42
+ label: 'Operations Pod',
43
+ icon: '🛠️',
44
+ accentClass: 'border-amber-500/30 bg-amber-500/10',
45
+ roleKeywords: ['ops', 'sre', 'infra', 'platform', 'reliability'],
46
+ },
47
+ {
48
+ id: 'research',
49
+ label: 'Research Corner',
50
+ icon: '🔬',
51
+ accentClass: 'border-violet-500/30 bg-violet-500/10',
52
+ roleKeywords: ['research', 'science', 'analyst', 'ai'],
53
+ },
54
+ {
55
+ id: 'product',
56
+ label: 'Product Studio',
57
+ icon: '📐',
58
+ accentClass: 'border-emerald-500/30 bg-emerald-500/10',
59
+ roleKeywords: ['product', 'pm', 'design', 'ux', 'ui'],
60
+ },
61
+ {
62
+ id: 'quality',
63
+ label: 'Quality Lab',
64
+ icon: '🧪',
65
+ accentClass: 'border-rose-500/30 bg-rose-500/10',
66
+ roleKeywords: ['qa', 'test', 'quality'],
67
+ },
68
+ {
69
+ id: 'general',
70
+ label: 'General Workspace',
71
+ icon: '🏢',
72
+ accentClass: 'border-slate-500/30 bg-slate-500/10',
73
+ roleKeywords: [],
74
+ },
75
+ ]
76
+
77
+ function normalizeRole(role: string | undefined): string {
78
+ return String(role || '').toLowerCase()
79
+ }
80
+
81
+ export function getZoneByRole(role: string | undefined): OfficeZoneDefinition {
82
+ const normalized = normalizeRole(role)
83
+ for (const zone of OFFICE_ZONES) {
84
+ if (zone.id === 'general') continue
85
+ if (zone.roleKeywords.some((keyword) => normalized.includes(keyword))) {
86
+ return zone
87
+ }
88
+ }
89
+ return OFFICE_ZONES.find((zone) => zone.id === 'general')!
90
+ }
91
+
92
+ function buildAnchor(index: number, columnCount: number): WorkstationAnchor {
93
+ const row = Math.floor(index / columnCount)
94
+ const col = index % columnCount
95
+ const rowLabel = String.fromCharCode(65 + row)
96
+ const seatLabel = `${rowLabel}${col + 1}`
97
+ return {
98
+ deskId: `desk-${seatLabel.toLowerCase()}`,
99
+ seatLabel,
100
+ row,
101
+ col,
102
+ // Useful for future absolute-position movement/collision mechanics.
103
+ x: col * 220 + 110,
104
+ y: row * 160 + 80,
105
+ }
106
+ }
107
+
108
+ export function buildOfficeLayout(agents: Agent[]): OfficeZoneLayout[] {
109
+ const zoneMap = new Map<OfficeZoneType, Agent[]>()
110
+ for (const zone of OFFICE_ZONES) zoneMap.set(zone.id, [])
111
+
112
+ for (const agent of agents) {
113
+ const zone = getZoneByRole(agent.role)
114
+ zoneMap.get(zone.id)!.push(agent)
115
+ }
116
+
117
+ const result: OfficeZoneLayout[] = []
118
+ for (const zone of OFFICE_ZONES) {
119
+ const workers = zoneMap.get(zone.id) || []
120
+ if (workers.length === 0) continue
121
+
122
+ const columns = workers.length >= 8 ? 4 : workers.length >= 4 ? 3 : 2
123
+ const zoned = workers.map((agent, i) => ({
124
+ agent,
125
+ anchor: buildAnchor(i, columns),
126
+ }))
127
+
128
+ result.push({ zone, workers: zoned })
129
+ }
130
+
131
+ return result.sort((a, b) => b.workers.length - a.workers.length)
132
+ }