Nyk commited on
Commit
ab90450
Β·
1 Parent(s): 6b2e74b

feat: upgrade local-mode virtual office and flight deck integration

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 +1822 -62
  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
@@ -48,7 +48,8 @@ function formatDateLabel(date: Date): string {
48
  }
49
 
50
  export function CronManagementPanel() {
51
- const { cronJobs, setCronJobs } = useMissionControl()
 
52
  const [isLoading, setIsLoading] = useState(false)
53
  const [showAddForm, setShowAddForm] = useState(false)
54
  const [selectedJob, setSelectedJob] = useState<CronJob | null>(null)
@@ -86,15 +87,40 @@ export function CronManagementPanel() {
86
  const loadCronJobs = useCallback(async () => {
87
  setIsLoading(true)
88
  try {
89
- const response = await fetch('/api/cron?action=list')
90
- const data = await response.json()
91
- setCronJobs(data.jobs || [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  } catch (error) {
93
  console.error('Failed to load cron jobs:', error)
94
  } finally {
95
  setIsLoading(false)
96
  }
97
- }, [setCronJobs])
98
 
99
  useEffect(() => {
100
  loadCronJobs()
@@ -118,9 +144,44 @@ export function CronManagementPanel() {
118
  loadAvailableModels()
119
  }, [])
120
 
121
- const loadJobLogs = async (jobName: string) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  try {
123
- const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(jobName)}`)
124
  const data = await response.json()
125
  setJobLogs(data.logs || [])
126
  } catch (error) {
@@ -154,7 +215,24 @@ export function CronManagementPanel() {
154
  }
155
 
156
  const triggerJob = async (job: CronJob) => {
 
157
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  const response = await fetch('/api/cron', {
159
  method: 'POST',
160
  headers: { 'Content-Type': 'application/json' },
@@ -249,7 +327,7 @@ export function CronManagementPanel() {
249
 
250
  const handleJobSelect = (job: CronJob) => {
251
  setSelectedJob(job)
252
- loadJobLogs(job.name)
253
  }
254
 
255
  const getStatusColor = (status?: string) => {
@@ -392,7 +470,11 @@ export function CronManagementPanel() {
392
  <div className="flex flex-wrap items-center justify-between gap-3">
393
  <div>
394
  <h2 className="text-xl font-semibold">Calendar View</h2>
395
- <p className="text-sm text-muted-foreground">Read-only schedule visibility across all cron jobs</p>
 
 
 
 
396
  </div>
397
  <div className="flex items-center gap-2">
398
  <button
@@ -571,10 +653,12 @@ export function CronManagementPanel() {
571
  </div>
572
  ) : (
573
  <div className="space-y-3 max-h-96 overflow-y-auto">
574
- {cronJobs.map((job, index) => (
575
- <div
576
- key={`${job.name}-${index}`}
577
- className={`border border-border rounded-lg p-4 cursor-pointer transition-colors ${
 
 
578
  selectedJob?.name === job.name
579
  ? 'bg-primary/10 border-primary/30'
580
  : 'hover:bg-secondary'
@@ -589,13 +673,15 @@ export function CronManagementPanel() {
589
 
590
  {/* Job Type Tag */}
591
  <span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${
 
592
  job.name.includes('backup') ? 'bg-green-500/20 text-green-400 border-green-500/30' :
593
  job.name.includes('alert') ? 'bg-orange-500/20 text-orange-400 border-orange-500/30' :
594
  job.name.includes('brief') ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
595
  job.name.includes('scan') ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' :
596
  'bg-muted-foreground/10 text-muted-foreground border-muted-foreground/20'
597
  }`}>
598
- {job.name.includes('backup') ? 'BACKUP' :
 
599
  job.name.includes('alert') ? 'ALERT' :
600
  job.name.includes('brief') ? 'BRIEF' :
601
  job.name.includes('scan') ? 'SCAN' :
@@ -636,11 +722,12 @@ export function CronManagementPanel() {
636
  e.stopPropagation()
637
  toggleJob(job)
638
  }}
 
639
  className={`px-2 py-1 text-xs rounded ${
640
  job.enabled
641
  ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
642
  : 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
643
- } transition-colors`}
644
  >
645
  {job.enabled ? 'Disable' : 'Enable'}
646
  </button>
@@ -658,6 +745,7 @@ export function CronManagementPanel() {
658
  e.stopPropagation()
659
  removeJob(job)
660
  }}
 
661
  className="px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors"
662
  >
663
  Remove
@@ -665,7 +753,7 @@ export function CronManagementPanel() {
665
  </div>
666
  </div>
667
  </div>
668
- ))}
669
  </div>
670
  )}
671
  </div>
@@ -687,6 +775,9 @@ export function CronManagementPanel() {
687
  <div><span className="text-muted-foreground">Model:</span> <code className="font-mono text-xs">{selectedJob.model}</code></div>
688
  )}
689
  <div><span className="text-muted-foreground">Status:</span> {selectedJob.enabled ? '🟒 Enabled' : 'πŸ”΄ Disabled'}</div>
 
 
 
690
  {selectedJob.nextRun && (
691
  <div><span className="text-muted-foreground">Next run:</span> {new Date(selectedJob.nextRun).toLocaleString()}</div>
692
  )}
 
48
  }
49
 
50
  export function CronManagementPanel() {
51
+ const { cronJobs, setCronJobs, dashboardMode } = useMissionControl()
52
+ const isLocalMode = dashboardMode === 'local'
53
  const [isLoading, setIsLoading] = useState(false)
54
  const [showAddForm, setShowAddForm] = useState(false)
55
  const [selectedJob, setSelectedJob] = useState<CronJob | null>(null)
 
87
  const loadCronJobs = useCallback(async () => {
88
  setIsLoading(true)
89
  try {
90
+ const cronResponse = await fetch('/api/cron?action=list')
91
+ const cronData = await cronResponse.json()
92
+ const cronList = Array.isArray(cronData.jobs) ? cronData.jobs : []
93
+
94
+ if (!isLocalMode) {
95
+ setCronJobs(cronList)
96
+ return
97
+ }
98
+
99
+ const schedulerResponse = await fetch('/api/scheduler')
100
+ const schedulerData = await schedulerResponse.json()
101
+ const schedulerTasks = Array.isArray(schedulerData.tasks) ? schedulerData.tasks : []
102
+ const mappedSchedulerJobs: CronJob[] = schedulerTasks.map((task: any) => ({
103
+ id: task.id,
104
+ name: task.name || task.id || 'scheduler-task',
105
+ schedule: 'system-managed automation',
106
+ command: `Built-in local automation (${task.id || 'unknown'})`,
107
+ agentId: 'mission-control-local',
108
+ delivery: 'local',
109
+ enabled: task.running ? true : !!task.enabled,
110
+ lastRun: typeof task.lastRun === 'number' ? task.lastRun : undefined,
111
+ nextRun: typeof task.nextRun === 'number' ? task.nextRun : undefined,
112
+ lastStatus: task.running
113
+ ? 'running'
114
+ : (task.lastResult?.ok === false ? 'error' : (task.lastResult?.ok === true ? 'success' : undefined)),
115
+ }))
116
+
117
+ setCronJobs([...cronList, ...mappedSchedulerJobs])
118
  } catch (error) {
119
  console.error('Failed to load cron jobs:', error)
120
  } finally {
121
  setIsLoading(false)
122
  }
123
+ }, [isLocalMode, setCronJobs])
124
 
125
  useEffect(() => {
126
  loadCronJobs()
 
144
  loadAvailableModels()
145
  }, [])
146
 
147
+ const loadJobLogs = async (job: CronJob) => {
148
+ const isLocalAutomation = (job.delivery === 'local' && job.agentId === 'mission-control-local')
149
+ if (isLocalAutomation) {
150
+ const logs: Array<{ timestamp: number; message: string; level: string }> = []
151
+ if (job.lastRun) {
152
+ logs.push({
153
+ timestamp: job.lastRun,
154
+ message: `Last run recorded for ${job.name}`,
155
+ level: job.lastStatus === 'error' ? 'error' : 'info',
156
+ })
157
+ }
158
+ if (job.lastError) {
159
+ logs.push({
160
+ timestamp: job.lastRun || Date.now(),
161
+ message: `Error: ${job.lastError}`,
162
+ level: 'error',
163
+ })
164
+ }
165
+ if (job.nextRun) {
166
+ logs.push({
167
+ timestamp: Date.now(),
168
+ message: `Next scheduled run: ${new Date(job.nextRun).toLocaleString()}`,
169
+ level: 'info',
170
+ })
171
+ }
172
+ if (logs.length === 0) {
173
+ logs.push({
174
+ timestamp: Date.now(),
175
+ message: 'No scheduler telemetry available yet for this local automation task',
176
+ level: 'info',
177
+ })
178
+ }
179
+ setJobLogs(logs)
180
+ return
181
+ }
182
+
183
  try {
184
+ const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(job.name)}`)
185
  const data = await response.json()
186
  setJobLogs(data.logs || [])
187
  } catch (error) {
 
215
  }
216
 
217
  const triggerJob = async (job: CronJob) => {
218
+ const isLocalAutomation = (job.delivery === 'local' && job.agentId === 'mission-control-local')
219
  try {
220
+ if (isLocalAutomation) {
221
+ const response = await fetch('/api/scheduler', {
222
+ method: 'POST',
223
+ headers: { 'Content-Type': 'application/json' },
224
+ body: JSON.stringify({ task_id: job.id }),
225
+ })
226
+ const result = await response.json()
227
+ if (response.ok && result.ok) {
228
+ alert(`Local automation executed: ${result.message}`)
229
+ } else {
230
+ alert(`Local automation failed: ${result.error || result.message || 'Unknown error'}`)
231
+ }
232
+ await loadCronJobs()
233
+ return
234
+ }
235
+
236
  const response = await fetch('/api/cron', {
237
  method: 'POST',
238
  headers: { 'Content-Type': 'application/json' },
 
327
 
328
  const handleJobSelect = (job: CronJob) => {
329
  setSelectedJob(job)
330
+ loadJobLogs(job)
331
  }
332
 
333
  const getStatusColor = (status?: string) => {
 
470
  <div className="flex flex-wrap items-center justify-between gap-3">
471
  <div>
472
  <h2 className="text-xl font-semibold">Calendar View</h2>
473
+ <p className="text-sm text-muted-foreground">
474
+ {isLocalMode
475
+ ? 'Read-only schedule visibility across local cron jobs and automations'
476
+ : 'Read-only schedule visibility across all cron jobs'}
477
+ </p>
478
  </div>
479
  <div className="flex items-center gap-2">
480
  <button
 
653
  </div>
654
  ) : (
655
  <div className="space-y-3 max-h-96 overflow-y-auto">
656
+ {cronJobs.map((job, index) => {
657
+ const isLocalAutomation = job.delivery === 'local' && job.agentId === 'mission-control-local'
658
+ return (
659
+ <div
660
+ key={`${job.name}-${index}`}
661
+ className={`border border-border rounded-lg p-4 cursor-pointer transition-colors ${
662
  selectedJob?.name === job.name
663
  ? 'bg-primary/10 border-primary/30'
664
  : 'hover:bg-secondary'
 
673
 
674
  {/* Job Type Tag */}
675
  <span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${
676
+ isLocalAutomation ? 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30' :
677
  job.name.includes('backup') ? 'bg-green-500/20 text-green-400 border-green-500/30' :
678
  job.name.includes('alert') ? 'bg-orange-500/20 text-orange-400 border-orange-500/30' :
679
  job.name.includes('brief') ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
680
  job.name.includes('scan') ? 'bg-purple-500/20 text-purple-400 border-purple-500/30' :
681
  'bg-muted-foreground/10 text-muted-foreground border-muted-foreground/20'
682
  }`}>
683
+ {isLocalAutomation ? 'LOCAL AUTO' :
684
+ job.name.includes('backup') ? 'BACKUP' :
685
  job.name.includes('alert') ? 'ALERT' :
686
  job.name.includes('brief') ? 'BRIEF' :
687
  job.name.includes('scan') ? 'SCAN' :
 
722
  e.stopPropagation()
723
  toggleJob(job)
724
  }}
725
+ disabled={isLocalAutomation}
726
  className={`px-2 py-1 text-xs rounded ${
727
  job.enabled
728
  ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
729
  : 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
730
+ } transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
731
  >
732
  {job.enabled ? 'Disable' : 'Enable'}
733
  </button>
 
745
  e.stopPropagation()
746
  removeJob(job)
747
  }}
748
+ disabled={isLocalAutomation}
749
  className="px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors"
750
  >
751
  Remove
 
753
  </div>
754
  </div>
755
  </div>
756
+ )})}
757
  </div>
758
  )}
759
  </div>
 
775
  <div><span className="text-muted-foreground">Model:</span> <code className="font-mono text-xs">{selectedJob.model}</code></div>
776
  )}
777
  <div><span className="text-muted-foreground">Status:</span> {selectedJob.enabled ? '🟒 Enabled' : 'πŸ”΄ Disabled'}</div>
778
+ {selectedJob.delivery === 'local' && selectedJob.agentId === 'mission-control-local' && (
779
+ <div><span className="text-muted-foreground">Source:</span> Local scheduler automation</div>
780
+ )}
781
  {selectedJob.nextRun && (
782
  <div><span className="text-muted-foreground">Next run:</span> {new Date(selectedJob.nextRun).toLocaleString()}</div>
783
  )}
src/components/panels/office-panel.tsx CHANGED
@@ -1,14 +1,119 @@
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
 
8
- interface Desk {
9
- agent: Agent
10
- row: number
11
- col: number
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  }
13
 
14
  const statusGlow: Record<string, string> = {
@@ -60,6 +165,14 @@ function hashColor(name: string): string {
60
  return colors[Math.abs(hash) % colors.length]
61
  }
62
 
 
 
 
 
 
 
 
 
63
  function formatLastSeen(ts?: number): string {
64
  if (!ts) return 'Never seen'
65
  const diff = Date.now() - ts * 1000
@@ -71,32 +184,447 @@ function formatLastSeen(ts?: number): string {
71
  return `${Math.floor(h / 24)}d ago`
72
  }
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  export function OfficePanel() {
75
- const { agents } = useMissionControl()
 
76
  const [localAgents, setLocalAgents] = useState<Agent[]>([])
 
77
  const [viewMode, setViewMode] = useState<ViewMode>('office')
78
  const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
 
 
 
 
 
 
 
 
 
 
 
 
79
  const [loading, setLoading] = useState(true)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  const fetchAgents = useCallback(async () => {
 
 
 
82
  try {
83
- const res = await fetch('/api/agents')
84
- if (res.ok) {
85
- const data = await res.json()
86
- setLocalAgents(data.agents || [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  }
88
  } catch { /* ignore */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  setLoading(false)
90
- }, [])
91
 
92
  useEffect(() => { fetchAgents() }, [fetchAgents])
93
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  useEffect(() => {
95
  const interval = setInterval(fetchAgents, 10000)
96
  return () => clearInterval(interval)
97
  }, [fetchAgents])
98
 
99
- const displayAgents = agents.length > 0 ? agents : localAgents
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  const counts = useMemo(() => {
102
  const c = { idle: 0, busy: 0, error: 0, offline: 0 }
@@ -104,15 +632,6 @@ export function OfficePanel() {
104
  return c
105
  }, [displayAgents])
106
 
107
- const desks: Desk[] = useMemo(() => {
108
- const cols = Math.max(2, Math.ceil(Math.sqrt(displayAgents.length)))
109
- return displayAgents.map((agent, i) => ({
110
- agent,
111
- row: Math.floor(i / cols),
112
- col: i % cols,
113
- }))
114
- }, [displayAgents])
115
-
116
  const roleGroups = useMemo(() => {
117
  const groups = new Map<string, Agent[]>()
118
  for (const a of displayAgents) {
@@ -123,11 +642,738 @@ export function OfficePanel() {
123
  return groups
124
  }, [displayAgents])
125
 
126
- if (loading && displayAgents.length === 0) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  return (
128
  <div className="flex items-center justify-center h-64">
129
  <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
130
- <span className="ml-3 text-muted-foreground">Loading office...</span>
 
 
131
  </div>
132
  )
133
  }
@@ -175,64 +1421,476 @@ export function OfficePanel() {
175
  <p className="text-sm mt-1">Add agents to see them appear here</p>
176
  </div>
177
  ) : viewMode === 'office' ? (
178
- <div className="relative">
179
- <div className="bg-gradient-to-br from-surface-1/50 to-card rounded-xl border border-border p-6 min-h-[400px]">
180
- <div className="absolute top-4 left-6 text-xs text-muted-foreground/50 uppercase tracking-widest font-medium">Main Floor</div>
181
-
182
- <div className="mt-6 grid gap-6" style={{ gridTemplateColumns: `repeat(${Math.max(2, Math.ceil(Math.sqrt(displayAgents.length)))}, minmax(180px, 1fr))` }}>
183
- {desks.map(({ agent }) => (
184
- <div
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  key={agent.id}
186
- onClick={() => setSelectedAgent(agent)}
187
- 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]}`}
188
- style={{ background: 'var(--card)' }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  >
190
- <div className="absolute inset-x-3 bottom-0 h-1.5 bg-amber-900/20 rounded-t-sm" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
- <div className="absolute -top-2 -right-2 text-lg" title={statusLabel[agent.status]}>
193
- {statusEmoji[agent.status]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  </div>
 
 
195
 
196
- <div className="flex items-center gap-3 mb-3">
197
- <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'}`}>
198
- {getInitials(agent.name)}
199
- </div>
200
- <div className="min-w-0">
201
- <div className="font-semibold text-foreground text-sm truncate">{agent.name}</div>
202
- <div className="text-xs text-muted-foreground truncate">{agent.role}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  </div>
204
  </div>
205
 
206
- <div className="flex items-center justify-between text-xs">
207
- <span className="flex items-center gap-1">
208
- <span className={`w-1.5 h-1.5 rounded-full ${statusDot[agent.status]} ${agent.status === 'busy' ? 'animate-pulse' : ''}`} />
209
- <span className="text-muted-foreground">{statusLabel[agent.status]}</span>
210
- </span>
211
- <span className="text-muted-foreground/60">{formatLastSeen(agent.last_seen)}</span>
212
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
- {agent.last_activity && (
215
- <div className="mt-2 text-[10px] text-muted-foreground/50 truncate italic">
216
- {agent.last_activity}
 
 
 
217
  </div>
218
  )}
219
 
220
- {agent.taskStats && agent.taskStats.in_progress > 0 && (
221
- <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">
222
- {agent.taskStats.in_progress}
 
 
 
223
  </div>
224
  )}
 
 
 
 
 
 
 
225
  </div>
226
  ))}
227
  </div>
228
 
229
- <div className="mt-8 flex items-center gap-4 text-[10px] text-muted-foreground/30">
230
- <span>πŸͺ΄</span>
231
- <div className="flex-1 border-t border-dashed border-border/30" />
232
- <span>β˜• Break room</span>
233
- <div className="flex-1 border-t border-dashed border-border/30" />
234
- <span>πŸͺ΄</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  </div>
237
  </div>
238
  ) : (
@@ -326,6 +1984,108 @@ export function OfficePanel() {
326
  <span className="font-medium">Session:</span> <code className="font-mono">{selectedAgent.session_key}</code>
327
  </div>
328
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  </div>
330
  </div>
331
  </div>
 
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
 
11
+ interface SessionAgentRow {
12
+ id: string
13
+ key: string
14
+ agent: string
15
+ kind: string
16
+ model: string
17
+ active: boolean
18
+ lastActivity?: number
19
+ workingDir?: string | null
20
+ }
21
+
22
+ interface SeatPosition {
23
+ seatKey: string
24
+ x: number
25
+ y: number
26
+ }
27
+
28
+ interface MovingWorker {
29
+ id: string
30
+ agentId: number
31
+ initials: string
32
+ colorClass: string
33
+ startX: number
34
+ startY: number
35
+ endX: number
36
+ endY: number
37
+ startedAt: number
38
+ durationMs: number
39
+ progress: number
40
+ path: Array<{ x: number; y: number }>
41
+ pathLengths: number[]
42
+ totalLength: number
43
+ destinationTile: string
44
+ }
45
+
46
+ type SidebarFilter = 'all' | 'working' | 'idle' | 'attention'
47
+
48
+ interface MapRoom {
49
+ id: string
50
+ label: string
51
+ x: number
52
+ y: number
53
+ w: number
54
+ h: number
55
+ style: string
56
+ }
57
+
58
+ interface MapProp {
59
+ id: string
60
+ x: number
61
+ y: number
62
+ w: number
63
+ h: number
64
+ style: string
65
+ border: string
66
+ }
67
+
68
+ interface LaunchToast {
69
+ kind: 'success' | 'info' | 'error'
70
+ title: string
71
+ detail: string
72
+ }
73
+
74
+ type OfficeAction = 'focus' | 'pair' | 'break'
75
+ type TimeTheme = 'dawn' | 'day' | 'dusk' | 'night'
76
+
77
+ type HotspotKind = 'room' | 'desk'
78
+
79
+ interface OfficeHotspot {
80
+ kind: HotspotKind
81
+ id: string
82
+ label: string
83
+ x: number
84
+ y: number
85
+ stats: string[]
86
+ }
87
+
88
+ interface OfficeEvent {
89
+ id: string
90
+ kind: 'action' | 'room' | 'desk'
91
+ message: string
92
+ at: number
93
+ severity: 'info' | 'warn' | 'good'
94
+ }
95
+
96
+ interface ThemePalette {
97
+ shell: string
98
+ gridLine: string
99
+ haze: string
100
+ glow: string
101
+ corridor: string
102
+ corridorStripe: string
103
+ }
104
+
105
+ interface PersistedOfficePrefs {
106
+ version: 1
107
+ viewMode: ViewMode
108
+ sidebarFilter: SidebarFilter
109
+ mapZoom: number
110
+ mapPan: { x: number; y: number }
111
+ timeTheme: TimeTheme
112
+ showSidebar: boolean
113
+ showMinimap: boolean
114
+ showEvents: boolean
115
+ roomLayout: MapRoom[]
116
+ mapProps: MapProp[]
117
  }
118
 
119
  const statusGlow: Record<string, string> = {
 
165
  return colors[Math.abs(hash) % colors.length]
166
  }
167
 
168
+ function hashNumber(value: string): number {
169
+ let hash = 0
170
+ for (let i = 0; i < value.length; i += 1) {
171
+ hash = value.charCodeAt(i) + ((hash << 5) - hash)
172
+ }
173
+ return Math.abs(hash)
174
+ }
175
+
176
  function formatLastSeen(ts?: number): string {
177
  if (!ts) return 'Never seen'
178
  const diff = Date.now() - ts * 1000
 
184
  return `${Math.floor(h / 24)}d ago`
185
  }
186
 
187
+ function easeInOut(progress: number): number {
188
+ if (progress <= 0) return 0
189
+ if (progress >= 1) return 1
190
+ return progress < 0.5
191
+ ? 2 * progress * progress
192
+ : 1 - Math.pow(-2 * progress + 2, 2) / 2
193
+ }
194
+
195
+ function getStatusEmote(status: Agent['status']): string {
196
+ if (status === 'busy') return 'πŸ’Ό'
197
+ if (status === 'idle') return 'β˜•'
198
+ if (status === 'error') return '⚠️'
199
+ return 'πŸ’€'
200
+ }
201
+
202
+ function inferLocalRole(row: SessionAgentRow): string {
203
+ const context = [
204
+ String(row.agent || ''),
205
+ String(row.key || ''),
206
+ String(row.workingDir || ''),
207
+ String(row.kind || ''),
208
+ ].join(' ').toLowerCase()
209
+
210
+ if (/frontend|ui|ux|design|landing|web/.test(context)) return 'frontend-engineer'
211
+ if (/backend|api|server|platform|infra|ops|sre|deploy|k8s|docker/.test(context)) return 'ops-engineer'
212
+ if (/research|science|ml|ai|llm|data|analytics/.test(context)) return 'research-analyst'
213
+ if (/qa|test|e2e|spec|validation/.test(context)) return 'qa-engineer'
214
+ if (/product|pm|roadmap|strategy/.test(context)) return 'product-manager'
215
+ if (/codex|claude|agent/.test(context)) return 'software-engineer'
216
+ return row.kind || 'local-session'
217
+ }
218
+
219
+ const MAP_COLS = 24
220
+ const MAP_ROWS = 16
221
+
222
+ const ROOM_LAYOUT: MapRoom[] = [
223
+ { id: 'eng', label: 'Engineering', x: 16, y: 22, w: 28, h: 22, style: 'bg-[#2a3558]' },
224
+ { id: 'product', label: 'Product', x: 48, y: 22, w: 24, h: 22, style: 'bg-[#213a4d]' },
225
+ { id: 'ops', label: 'Operations', x: 16, y: 49, w: 24, h: 24, style: 'bg-[#2f2f52]' },
226
+ { id: 'research', label: 'Research', x: 44, y: 49, w: 22, h: 24, style: 'bg-[#2b334c]' },
227
+ { id: 'lounge', label: 'Lounge', x: 70, y: 49, w: 16, h: 24, style: 'bg-[#2e4438]' },
228
+ ]
229
+
230
+ const MAP_PROPS: MapProp[] = [
231
+ { id: 'desk-a', x: 22, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
232
+ { id: 'desk-b', x: 33, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
233
+ { id: 'desk-c', x: 52, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
234
+ { id: 'desk-d', x: 61, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
235
+ { id: 'desk-e', x: 22, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
236
+ { id: 'desk-f', x: 31, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
237
+ { id: 'desk-g', x: 48, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
238
+ { id: 'desk-h', x: 57, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' },
239
+ { id: 'plant-l', x: 14, y: 47, w: 3, h: 5, style: 'bg-emerald-400/60', border: 'border-emerald-200/35' },
240
+ { id: 'plant-r', x: 84, y: 47, w: 3, h: 5, style: 'bg-emerald-400/60', border: 'border-emerald-200/35' },
241
+ { id: 'kitchen', x: 72, y: 57, w: 12, h: 10, style: 'bg-[#334137]', border: 'border-[#88d4a3]/35' },
242
+ ]
243
+
244
+ const LOUNGE_WAYPOINTS = [
245
+ { x: 74, y: 60 },
246
+ { x: 79, y: 60 },
247
+ { x: 82, y: 66 },
248
+ { x: 76, y: 68 },
249
+ ]
250
+
251
+ function getPropSprite(propId: string): string {
252
+ if (propId === 'desk-a' || propId === 'desk-b' || propId === 'desk-e' || propId === 'desk-f') return '/office-sprites/kenney/desk.png'
253
+ if (propId.startsWith('desk-')) return '/office-sprites/kenney/tableCross.png'
254
+ if (propId === 'plant-l') return '/office-sprites/kenney/plantSmall1.png'
255
+ if (propId === 'plant-r') return '/office-sprites/kenney/plantSmall2.png'
256
+ if (propId === 'kitchen') return '/office-sprites/kenney/rugRectangle.png'
257
+ return ''
258
+ }
259
+
260
+ const HERO_SHEET_COLS = 6
261
+ const HERO_SHEET_ROWS = 7
262
+
263
+ function getWorkerHeroFrame(status: Agent['status'], isMoving: boolean, frame: number) {
264
+ const phase = frame % 2
265
+ const walkCol = phase === 0 ? 1 : 3
266
+ if (isMoving) return { col: walkCol, row: 3 } // side-walk row
267
+ if (status === 'busy') return { col: walkCol, row: 0 } // forward loop as typing proxy
268
+ if (status === 'error') return { col: 5, row: 6 }
269
+ return { col: phase === 0 ? 0 : 5, row: 0 } // idle pulse
270
+ }
271
+
272
+ interface WorkerVariant {
273
+ id: string
274
+ filter: string
275
+ accent: string
276
+ }
277
+
278
+ const WORKER_VARIANTS: WorkerVariant[] = [
279
+ { id: 'default', filter: 'none', accent: 'border-cyan-300/60' },
280
+ { id: 'warm', filter: 'hue-rotate(18deg) saturate(1.08)', accent: 'border-amber-300/60' },
281
+ { id: 'cool', filter: 'hue-rotate(-20deg) saturate(1.1)', accent: 'border-sky-300/60' },
282
+ { id: 'mint', filter: 'hue-rotate(42deg) saturate(1.08)', accent: 'border-emerald-300/60' },
283
+ { id: 'violet', filter: 'hue-rotate(64deg) saturate(1.12)', accent: 'border-violet-300/60' },
284
+ ]
285
+
286
+ function getWorkerVariant(name: string): WorkerVariant {
287
+ return WORKER_VARIANTS[hashNumber(name) % WORKER_VARIANTS.length]
288
+ }
289
+
290
+ function clamp(value: number, min: number, max: number) {
291
+ return Math.max(min, Math.min(max, value))
292
+ }
293
+
294
+ function toTile(xPercent: number, yPercent: number) {
295
+ const col = clamp(Math.round((xPercent / 100) * (MAP_COLS - 1)), 0, MAP_COLS - 1)
296
+ const row = clamp(Math.round((yPercent / 100) * (MAP_ROWS - 1)), 0, MAP_ROWS - 1)
297
+ return { col, row }
298
+ }
299
+
300
+ function tileToPercent(col: number, row: number) {
301
+ const x = (col / (MAP_COLS - 1)) * 100
302
+ const y = (row / (MAP_ROWS - 1)) * 100
303
+ return { x, y }
304
+ }
305
+
306
+ function buildWalkabilityGrid() {
307
+ const walkable: boolean[][] = Array.from({ length: MAP_ROWS }, () => Array.from({ length: MAP_COLS }, () => true))
308
+ // Border walls
309
+ for (let r = 0; r < MAP_ROWS; r += 1) {
310
+ walkable[r][0] = false
311
+ walkable[r][MAP_COLS - 1] = false
312
+ }
313
+ for (let c = 0; c < MAP_COLS; c += 1) {
314
+ walkable[0][c] = false
315
+ walkable[MAP_ROWS - 1][c] = false
316
+ }
317
+
318
+ // Block static furniture/obstacles so routes prefer corridor lanes.
319
+ const obstacleRects = [
320
+ { c1: 5, c2: 8, r1: 4, r2: 5 },
321
+ { c1: 9, c2: 12, r1: 4, r2: 5 },
322
+ { c1: 13, c2: 16, r1: 4, r2: 5 },
323
+ { c1: 17, c2: 20, r1: 4, r2: 5 },
324
+ { c1: 5, c2: 8, r1: 9, r2: 10 },
325
+ { c1: 9, c2: 12, r1: 9, r2: 10 },
326
+ { c1: 13, c2: 16, r1: 9, r2: 10 },
327
+ { c1: 17, c2: 20, r1: 9, r2: 10 },
328
+ { c1: 18, c2: 21, r1: 10, r2: 13 },
329
+ ]
330
+ for (const rect of obstacleRects) {
331
+ for (let r = rect.r1; r <= rect.r2; r += 1) {
332
+ for (let c = rect.c1; c <= rect.c2; c += 1) {
333
+ if (r >= 0 && r < MAP_ROWS && c >= 0 && c < MAP_COLS) walkable[r][c] = false
334
+ }
335
+ }
336
+ }
337
+
338
+ // Keep a central horizontal corridor open.
339
+ const corridorRow = 7
340
+ for (let c = 1; c < MAP_COLS - 1; c += 1) walkable[corridorRow][c] = true
341
+ return walkable
342
+ }
343
+
344
+ function tileKey(col: number, row: number): string {
345
+ return `${col},${row}`
346
+ }
347
+
348
+ function findGridPath(start: { col: number; row: number }, end: { col: number; row: number }, walkable: boolean[][]) {
349
+ const inBounds = (col: number, row: number) => row >= 0 && row < MAP_ROWS && col >= 0 && col < MAP_COLS
350
+ const key = (col: number, row: number) => `${col},${row}`
351
+ const parse = (k: string) => {
352
+ const [c, r] = k.split(',').map(Number)
353
+ return { col: c, row: r }
354
+ }
355
+
356
+ const open = new Set<string>([key(start.col, start.row)])
357
+ const cameFrom = new Map<string, string>()
358
+ const gScore = new Map<string, number>([[key(start.col, start.row), 0]])
359
+ const fScore = new Map<string, number>([[key(start.col, start.row), Math.abs(start.col - end.col) + Math.abs(start.row - end.row)]])
360
+
361
+ while (open.size > 0) {
362
+ let currentKey = ''
363
+ let lowest = Number.POSITIVE_INFINITY
364
+ for (const k of open) {
365
+ const f = fScore.get(k) ?? Number.POSITIVE_INFINITY
366
+ if (f < lowest) {
367
+ lowest = f
368
+ currentKey = k
369
+ }
370
+ }
371
+ if (!currentKey) break
372
+
373
+ const current = parse(currentKey)
374
+ if (current.col === end.col && current.row === end.row) {
375
+ const path = [current]
376
+ let ck = currentKey
377
+ while (cameFrom.has(ck)) {
378
+ ck = cameFrom.get(ck)!
379
+ path.push(parse(ck))
380
+ }
381
+ path.reverse()
382
+ return path
383
+ }
384
+
385
+ open.delete(currentKey)
386
+ const neighbors = [
387
+ { col: current.col + 1, row: current.row },
388
+ { col: current.col - 1, row: current.row },
389
+ { col: current.col, row: current.row + 1 },
390
+ { col: current.col, row: current.row - 1 },
391
+ ]
392
+
393
+ for (const n of neighbors) {
394
+ if (!inBounds(n.col, n.row)) continue
395
+ if (!walkable[n.row][n.col]) continue
396
+ const nk = key(n.col, n.row)
397
+ const tentative = (gScore.get(currentKey) ?? Number.POSITIVE_INFINITY) + 1
398
+ if (tentative >= (gScore.get(nk) ?? Number.POSITIVE_INFINITY)) continue
399
+ cameFrom.set(nk, currentKey)
400
+ gScore.set(nk, tentative)
401
+ fScore.set(nk, tentative + Math.abs(n.col - end.col) + Math.abs(n.row - end.row))
402
+ open.add(nk)
403
+ }
404
+ }
405
+
406
+ return [start, end]
407
+ }
408
+
409
+ function buildPath(startX: number, startY: number, endX: number, endY: number, blockedTiles: Set<string> = new Set()) {
410
+ const walkable = buildWalkabilityGrid()
411
+ const startTile = toTile(startX, startY)
412
+ const endTile = toTile(endX, endY)
413
+ for (const tile of blockedTiles) {
414
+ const [col, row] = tile.split(',').map(Number)
415
+ if (!Number.isFinite(col) || !Number.isFinite(row)) continue
416
+ if (row < 0 || row >= MAP_ROWS || col < 0 || col >= MAP_COLS) continue
417
+ walkable[row][col] = false
418
+ }
419
+ // Start/end must always be traversable.
420
+ walkable[startTile.row][startTile.col] = true
421
+ walkable[endTile.row][endTile.col] = true
422
+ const tilePath = findGridPath(startTile, endTile, walkable)
423
+ const path = tilePath.map((tile) => tileToPercent(tile.col, tile.row))
424
+ const pathLengths: number[] = [0]
425
+ let totalLength = 0
426
+ for (let i = 1; i < path.length; i += 1) {
427
+ const dx = path[i].x - path[i - 1].x
428
+ const dy = path[i].y - path[i - 1].y
429
+ totalLength += Math.hypot(dx, dy)
430
+ pathLengths.push(totalLength)
431
+ }
432
+ return { path, pathLengths, totalLength }
433
+ }
434
+
435
+ function pointAlongPath(path: Array<{ x: number; y: number }>, pathLengths: number[], totalLength: number, progress: number) {
436
+ if (path.length === 0) return { x: 0, y: 0 }
437
+ if (path.length === 1 || totalLength <= 0) return path[path.length - 1]
438
+ const target = totalLength * clamp(progress, 0, 1)
439
+ let idx = 1
440
+ while (idx < pathLengths.length && pathLengths[idx] < target) idx += 1
441
+ const prevIdx = Math.max(0, idx - 1)
442
+ const prevLen = pathLengths[prevIdx] ?? 0
443
+ const nextLen = pathLengths[Math.min(idx, pathLengths.length - 1)] ?? totalLength
444
+ const local = nextLen > prevLen ? (target - prevLen) / (nextLen - prevLen) : 0
445
+ const a = path[prevIdx]
446
+ const b = path[Math.min(idx, path.length - 1)]
447
+ return {
448
+ x: a.x + (b.x - a.x) * local,
449
+ y: a.y + (b.y - a.y) * local,
450
+ }
451
+ }
452
+
453
  export function OfficePanel() {
454
+ const { agents, dashboardMode, currentUser } = useMissionControl()
455
+ const isLocalMode = dashboardMode === 'local'
456
  const [localAgents, setLocalAgents] = useState<Agent[]>([])
457
+ const [sessionAgents, setSessionAgents] = useState<Agent[]>([])
458
  const [viewMode, setViewMode] = useState<ViewMode>('office')
459
  const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
460
+ const [showFlightDeckModal, setShowFlightDeckModal] = useState(false)
461
+ const [flightDeckDownloadUrl, setFlightDeckDownloadUrl] = useState('https://flightdeck.example.com/download')
462
+ const [flightDeckLaunching, setFlightDeckLaunching] = useState(false)
463
+ const [launchToast, setLaunchToast] = useState<LaunchToast | null>(null)
464
+ const [selectedHotspot, setSelectedHotspot] = useState<OfficeHotspot | null>(null)
465
+ const [agentActionOverrides, setAgentActionOverrides] = useState<Map<number, OfficeAction>>(new Map())
466
+ const [officeEvents, setOfficeEvents] = useState<OfficeEvent[]>([])
467
+ const [roomLayoutState, setRoomLayoutState] = useState<MapRoom[]>(() => ROOM_LAYOUT.map((room) => ({ ...room })))
468
+ const [mapPropsState, setMapPropsState] = useState<MapProp[]>(() => MAP_PROPS.map((prop) => ({ ...prop })))
469
+ const [showSidebar, setShowSidebar] = useState(true)
470
+ const [showMinimap, setShowMinimap] = useState(true)
471
+ const [showEvents, setShowEvents] = useState(true)
472
  const [loading, setLoading] = useState(true)
473
+ const [localBootstrapping, setLocalBootstrapping] = useState(isLocalMode)
474
+ const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>('all')
475
+ const [spriteFrame, setSpriteFrame] = useState(0)
476
+ const [timeTheme, setTimeTheme] = useState<TimeTheme>('night')
477
+ const [mapZoom, setMapZoom] = useState(1)
478
+ const [mapPan, setMapPan] = useState({ x: 0, y: 0 })
479
+ const mapViewportRef = useRef<HTMLDivElement | null>(null)
480
+ const localBootstrapRetries = useRef(0)
481
+ const mapDragActiveRef = useRef(false)
482
+ const mapDragOriginRef = useRef({ x: 0, y: 0 })
483
+ const mapPanStartRef = useRef({ x: 0, y: 0 })
484
+ const prevStatusRef = useRef<Map<number, Agent['status']>>(new Map())
485
+ const transitionTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
486
+ const launchToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
487
+ const roamReturnTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
488
+ const movingAgentIdsRef = useRef<Set<number>>(new Set())
489
+ const movingWorkersRef = useRef<MovingWorker[]>([])
490
+ const renderedWorkersRef = useRef<Array<{ agent: Agent; x: number; y: number; zoneLabel: string; seatLabel: string; isMoving: boolean; direction: { dx: number; dy: number }; variant: WorkerVariant }>>([])
491
+ const [transitioningAgentIds, setTransitioningAgentIds] = useState<Set<number>>(new Set())
492
+ const previousSeatMapRef = useRef<Map<number, SeatPosition>>(new Map())
493
+ const [movingWorkers, setMovingWorkers] = useState<MovingWorker[]>([])
494
 
495
  const fetchAgents = useCallback(async () => {
496
+ let nextLocalAgents: Agent[] = []
497
+ let nextSessionAgents: Agent[] = []
498
+
499
  try {
500
+ const [agentRes, sessionRes] = await Promise.all([
501
+ fetch('/api/agents'),
502
+ isLocalMode ? fetch('/api/sessions') : Promise.resolve(null),
503
+ ])
504
+
505
+ if (agentRes.ok) {
506
+ const data = await agentRes.json()
507
+ nextLocalAgents = Array.isArray(data.agents) ? data.agents : []
508
+ setLocalAgents(nextLocalAgents)
509
+ }
510
+
511
+ if (isLocalMode && sessionRes?.ok) {
512
+ const sessionJson = await sessionRes.json().catch(() => ({}))
513
+ const rows = Array.isArray(sessionJson?.sessions) ? sessionJson.sessions as SessionAgentRow[] : []
514
+ const byAgent = new Map<string, Agent>()
515
+ let idx = 0
516
+
517
+ for (const row of rows) {
518
+ const name = String(row.agent || '').trim()
519
+ if (!name) continue
520
+ const existing = byAgent.get(name)
521
+ const nowSec = Math.floor(Date.now() / 1000)
522
+ const lastSeenSec = row.lastActivity ? Math.floor(row.lastActivity / 1000) : nowSec
523
+ const inferredRole = inferLocalRole(row)
524
+ const candidate: Agent = {
525
+ id: -5000 - idx,
526
+ name,
527
+ role: inferredRole,
528
+ status: row.active ? 'busy' : 'idle',
529
+ last_seen: lastSeenSec,
530
+ last_activity: `${row.kind || 'session'} Β· ${row.model || 'unknown model'}`,
531
+ session_key: row.key || row.id,
532
+ created_at: nowSec,
533
+ updated_at: nowSec,
534
+ config: {
535
+ localSession: {
536
+ sessionId: row.id,
537
+ key: row.key,
538
+ workingDir: row.workingDir || null,
539
+ kind: row.kind || 'session',
540
+ },
541
+ },
542
+ }
543
+
544
+ const existingLastSeen = existing?.last_seen || 0
545
+ const candidateLastSeen = candidate.last_seen || 0
546
+ const shouldReplace =
547
+ !existing ||
548
+ (existing.status !== 'busy' && candidate.status === 'busy') ||
549
+ (existing.status === candidate.status && candidateLastSeen > existingLastSeen)
550
+
551
+ if (shouldReplace) {
552
+ byAgent.set(name, candidate)
553
+ idx += 1
554
+ }
555
+ }
556
+
557
+ nextSessionAgents = Array.from(byAgent.values())
558
+ setSessionAgents(nextSessionAgents)
559
  }
560
  } catch { /* ignore */ }
561
+
562
+ if (isLocalMode) {
563
+ const hasAnyAgents = nextLocalAgents.length > 0 || nextSessionAgents.length > 0
564
+ if (hasAnyAgents) setLocalBootstrapping(false)
565
+ if (!hasAnyAgents && localBootstrapRetries.current < 5) {
566
+ localBootstrapRetries.current += 1
567
+ setLoading(true)
568
+ setTimeout(() => {
569
+ void fetchAgents()
570
+ }, 700)
571
+ return
572
+ }
573
+ }
574
+
575
  setLoading(false)
576
+ }, [isLocalMode])
577
 
578
  useEffect(() => { fetchAgents() }, [fetchAgents])
579
 
580
+ useEffect(() => {
581
+ if (!isLocalMode) {
582
+ setLocalBootstrapping(false)
583
+ return
584
+ }
585
+ setLocalBootstrapping(true)
586
+ const bootstrapTimer = setTimeout(() => {
587
+ setLocalBootstrapping(false)
588
+ }, 4500)
589
+ return () => clearTimeout(bootstrapTimer)
590
+ }, [isLocalMode])
591
+
592
  useEffect(() => {
593
  const interval = setInterval(fetchAgents, 10000)
594
  return () => clearInterval(interval)
595
  }, [fetchAgents])
596
 
597
+ useEffect(() => {
598
+ const interval = setInterval(() => {
599
+ setSpriteFrame((current) => (current + 1) % 2)
600
+ }, 380)
601
+ return () => clearInterval(interval)
602
+ }, [])
603
+
604
+ const displayAgents = useMemo(() => {
605
+ if (agents.length > 0) return agents
606
+ if (isLocalMode) {
607
+ const merged = new Map<string, Agent>()
608
+ for (const agent of [...sessionAgents, ...localAgents]) {
609
+ const key = String(agent.name || '').trim().toLowerCase()
610
+ if (!key) continue
611
+ const existing = merged.get(key)
612
+ if (!existing) {
613
+ merged.set(key, agent)
614
+ continue
615
+ }
616
+ const existingLastSeen = existing.last_seen || 0
617
+ const candidateLastSeen = agent.last_seen || 0
618
+ const shouldReplace =
619
+ (existing.status !== 'busy' && agent.status === 'busy') ||
620
+ (existing.status === agent.status && candidateLastSeen > existingLastSeen)
621
+ if (shouldReplace) merged.set(key, agent)
622
+ }
623
+ return Array.from(merged.values())
624
+ }
625
+ if (localAgents.length > 0) return localAgents
626
+ return []
627
+ }, [agents, isLocalMode, localAgents, sessionAgents])
628
 
629
  const counts = useMemo(() => {
630
  const c = { idle: 0, busy: 0, error: 0, offline: 0 }
 
632
  return c
633
  }, [displayAgents])
634
 
 
 
 
 
 
 
 
 
 
635
  const roleGroups = useMemo(() => {
636
  const groups = new Map<string, Agent[]>()
637
  for (const a of displayAgents) {
 
642
  return groups
643
  }, [displayAgents])
644
 
645
+ const officeLayout = useMemo(() => buildOfficeLayout(displayAgents), [displayAgents])
646
+
647
+ const currentSeatMap = useMemo(() => {
648
+ const seatMap = new Map<number, SeatPosition>()
649
+ const zoneSeatTemplates: Record<string, Array<{ x: number; y: number }>> = {
650
+ engineering: [{ x: 24, y: 36 }, { x: 32, y: 36 }, { x: 24, y: 42 }, { x: 32, y: 42 }],
651
+ product: [{ x: 54, y: 36 }, { x: 62, y: 36 }, { x: 54, y: 42 }, { x: 62, y: 42 }],
652
+ operations: [{ x: 24, y: 64 }, { x: 32, y: 64 }, { x: 24, y: 70 }, { x: 32, y: 70 }],
653
+ research: [{ x: 50, y: 64 }, { x: 58, y: 64 }, { x: 50, y: 70 }, { x: 58, y: 70 }],
654
+ quality: [{ x: 58, y: 64 }, { x: 66, y: 64 }, { x: 58, y: 70 }, { x: 66, y: 70 }],
655
+ 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 }],
656
+ }
657
+ const fallbackByZone: Record<string, string[]> = {
658
+ engineering: ['operations', 'general'],
659
+ product: ['research', 'general'],
660
+ operations: ['engineering', 'general'],
661
+ research: ['product', 'general'],
662
+ quality: ['research', 'general'],
663
+ general: ['general'],
664
+ }
665
+
666
+ const usageByZone = new Map<string, number>()
667
+ const pullSeat = (zoneId: string) => {
668
+ const templates = zoneSeatTemplates[zoneId] || zoneSeatTemplates.general
669
+ const used = usageByZone.get(zoneId) || 0
670
+ const chosen = templates[used % templates.length] || { x: 38, y: 47 }
671
+ const overflowBand = Math.floor(used / templates.length)
672
+ usageByZone.set(zoneId, used + 1)
673
+ return {
674
+ x: chosen.x,
675
+ y: chosen.y + overflowBand * 3.5,
676
+ }
677
+ }
678
+
679
+ for (let zoneIndex = 0; zoneIndex < officeLayout.length; zoneIndex += 1) {
680
+ const zone = officeLayout[zoneIndex].zone
681
+ const sortedWorkers = [...officeLayout[zoneIndex].workers].sort((a, b) => a.agent.name.localeCompare(b.agent.name))
682
+
683
+ for (const worker of sortedWorkers) {
684
+ const primaryTemplates = zoneSeatTemplates[zone.id] || zoneSeatTemplates.general
685
+ const primaryUsed = usageByZone.get(zone.id) || 0
686
+ const inPrimaryCapacity = primaryUsed < primaryTemplates.length * 2
687
+ const targetZone = inPrimaryCapacity ? zone.id : (fallbackByZone[zone.id] || ['general'])[0]
688
+ const seat = pullSeat(targetZone)
689
+ const x = clamp(seat.x, 8, 92)
690
+ const y = clamp(seat.y, 12, 92)
691
+ seatMap.set(worker.agent.id, {
692
+ seatKey: `${targetZone}:${worker.anchor.seatLabel}`,
693
+ x,
694
+ y,
695
+ })
696
+ }
697
+ }
698
+ return seatMap
699
+ }, [officeLayout])
700
+
701
+ const gameWorkers = useMemo(() => {
702
+ const workers: Array<{ agent: Agent; x: number; y: number; zoneLabel: string; seatLabel: string }> = []
703
+ for (let zoneIndex = 0; zoneIndex < officeLayout.length; zoneIndex += 1) {
704
+ const zone = officeLayout[zoneIndex]
705
+ for (const worker of zone.workers) {
706
+ const seat = currentSeatMap.get(worker.agent.id)
707
+ if (!seat) continue
708
+ workers.push({
709
+ agent: worker.agent,
710
+ x: seat.x,
711
+ y: seat.y,
712
+ zoneLabel: zone.zone.label,
713
+ seatLabel: worker.anchor.seatLabel,
714
+ })
715
+ }
716
+ }
717
+ return workers
718
+ }, [currentSeatMap, officeLayout])
719
+
720
+ const floorTiles = useMemo(() => {
721
+ const tiles: Array<{ id: string; x: number; y: number; w: number; h: number; sprite: boolean }> = []
722
+ const tileW = 100 / MAP_COLS
723
+ const tileH = 100 / MAP_ROWS
724
+ for (let row = 0; row < MAP_ROWS; row += 1) {
725
+ for (let col = 0; col < MAP_COLS; col += 1) {
726
+ tiles.push({
727
+ id: `tile-${row}-${col}`,
728
+ x: col * tileW,
729
+ y: row * tileH,
730
+ w: tileW,
731
+ h: tileH,
732
+ sprite: (row + col) % 2 === 0,
733
+ })
734
+ }
735
+ }
736
+ return tiles
737
+ }, [])
738
+
739
+ const movingPositionByAgent = useMemo(() => {
740
+ const positions = new Map<number, { x: number; y: number }>()
741
+ for (const worker of movingWorkers) {
742
+ const eased = easeInOut(worker.progress)
743
+ positions.set(
744
+ worker.agentId,
745
+ pointAlongPath(worker.path, worker.pathLengths, worker.totalLength, eased),
746
+ )
747
+ }
748
+ return positions
749
+ }, [movingWorkers])
750
+
751
+ const movingDirectionByAgent = useMemo(() => {
752
+ const directions = new Map<number, { dx: number; dy: number }>()
753
+ for (const worker of movingWorkers) {
754
+ directions.set(worker.agentId, {
755
+ dx: worker.endX - worker.startX,
756
+ dy: worker.endY - worker.startY,
757
+ })
758
+ }
759
+ return directions
760
+ }, [movingWorkers])
761
+
762
+ const renderedWorkers = useMemo(() => {
763
+ return gameWorkers.map((worker) => {
764
+ const movingPosition = movingPositionByAgent.get(worker.agent.id)
765
+ return {
766
+ ...worker,
767
+ x: movingPosition?.x ?? worker.x,
768
+ y: movingPosition?.y ?? worker.y,
769
+ isMoving: Boolean(movingPosition),
770
+ direction: movingDirectionByAgent.get(worker.agent.id) || { dx: 0, dy: 0 },
771
+ variant: getWorkerVariant(worker.agent.name),
772
+ }
773
+ })
774
+ }, [gameWorkers, movingDirectionByAgent, movingPositionByAgent])
775
+
776
+ const officePrefsKey = useMemo(() => {
777
+ const userPart = currentUser?.id ? `u${currentUser.id}` : `guest-${currentUser?.username || 'anon'}`
778
+ const pathPart = typeof window === 'undefined' ? 'server' : window.location.pathname.replace(/[^a-zA-Z0-9/_-]/g, '_')
779
+ return `mc-office-prefs:v1:${dashboardMode}:${userPart}:${pathPart}`
780
+ }, [currentUser?.id, currentUser?.username, dashboardMode])
781
+
782
+ useEffect(() => {
783
+ if (typeof window === 'undefined') return
784
+ try {
785
+ const raw = window.localStorage.getItem(officePrefsKey)
786
+ if (!raw) return
787
+ const prefs = JSON.parse(raw) as PersistedOfficePrefs
788
+ if (!prefs || prefs.version !== 1) return
789
+ setViewMode(prefs.viewMode || 'office')
790
+ setSidebarFilter(prefs.sidebarFilter || 'all')
791
+ setMapZoom(Number.isFinite(prefs.mapZoom) ? clamp(prefs.mapZoom, 0.8, 2.2) : 1)
792
+ setMapPan({
793
+ x: Number.isFinite(prefs.mapPan?.x) ? prefs.mapPan.x : 0,
794
+ y: Number.isFinite(prefs.mapPan?.y) ? prefs.mapPan.y : 0,
795
+ })
796
+ setTimeTheme(prefs.timeTheme || 'night')
797
+ setShowSidebar(prefs.showSidebar !== false)
798
+ setShowMinimap(prefs.showMinimap !== false)
799
+ setShowEvents(prefs.showEvents !== false)
800
+ if (Array.isArray(prefs.roomLayout) && prefs.roomLayout.length > 0) {
801
+ setRoomLayoutState(prefs.roomLayout.map((room) => ({ ...room })))
802
+ }
803
+ if (Array.isArray(prefs.mapProps) && prefs.mapProps.length > 0) {
804
+ setMapPropsState(prefs.mapProps.map((prop) => ({ ...prop })))
805
+ }
806
+ } catch {
807
+ // ignore corrupted local preferences
808
+ }
809
+ }, [officePrefsKey])
810
+
811
+ useEffect(() => {
812
+ if (typeof window === 'undefined') return
813
+ const payload: PersistedOfficePrefs = {
814
+ version: 1,
815
+ viewMode,
816
+ sidebarFilter,
817
+ mapZoom,
818
+ mapPan,
819
+ timeTheme,
820
+ showSidebar,
821
+ showMinimap,
822
+ showEvents,
823
+ roomLayout: roomLayoutState,
824
+ mapProps: mapPropsState,
825
+ }
826
+ try {
827
+ window.localStorage.setItem(officePrefsKey, JSON.stringify(payload))
828
+ } catch {
829
+ // ignore storage failures
830
+ }
831
+ }, [
832
+ officePrefsKey,
833
+ mapPan,
834
+ mapPropsState,
835
+ mapZoom,
836
+ roomLayoutState,
837
+ showEvents,
838
+ showMinimap,
839
+ showSidebar,
840
+ sidebarFilter,
841
+ timeTheme,
842
+ viewMode,
843
+ ])
844
+
845
+ useEffect(() => {
846
+ const updateThemeFromClock = () => {
847
+ const hour = new Date().getHours()
848
+ if (hour >= 6 && hour < 11) setTimeTheme('dawn')
849
+ else if (hour >= 11 && hour < 17) setTimeTheme('day')
850
+ else if (hour >= 17 && hour < 20) setTimeTheme('dusk')
851
+ else setTimeTheme('night')
852
+ }
853
+ updateThemeFromClock()
854
+ const interval = setInterval(updateThemeFromClock, 60_000)
855
+ return () => clearInterval(interval)
856
+ }, [])
857
+
858
+ const themePalette = useMemo<ThemePalette>(() => {
859
+ if (timeTheme === 'dawn') {
860
+ return {
861
+ shell: 'radial-gradient(circle at 20% 10%, rgba(214,141,89,0.55) 0, rgba(48,66,109,0.9) 45%, rgba(17,24,41,1) 100%)',
862
+ gridLine: 'rgba(255,198,151,0.13)',
863
+ haze: 'radial-gradient(circle at 50% 30%, rgba(255,188,137,0.2), transparent 62%)',
864
+ glow: 'linear-gradient(to bottom, rgba(255,255,255,0.06), transparent 34%, rgba(0,0,0,0.16))',
865
+ corridor: '#3f3f54',
866
+ corridorStripe: '#ffca95',
867
+ }
868
+ }
869
+ if (timeTheme === 'day') {
870
+ return {
871
+ shell: 'radial-gradient(circle at 20% 10%, rgba(121,167,255,0.55) 0, rgba(33,62,112,0.9) 45%, rgba(13,22,40,1) 100%)',
872
+ gridLine: 'rgba(171,208,255,0.14)',
873
+ haze: 'radial-gradient(circle at 50% 30%, rgba(168,218,255,0.16), transparent 60%)',
874
+ glow: 'linear-gradient(to bottom, rgba(255,255,255,0.08), transparent 30%, rgba(0,0,0,0.12))',
875
+ corridor: '#3a4258',
876
+ corridorStripe: '#b8d5ff',
877
+ }
878
+ }
879
+ if (timeTheme === 'dusk') {
880
+ return {
881
+ shell: 'radial-gradient(circle at 20% 10%, rgba(180,112,164,0.45) 0, rgba(35,43,84,0.92) 45%, rgba(10,14,28,1) 100%)',
882
+ gridLine: 'rgba(198,156,255,0.13)',
883
+ haze: 'radial-gradient(circle at 50% 30%, rgba(221,164,255,0.18), transparent 62%)',
884
+ glow: 'linear-gradient(to bottom, rgba(255,255,255,0.05), transparent 30%, rgba(0,0,0,0.2))',
885
+ corridor: '#413b58',
886
+ corridorStripe: '#d7b0ff',
887
+ }
888
+ }
889
+ return {
890
+ shell: 'radial-gradient(circle at 20% 10%, rgba(51,86,153,0.7) 0, rgba(13,20,36,0.95) 40%, rgba(9,13,24,1) 100%)',
891
+ gridLine: 'rgba(99,121,166,0.14)',
892
+ haze: 'radial-gradient(circle at 50% 30%, rgba(75,132,255,0.16), transparent 60%)',
893
+ glow: 'linear-gradient(to bottom, rgba(255,255,255,0.03), transparent 30%, rgba(0,0,0,0.18))',
894
+ corridor: '#303746',
895
+ corridorStripe: '#9cc2ff',
896
+ }
897
+ }, [timeTheme])
898
+
899
+ const heatmapPoints = useMemo(() => {
900
+ return renderedWorkers.map((worker) => {
901
+ const action = agentActionOverrides.get(worker.agent.id)
902
+ let intensity = worker.agent.status === 'busy' ? 0.95 : worker.agent.status === 'idle' ? 0.45 : 0.7
903
+ if (action === 'focus') intensity += 0.25
904
+ if (action === 'pair') intensity += 0.15
905
+ if (worker.isMoving) intensity += 0.2
906
+ const radius = worker.agent.status === 'busy' ? 14 : 10
907
+ const hue = worker.agent.status === 'busy' ? 'rgba(255,191,84,' : worker.agent.status === 'idle' ? 'rgba(88,220,139,' : 'rgba(120,189,255,'
908
+ return {
909
+ id: worker.agent.id,
910
+ x: worker.x,
911
+ y: worker.y,
912
+ radius,
913
+ color: `${hue}${Math.min(0.85, Math.max(0.2, intensity)).toFixed(2)})`,
914
+ }
915
+ })
916
+ }, [agentActionOverrides, renderedWorkers])
917
+
918
+ const rosterRows = useMemo(() => {
919
+ return gameWorkers.map(({ agent }) => {
920
+ const minutesIdle = agent.last_seen ? Math.floor((Date.now() / 1000 - agent.last_seen) / 60) : Number.POSITIVE_INFINITY
921
+ const needsAttention = isLocalMode && agent.status === 'idle' && minutesIdle >= 15
922
+ return {
923
+ agent,
924
+ minutesIdle,
925
+ needsAttention,
926
+ }
927
+ })
928
+ }, [gameWorkers, isLocalMode])
929
+
930
+ const filteredRosterRows = useMemo(() => {
931
+ if (sidebarFilter === 'all') return rosterRows
932
+ if (sidebarFilter === 'working') return rosterRows.filter((row) => row.agent.status === 'busy')
933
+ if (sidebarFilter === 'idle') return rosterRows.filter((row) => row.agent.status === 'idle')
934
+ return rosterRows.filter((row) => row.needsAttention)
935
+ }, [rosterRows, sidebarFilter])
936
+
937
+ const pathEdges = useMemo(() => {
938
+ const edges: Array<{ x1: number; y1: number; x2: number; y2: number }> = []
939
+ const zoneGroups = new Map<string, Array<{ x: number; y: number }>>()
940
+ for (const worker of gameWorkers) {
941
+ if (!zoneGroups.has(worker.zoneLabel)) zoneGroups.set(worker.zoneLabel, [])
942
+ zoneGroups.get(worker.zoneLabel)!.push({ x: worker.x, y: worker.y })
943
+ }
944
+
945
+ for (const points of zoneGroups.values()) {
946
+ const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y)
947
+ for (let i = 0; i < sorted.length - 1; i += 1) {
948
+ edges.push({
949
+ x1: sorted[i].x,
950
+ y1: sorted[i].y + 2,
951
+ x2: sorted[i + 1].x,
952
+ y2: sorted[i + 1].y + 2,
953
+ })
954
+ }
955
+ }
956
+
957
+ // Trunk corridor and vertical connectors to mimic an office hallway system.
958
+ edges.push({ x1: 16, y1: 47, x2: 84, y2: 47 })
959
+ edges.push({ x1: 30, y1: 33, x2: 30, y2: 47 })
960
+ edges.push({ x1: 60, y1: 33, x2: 60, y2: 47 })
961
+ edges.push({ x1: 28, y1: 47, x2: 28, y2: 68 })
962
+ edges.push({ x1: 54, y1: 47, x2: 54, y2: 68 })
963
+
964
+ return edges
965
+ }, [gameWorkers])
966
+
967
+ const enqueueMovement = useCallback(
968
+ (agent: Agent, startX: number, startY: number, endX: number, endY: number, durationMs = 2200) => {
969
+ const blockedTiles = new Set<string>()
970
+ for (const worker of renderedWorkersRef.current) {
971
+ if (worker.agent.id === agent.id) continue
972
+ const tile = toTile(worker.x, worker.y)
973
+ blockedTiles.add(tileKey(tile.col, tile.row))
974
+ }
975
+ for (const moving of movingWorkersRef.current) {
976
+ if (moving.agentId === agent.id) continue
977
+ blockedTiles.add(moving.destinationTile)
978
+ }
979
+ const destination = toTile(endX, endY)
980
+ const movement: MovingWorker = {
981
+ id: `${agent.id}-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
982
+ agentId: agent.id,
983
+ initials: getInitials(agent.name),
984
+ colorClass: hashColor(agent.name),
985
+ startX,
986
+ startY,
987
+ endX,
988
+ endY,
989
+ startedAt: Date.now(),
990
+ durationMs,
991
+ progress: 0,
992
+ ...buildPath(startX, startY, endX, endY, blockedTiles),
993
+ destinationTile: tileKey(destination.col, destination.row),
994
+ }
995
+ setMovingWorkers((current) => {
996
+ if (current.some((item) => item.agentId === agent.id)) return current
997
+ return [...current, movement]
998
+ })
999
+ },
1000
+ [],
1001
+ )
1002
+
1003
+ useEffect(() => {
1004
+ const prev = prevStatusRef.current
1005
+ const next = new Map<number, Agent['status']>()
1006
+ const toAnimate: number[] = []
1007
+
1008
+ for (const agent of displayAgents) {
1009
+ next.set(agent.id, agent.status)
1010
+ const prevStatus = prev.get(agent.id)
1011
+ if (prevStatus && prevStatus !== agent.status) {
1012
+ toAnimate.push(agent.id)
1013
+ }
1014
+ }
1015
+
1016
+ prevStatusRef.current = next
1017
+
1018
+ if (toAnimate.length === 0) return
1019
+ setTransitioningAgentIds((current) => {
1020
+ const updated = new Set(current)
1021
+ for (const id of toAnimate) updated.add(id)
1022
+ return updated
1023
+ })
1024
+
1025
+ for (const id of toAnimate) {
1026
+ const existingTimer = transitionTimersRef.current.get(id)
1027
+ if (existingTimer) clearTimeout(existingTimer)
1028
+ const timer = setTimeout(() => {
1029
+ setTransitioningAgentIds((current) => {
1030
+ const updated = new Set(current)
1031
+ updated.delete(id)
1032
+ return updated
1033
+ })
1034
+ transitionTimersRef.current.delete(id)
1035
+ }, 2200)
1036
+ transitionTimersRef.current.set(id, timer)
1037
+ }
1038
+ }, [displayAgents])
1039
+
1040
+ useEffect(() => {
1041
+ const previous = previousSeatMapRef.current
1042
+
1043
+ for (const agent of displayAgents) {
1044
+ const currentSeat = currentSeatMap.get(agent.id)
1045
+ const previousSeat = previous.get(agent.id)
1046
+ if (!currentSeat || !previousSeat) continue
1047
+ if (currentSeat.seatKey === previousSeat.seatKey) continue
1048
+
1049
+ enqueueMovement(agent, previousSeat.x, previousSeat.y, currentSeat.x, currentSeat.y, 1800)
1050
+ }
1051
+
1052
+ previousSeatMapRef.current = currentSeatMap
1053
+ }, [currentSeatMap, displayAgents, enqueueMovement])
1054
+
1055
+ useEffect(() => {
1056
+ if (movingWorkers.length === 0) return
1057
+
1058
+ let rafId: number | null = null
1059
+ const step = () => {
1060
+ const now = Date.now()
1061
+ setMovingWorkers((current) => {
1062
+ if (current.length === 0) return current
1063
+ const updated = current
1064
+ .map((worker) => {
1065
+ const linear = (now - worker.startedAt) / worker.durationMs
1066
+ const progress = Math.max(0, Math.min(1, linear))
1067
+ return { ...worker, progress }
1068
+ })
1069
+ .filter((worker) => worker.progress < 1)
1070
+ return updated
1071
+ })
1072
+ rafId = window.requestAnimationFrame(step)
1073
+ }
1074
+
1075
+ rafId = window.requestAnimationFrame(step)
1076
+ return () => {
1077
+ if (rafId != null) window.cancelAnimationFrame(rafId)
1078
+ }
1079
+ }, [movingWorkers.length])
1080
+
1081
+ useEffect(() => {
1082
+ movingWorkersRef.current = movingWorkers
1083
+ movingAgentIdsRef.current = new Set(movingWorkers.map((worker) => worker.agentId))
1084
+ }, [movingWorkers])
1085
+
1086
+ useEffect(() => {
1087
+ renderedWorkersRef.current = renderedWorkers
1088
+ }, [renderedWorkers])
1089
+
1090
+ const pushOfficeEvent = useCallback((event: Omit<OfficeEvent, 'id' | 'at'>) => {
1091
+ const next: OfficeEvent = {
1092
+ ...event,
1093
+ id: `${Date.now()}-${Math.floor(Math.random() * 1000)}`,
1094
+ at: Date.now(),
1095
+ }
1096
+ setOfficeEvents((current) => [next, ...current].slice(0, 12))
1097
+ }, [])
1098
+
1099
+ useEffect(() => {
1100
+ if (!isLocalMode) return
1101
+ const interval = setInterval(() => {
1102
+ const activeMovingIds = movingAgentIdsRef.current
1103
+ const idleCandidates = renderedWorkersRef.current
1104
+ .filter((worker) => worker.agent.status === 'idle' && !worker.isMoving && !activeMovingIds.has(worker.agent.id))
1105
+ .sort((a, b) => a.agent.name.localeCompare(b.agent.name))
1106
+ .slice(0, 2)
1107
+
1108
+ if (idleCandidates.length === 0) return
1109
+ const cycle = Math.floor(Date.now() / 14_000)
1110
+
1111
+ for (const worker of idleCandidates) {
1112
+ const waypoint = LOUNGE_WAYPOINTS[(hashNumber(worker.agent.name) + cycle) % LOUNGE_WAYPOINTS.length]
1113
+ enqueueMovement(worker.agent, worker.x, worker.y, waypoint.x, waypoint.y, 2200)
1114
+
1115
+ const existingReturnTimer = roamReturnTimersRef.current.get(worker.agent.id)
1116
+ if (existingReturnTimer) clearTimeout(existingReturnTimer)
1117
+ const returnTimer = setTimeout(() => {
1118
+ const seat = currentSeatMap.get(worker.agent.id)
1119
+ if (seat) {
1120
+ enqueueMovement(worker.agent, waypoint.x, waypoint.y, seat.x, seat.y, 2200)
1121
+ }
1122
+ roamReturnTimersRef.current.delete(worker.agent.id)
1123
+ }, 2700)
1124
+ roamReturnTimersRef.current.set(worker.agent.id, returnTimer)
1125
+ }
1126
+ }, 14_000)
1127
+ return () => clearInterval(interval)
1128
+ }, [currentSeatMap, enqueueMovement, isLocalMode])
1129
+
1130
+ useEffect(() => {
1131
+ const interval = setInterval(() => {
1132
+ const workers = renderedWorkersRef.current
1133
+ if (workers.length === 0) return
1134
+ const sample = workers[Math.floor(Math.random() * workers.length)]
1135
+ const mood = sample.agent.status === 'busy' ? 'good' : sample.agent.status === 'idle' ? 'warn' : 'info'
1136
+ pushOfficeEvent({
1137
+ kind: 'room',
1138
+ severity: mood,
1139
+ message: `${sample.zoneLabel}: ${sample.agent.name} status is ${statusLabel[sample.agent.status].toLowerCase()}.`,
1140
+ })
1141
+ }, 22000)
1142
+ return () => clearInterval(interval)
1143
+ }, [pushOfficeEvent])
1144
+
1145
+ useEffect(() => {
1146
+ const timers = transitionTimersRef.current
1147
+ const roamTimers = roamReturnTimersRef.current
1148
+ return () => {
1149
+ for (const timer of timers.values()) clearTimeout(timer)
1150
+ timers.clear()
1151
+ for (const timer of roamTimers.values()) clearTimeout(timer)
1152
+ roamTimers.clear()
1153
+ if (launchToastTimerRef.current) {
1154
+ clearTimeout(launchToastTimerRef.current)
1155
+ launchToastTimerRef.current = null
1156
+ }
1157
+ }
1158
+ }, [])
1159
+
1160
+ const showLaunchToast = (toast: LaunchToast) => {
1161
+ setLaunchToast(toast)
1162
+ if (launchToastTimerRef.current) {
1163
+ clearTimeout(launchToastTimerRef.current)
1164
+ }
1165
+ launchToastTimerRef.current = setTimeout(() => {
1166
+ setLaunchToast(null)
1167
+ launchToastTimerRef.current = null
1168
+ }, 5000)
1169
+ }
1170
+
1171
+ const executeAgentAction = useCallback((agent: Agent, action: OfficeAction) => {
1172
+ setAgentActionOverrides((current) => {
1173
+ const next = new Map(current)
1174
+ next.set(agent.id, action)
1175
+ return next
1176
+ })
1177
+
1178
+ if (action === 'focus') {
1179
+ pushOfficeEvent({ kind: 'action', severity: 'good', message: `${agent.name} is now in deep focus mode.` })
1180
+ return
1181
+ }
1182
+
1183
+ if (action === 'pair') {
1184
+ const partner = renderedWorkersRef.current.find((worker) => worker.agent.id !== agent.id)?.agent
1185
+ pushOfficeEvent({
1186
+ kind: 'action',
1187
+ severity: 'info',
1188
+ message: partner
1189
+ ? `${agent.name} started a pairing session with ${partner.name}.`
1190
+ : `${agent.name} started a solo pairing prep session.`,
1191
+ })
1192
+ return
1193
+ }
1194
+
1195
+ const worker = renderedWorkersRef.current.find((item) => item.agent.id === agent.id)
1196
+ const waypoint = LOUNGE_WAYPOINTS[hashNumber(agent.name) % LOUNGE_WAYPOINTS.length]
1197
+ if (worker) {
1198
+ enqueueMovement(agent, worker.x, worker.y, waypoint.x, waypoint.y, 2200)
1199
+ pushOfficeEvent({ kind: 'action', severity: 'warn', message: `${agent.name} is taking a short lounge break.` })
1200
+ return
1201
+ }
1202
+ pushOfficeEvent({ kind: 'action', severity: 'warn', message: `${agent.name} requested a break.` })
1203
+ }, [enqueueMovement, pushOfficeEvent])
1204
+
1205
+ const openFlightDeck = async (agent: Agent) => {
1206
+ setFlightDeckLaunching(true)
1207
+ try {
1208
+ const res = await fetch('/api/local/flight-deck', {
1209
+ method: 'POST',
1210
+ headers: { 'Content-Type': 'application/json' },
1211
+ body: JSON.stringify({
1212
+ agent: agent.name,
1213
+ session: agent.session_key || '',
1214
+ }),
1215
+ })
1216
+ const json = await res.json().catch(() => ({}))
1217
+ if (!res.ok || json?.installed === false) {
1218
+ if (typeof json?.downloadUrl === 'string' && json.downloadUrl) {
1219
+ setFlightDeckDownloadUrl(json.downloadUrl)
1220
+ }
1221
+ setShowFlightDeckModal(true)
1222
+ showLaunchToast({
1223
+ kind: 'info',
1224
+ title: 'Flight Deck not installed',
1225
+ detail: 'Install Flight Deck to open this session.',
1226
+ })
1227
+ return
1228
+ }
1229
+ if (!json?.launched) {
1230
+ // Fallback for environments where native launch fails.
1231
+ if (typeof json?.fallbackUrl === 'string' && json.fallbackUrl) {
1232
+ window.open(json.fallbackUrl, '_blank', 'noopener,noreferrer')
1233
+ showLaunchToast({
1234
+ kind: 'info',
1235
+ title: 'Opened browser fallback',
1236
+ detail: 'Native launch failed, opened Flight Deck web fallback.',
1237
+ })
1238
+ return
1239
+ }
1240
+ showLaunchToast({
1241
+ kind: 'error',
1242
+ title: 'Flight Deck launch failed',
1243
+ detail: json?.error || 'Unable to launch Flight Deck for this session.',
1244
+ })
1245
+ return
1246
+ }
1247
+ showLaunchToast({
1248
+ kind: 'success',
1249
+ title: 'Opened in Flight Deck',
1250
+ detail: 'Launched native Flight Deck app for this session.',
1251
+ })
1252
+ } catch {
1253
+ setShowFlightDeckModal(true)
1254
+ showLaunchToast({
1255
+ kind: 'error',
1256
+ title: 'Flight Deck request failed',
1257
+ detail: 'Could not reach local launch endpoint.',
1258
+ })
1259
+ } finally {
1260
+ setFlightDeckLaunching(false)
1261
+ }
1262
+ }
1263
+
1264
+ const resetMapView = () => {
1265
+ setMapZoom(1)
1266
+ setMapPan({ x: 0, y: 0 })
1267
+ }
1268
+
1269
+ const onMapWheel = (event: WheelEvent<HTMLDivElement>) => {
1270
+ event.preventDefault()
1271
+ const delta = event.deltaY > 0 ? -0.08 : 0.08
1272
+ setMapZoom((current) => Math.min(2.2, Math.max(0.8, Number((current + delta).toFixed(2)))))
1273
+ }
1274
+
1275
+ const onMapMouseDown = (event: MouseEvent<HTMLDivElement>) => {
1276
+ mapDragActiveRef.current = true
1277
+ mapDragOriginRef.current = { x: event.clientX, y: event.clientY }
1278
+ mapPanStartRef.current = { ...mapPan }
1279
+ }
1280
+
1281
+ const onMapMouseMove = (event: MouseEvent<HTMLDivElement>) => {
1282
+ if (!mapDragActiveRef.current) return
1283
+ const dx = event.clientX - mapDragOriginRef.current.x
1284
+ const dy = event.clientY - mapDragOriginRef.current.y
1285
+ setMapPan({
1286
+ x: mapPanStartRef.current.x + dx,
1287
+ y: mapPanStartRef.current.y + dy,
1288
+ })
1289
+ }
1290
+
1291
+ const endMapDrag = () => {
1292
+ mapDragActiveRef.current = false
1293
+ }
1294
+
1295
+ const focusMapPoint = useCallback(
1296
+ (xPercent: number, yPercent: number) => {
1297
+ const viewport = mapViewportRef.current
1298
+ if (!viewport) return
1299
+ const rect = viewport.getBoundingClientRect()
1300
+ const nextPanX = rect.width / 2 - (xPercent / 100) * rect.width * mapZoom
1301
+ const nextPanY = rect.height / 2 - (yPercent / 100) * rect.height * mapZoom
1302
+ setMapPan({ x: nextPanX, y: nextPanY })
1303
+ },
1304
+ [mapZoom],
1305
+ )
1306
+
1307
+ const nudgeSelectedHotspot = useCallback((dx: number, dy: number) => {
1308
+ if (!selectedHotspot) return
1309
+ if (selectedHotspot.kind === 'room') {
1310
+ setRoomLayoutState((current) =>
1311
+ current.map((room) => {
1312
+ if (room.id !== selectedHotspot.id) return room
1313
+ return {
1314
+ ...room,
1315
+ x: clamp(room.x + dx, 2, 94 - room.w),
1316
+ y: clamp(room.y + dy, 8, 94 - room.h),
1317
+ }
1318
+ }),
1319
+ )
1320
+ setSelectedHotspot((current) =>
1321
+ current ? { ...current, x: clamp(current.x + dx, 2, 98), y: clamp(current.y + dy, 8, 98) } : current,
1322
+ )
1323
+ return
1324
+ }
1325
+ setMapPropsState((current) =>
1326
+ current.map((prop) => {
1327
+ if (prop.id !== selectedHotspot.id) return prop
1328
+ return {
1329
+ ...prop,
1330
+ x: clamp(prop.x + dx, 2, 98 - prop.w),
1331
+ y: clamp(prop.y + dy, 8, 98 - prop.h),
1332
+ }
1333
+ }),
1334
+ )
1335
+ setSelectedHotspot((current) =>
1336
+ current ? { ...current, x: clamp(current.x + dx, 2, 98), y: clamp(current.y + dy, 8, 98) } : current,
1337
+ )
1338
+ }, [selectedHotspot])
1339
+
1340
+ const resizeSelectedRoom = useCallback((dw: number, dh: number) => {
1341
+ if (!selectedHotspot || selectedHotspot.kind !== 'room') return
1342
+ setRoomLayoutState((current) =>
1343
+ current.map((room) => {
1344
+ if (room.id !== selectedHotspot.id) return room
1345
+ const nextW = clamp(room.w + dw, 10, 40)
1346
+ const nextH = clamp(room.h + dh, 10, 36)
1347
+ return {
1348
+ ...room,
1349
+ w: nextW,
1350
+ h: nextH,
1351
+ x: clamp(room.x, 2, 98 - nextW),
1352
+ y: clamp(room.y, 8, 98 - nextH),
1353
+ }
1354
+ }),
1355
+ )
1356
+ }, [selectedHotspot])
1357
+
1358
+ const resetOfficeLayout = useCallback(() => {
1359
+ setRoomLayoutState(ROOM_LAYOUT.map((room) => ({ ...room })))
1360
+ setMapPropsState(MAP_PROPS.map((prop) => ({ ...prop })))
1361
+ setMapZoom(1)
1362
+ setMapPan({ x: 0, y: 0 })
1363
+ setShowSidebar(true)
1364
+ setShowMinimap(true)
1365
+ setShowEvents(true)
1366
+ setSelectedHotspot(null)
1367
+ pushOfficeEvent({ kind: 'room', severity: 'info', message: 'Office layout reset to defaults.' })
1368
+ }, [pushOfficeEvent])
1369
+
1370
+ if ((loading || (isLocalMode && localBootstrapping)) && displayAgents.length === 0) {
1371
  return (
1372
  <div className="flex items-center justify-center h-64">
1373
  <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
1374
+ <span className="ml-3 text-muted-foreground">
1375
+ {isLocalMode ? 'Scanning local sessions...' : 'Loading office...'}
1376
+ </span>
1377
  </div>
1378
  )
1379
  }
 
1421
  <p className="text-sm mt-1">Add agents to see them appear here</p>
1422
  </div>
1423
  ) : viewMode === 'office' ? (
1424
+ <div className={`grid grid-cols-1 ${showSidebar ? 'xl:grid-cols-[220px_1fr]' : 'xl:grid-cols-1'} gap-4`}>
1425
+ {showSidebar && (
1426
+ <div className="rounded-xl border border-border bg-[#1a1f2d] text-slate-100 p-3 h-fit">
1427
+ <div className="flex items-center justify-between mb-2">
1428
+ <div className="text-xs font-semibold tracking-wider">TEAMY</div>
1429
+ <div className="text-[10px] text-slate-300">{displayAgents.length} online</div>
1430
+ </div>
1431
+ <div className="mb-2 flex flex-wrap gap-1.5">
1432
+ {([
1433
+ { key: 'all', label: 'All' },
1434
+ { key: 'working', label: 'Working' },
1435
+ { key: 'idle', label: 'Idle' },
1436
+ { key: 'attention', label: 'Needs Attention' },
1437
+ ] as Array<{ key: SidebarFilter; label: string }>).map((item) => (
1438
+ <button
1439
+ key={item.key}
1440
+ onClick={() => setSidebarFilter(item.key)}
1441
+ className={`px-2 py-1 rounded text-[10px] border transition-smooth ${
1442
+ sidebarFilter === item.key
1443
+ ? 'bg-primary/25 border-primary/40 text-primary-foreground'
1444
+ : 'bg-black/20 border-white/10 text-slate-300 hover:bg-black/35'
1445
+ }`}
1446
+ >
1447
+ {item.label}
1448
+ </button>
1449
+ ))}
1450
+ </div>
1451
+ <div className="space-y-2 max-h-[560px] overflow-y-auto pr-1">
1452
+ {filteredRosterRows.map(({ agent, minutesIdle, needsAttention }) => (
1453
+ <button
1454
  key={agent.id}
1455
+ onClick={() => {
1456
+ setSelectedAgent(agent)
1457
+ const worker = renderedWorkers.find((item) => item.agent.id === agent.id)
1458
+ if (worker) focusMapPoint(worker.x, worker.y)
1459
+ }}
1460
+ className={`w-full flex items-center gap-2 rounded-lg p-2 text-left transition-smooth ${
1461
+ needsAttention
1462
+ ? 'bg-amber-500/12 border border-amber-400/60 hover:bg-amber-500/20'
1463
+ : 'bg-black/20 border border-white/5 hover:bg-black/35'
1464
+ }`}
1465
+ >
1466
+ <span className={`w-6 h-6 rounded ${hashColor(agent.name)} flex items-center justify-center text-[10px] font-bold text-white`}>
1467
+ {getInitials(agent.name)}
1468
+ </span>
1469
+ <span className="min-w-0 flex-1">
1470
+ <span className="block text-xs font-medium truncate">{agent.name}</span>
1471
+ <span className="block text-[10px] text-slate-300 truncate">{agent.role}</span>
1472
+ <span className="block text-[9px] text-slate-400 truncate">
1473
+ {agent.last_activity || 'No recent activity'}
1474
+ </span>
1475
+ </span>
1476
+ <span className="flex flex-col items-end gap-1">
1477
+ <span className={`w-2 h-2 rounded-full ${statusDot[agent.status]}`} />
1478
+ <span className={`text-[9px] ${needsAttention ? 'text-amber-300 font-semibold' : 'text-slate-400'}`}>
1479
+ {agent.status === 'busy' ? 'active' : `${minutesIdle}m idle`}
1480
+ </span>
1481
+ </span>
1482
+ </button>
1483
+ ))}
1484
+ {filteredRosterRows.length === 0 && (
1485
+ <div className="text-[11px] text-slate-400 px-1 py-2">No workers in this filter.</div>
1486
+ )}
1487
+ </div>
1488
+ </div>
1489
+ )}
1490
+
1491
+ <div
1492
+ ref={mapViewportRef}
1493
+ 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)]"
1494
+ style={{
1495
+ backgroundColor: '#0b1220',
1496
+ backgroundImage: `${themePalette.shell}, linear-gradient(90deg, ${themePalette.gridLine} 1px, transparent 1px), linear-gradient(${themePalette.gridLine} 1px, transparent 1px)`,
1497
+ backgroundSize: 'auto, 64px 64px, 64px 64px',
1498
+ }}
1499
+ onWheel={onMapWheel}
1500
+ onMouseDown={onMapMouseDown}
1501
+ onMouseMove={onMapMouseMove}
1502
+ onMouseUp={endMapDrag}
1503
+ onMouseLeave={endMapDrag}
1504
+ >
1505
+ <div className="absolute inset-0 pointer-events-none z-0" style={{ backgroundImage: themePalette.haze }} />
1506
+ <div className="absolute inset-0 pointer-events-none z-0" style={{ backgroundImage: themePalette.glow }} />
1507
+
1508
+ <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">
1509
+ MAIN FLOOR
1510
+ </div>
1511
+ <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">
1512
+ <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>
1513
+ <span className="text-[11px] w-10 text-center">{Math.round(mapZoom * 100)}%</span>
1514
+ <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>
1515
+ <button onClick={resetMapView} className="text-[11px] px-1.5 py-0.5 hover:bg-white/10 rounded">Reset</button>
1516
+ </div>
1517
+ <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">
1518
+ {(['dawn', 'day', 'dusk', 'night'] as TimeTheme[]).map((item) => (
1519
+ <button
1520
+ key={item}
1521
+ onClick={() => setTimeTheme(item)}
1522
+ 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'}`}
1523
  >
1524
+ {item}
1525
+ </button>
1526
+ ))}
1527
+ </div>
1528
+ <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">
1529
+ <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>
1530
+ <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>
1531
+ <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>
1532
+ <button onClick={resetOfficeLayout} className="text-[10px] px-1.5 py-0.5 rounded hover:bg-white/10">Reset Layout</button>
1533
+ </div>
1534
+
1535
+ <div
1536
+ className="absolute inset-0 origin-top-left"
1537
+ style={{ transform: `translate(${mapPan.x}px, ${mapPan.y}px) scale(${mapZoom})` }}
1538
+ >
1539
+ <div className="absolute inset-0 z-0">
1540
+ {floorTiles.map((tile) => (
1541
+ <div
1542
+ key={tile.id}
1543
+ className="absolute border border-[#7fa4ff]/[0.06]"
1544
+ style={{
1545
+ left: `${tile.x}%`,
1546
+ top: `${tile.y}%`,
1547
+ width: `${tile.w}%`,
1548
+ height: `${tile.h}%`,
1549
+ backgroundImage: `url('/office-sprites/kenney/floorFull.png')`,
1550
+ backgroundSize: '100% 100%',
1551
+ opacity: tile.sprite ? 0.9 : 0.76,
1552
+ }}
1553
+ />
1554
+ ))}
1555
+ </div>
1556
 
1557
+ {/* Corridor base */}
1558
+ <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 }} />
1559
+ <div className="absolute left-[14%] top-[47.6%] w-[72%] h-[0.7%]" style={{ backgroundColor: themePalette.corridorStripe }} />
1560
+
1561
+ <div className="absolute inset-0 pointer-events-none z-[1]">
1562
+ {heatmapPoints.map((point) => (
1563
+ <div
1564
+ key={`heat-${point.id}`}
1565
+ className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full blur-xl"
1566
+ style={{
1567
+ left: `${point.x}%`,
1568
+ top: `${point.y}%`,
1569
+ width: `${point.radius * 2}px`,
1570
+ height: `${point.radius * 2}px`,
1571
+ background: `radial-gradient(circle, ${point.color} 0%, rgba(0,0,0,0) 72%)`,
1572
+ }}
1573
+ />
1574
+ ))}
1575
+ </div>
1576
+
1577
+ {/* Zone rooms */}
1578
+ {roomLayoutState.map((room) => (
1579
+ <div
1580
+ key={room.id}
1581
+ 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)]`}
1582
+ style={{
1583
+ left: `${room.x}%`,
1584
+ top: `${room.y}%`,
1585
+ width: `${room.w}%`,
1586
+ height: `${room.h}%`,
1587
+ backgroundImage: `linear-gradient(to bottom right, rgba(255,255,255,0.04), rgba(0,0,0,0.1)), url('/office-sprites/kenney/floorFull.png')`,
1588
+ backgroundSize: 'auto, 22% 22%',
1589
+ }}
1590
+ onClick={(event) => {
1591
+ event.stopPropagation()
1592
+ const activeInRoom = renderedWorkers.filter((worker) => worker.zoneLabel === room.label).length
1593
+ setSelectedHotspot({
1594
+ kind: 'room',
1595
+ id: room.id,
1596
+ label: room.label,
1597
+ x: room.x + room.w / 2,
1598
+ y: room.y + room.h / 2,
1599
+ stats: [
1600
+ `${activeInRoom} workers present`,
1601
+ `${Math.round(room.w * room.h)} tile area`,
1602
+ 'Click worker to inspect session',
1603
+ ],
1604
+ })
1605
+ pushOfficeEvent({
1606
+ kind: 'room',
1607
+ severity: 'info',
1608
+ message: `${room.label} room inspected (${activeInRoom} workers).`,
1609
+ })
1610
+ }}
1611
+ >
1612
+ <div className="absolute inset-0 bg-[linear-gradient(to_bottom_right,rgba(255,255,255,0.08),transparent_45%)] pointer-events-none" />
1613
+ <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">
1614
+ {room.label}
1615
  </div>
1616
+ </div>
1617
+ ))}
1618
 
1619
+ {/* Props / furniture */}
1620
+ {mapPropsState.map((prop) => (
1621
+ <div
1622
+ key={prop.id}
1623
+ className={`absolute relative border ${prop.style} ${prop.border} shadow-[0_0_12px_rgba(108,164,255,0.18)] overflow-hidden`}
1624
+ style={{ left: `${prop.x}%`, top: `${prop.y}%`, width: `${prop.w}%`, height: `${prop.h}%` }}
1625
+ onClick={(event) => {
1626
+ event.stopPropagation()
1627
+ const nearest = renderedWorkers
1628
+ .slice()
1629
+ .sort((a, b) => Math.hypot(a.x - prop.x, a.y - prop.y) - Math.hypot(b.x - prop.x, b.y - prop.y))[0]
1630
+ setSelectedHotspot({
1631
+ kind: 'desk',
1632
+ id: prop.id,
1633
+ label: prop.id.replace(/^desk-/, 'Desk ').replace(/^plant-/, 'Plant ').replace(/^kitchen$/, 'Lounge Rug'),
1634
+ x: prop.x + prop.w / 2,
1635
+ y: prop.y + prop.h / 2,
1636
+ stats: [
1637
+ nearest ? `Nearest worker: ${nearest.agent.name}` : 'No nearby worker',
1638
+ `Footprint ${prop.w.toFixed(1)}x${prop.h.toFixed(1)}`,
1639
+ 'Use action buttons in agent modal',
1640
+ ],
1641
+ })
1642
+ pushOfficeEvent({
1643
+ kind: 'desk',
1644
+ severity: 'info',
1645
+ message: `${prop.id} inspected${nearest ? ` near ${nearest.agent.name}` : ''}.`,
1646
+ })
1647
+ }}
1648
+ >
1649
+ <Image
1650
+ src={getPropSprite(prop.id)}
1651
+ alt=""
1652
+ aria-hidden="true"
1653
+ fill
1654
+ unoptimized
1655
+ className="object-contain opacity-95"
1656
+ style={{ imageRendering: 'pixelated' }}
1657
+ draggable={false}
1658
+ />
1659
+ </div>
1660
+ ))}
1661
+
1662
+ <svg className="absolute inset-0 w-full h-full pointer-events-none" aria-hidden="true">
1663
+ {pathEdges.map((edge, idx) => (
1664
+ <line
1665
+ key={`edge-${idx}`}
1666
+ x1={`${edge.x1}%`}
1667
+ y1={`${edge.y1}%`}
1668
+ x2={`${edge.x2}%`}
1669
+ y2={`${edge.y2}%`}
1670
+ stroke="rgba(170, 203, 255, 0.42)"
1671
+ strokeWidth="2"
1672
+ strokeDasharray="4 6"
1673
+ />
1674
+ ))}
1675
+ </svg>
1676
+
1677
+ {renderedWorkers.map(({ agent, x, y, zoneLabel, seatLabel, isMoving, direction }) => (
1678
+ <div key={agent.id}>
1679
+ <div
1680
+ className="absolute -translate-x-1/2 pointer-events-none"
1681
+ style={{ left: `${x}%`, top: `calc(${y}% - 14px)` }}
1682
+ >
1683
+ <Image
1684
+ src="/office-sprites/kenney/chairDesk.png"
1685
+ alt=""
1686
+ aria-hidden="true"
1687
+ width={22}
1688
+ height={21}
1689
+ unoptimized
1690
+ className="w-6 h-6 object-contain opacity-90"
1691
+ style={{ imageRendering: 'pixelated' }}
1692
+ draggable={false}
1693
+ />
1694
+ </div>
1695
+ <div
1696
+ className="absolute -translate-x-1/2 pointer-events-none"
1697
+ style={{ left: `${x}%`, top: `calc(${y}% - 56px)` }}
1698
+ >
1699
+ <div className="relative w-16 h-9">
1700
+ <Image
1701
+ src="/office-sprites/kenney/desk.png"
1702
+ alt=""
1703
+ aria-hidden="true"
1704
+ width={64}
1705
+ height={32}
1706
+ unoptimized
1707
+ className="w-16 h-9 object-contain opacity-95"
1708
+ style={{ imageRendering: 'pixelated' }}
1709
+ draggable={false}
1710
+ />
1711
+ <Image
1712
+ src="/office-sprites/kenney/computerScreen.png"
1713
+ alt=""
1714
+ aria-hidden="true"
1715
+ width={20}
1716
+ height={6}
1717
+ unoptimized
1718
+ className="absolute left-1/2 -translate-x-1/2 top-[6px] w-7 h-2 object-contain opacity-95"
1719
+ style={{ imageRendering: 'pixelated' }}
1720
+ draggable={false}
1721
+ />
1722
  </div>
1723
  </div>
1724
 
1725
+ <button
1726
+ onClick={() => setSelectedAgent(agent)}
1727
+ className="absolute -translate-x-1/2 -translate-y-1/2 transition-all duration-500 hover:scale-110"
1728
+ style={{ left: `${x}%`, top: `${y}%` }}
1729
+ >
1730
+ <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)]">
1731
+ <span className={`inline-block w-2 h-2 rounded-full ${statusDot[agent.status]} mr-1`} />
1732
+ {agent.name}
1733
+ </div>
1734
+ <div className="absolute -top-12 left-1/2 -translate-x-1/2 text-sm">
1735
+ <span className={`${agent.status === 'busy' ? 'animate-bounce' : 'animate-pulse'}`}>{getStatusEmote(agent.status)}</span>
1736
+ </div>
1737
+ <div className="relative w-8 h-12 mx-auto">
1738
+ <div
1739
+ className={`absolute inset-0 ${transitioningAgentIds.has(agent.id) || isMoving ? 'animate-pulse' : ''}`}
1740
+ style={{
1741
+ backgroundImage: `url('/office-sprites/cc0-hero/player_full_animation.png')`,
1742
+ backgroundRepeat: 'no-repeat',
1743
+ backgroundSize: `${HERO_SHEET_COLS * 100}% ${HERO_SHEET_ROWS * 100}%`,
1744
+ backgroundPosition: (() => {
1745
+ const frame = getWorkerHeroFrame(agent.status, isMoving, spriteFrame)
1746
+ const xPct = (frame.col / (HERO_SHEET_COLS - 1)) * 100
1747
+ const yPct = (frame.row / (HERO_SHEET_ROWS - 1)) * 100
1748
+ return `${xPct}% ${yPct}%`
1749
+ })(),
1750
+ imageRendering: 'pixelated',
1751
+ transform: isMoving && Math.abs(direction.dx) > Math.abs(direction.dy) && direction.dx < 0 ? 'scaleX(-1)' : undefined,
1752
+ transformOrigin: 'center',
1753
+ }}
1754
+ />
1755
+ <div className={`absolute left-[8px] top-[14px] w-4 h-3 ${hashColor(agent.name)} border border-black/60`} />
1756
+ </div>
1757
+ {!isMoving && <div className="text-[9px] text-slate-300 font-mono mt-0.5">#{seatLabel}</div>}
1758
+ </button>
1759
 
1760
+ {agentActionOverrides.has(agent.id) && (
1761
+ <div
1762
+ 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"
1763
+ style={{ left: `${x}%`, top: `calc(${y}% - 24px)` }}
1764
+ >
1765
+ {agentActionOverrides.get(agent.id)}
1766
  </div>
1767
  )}
1768
 
1769
+ {(transitioningAgentIds.has(agent.id) || isMoving) && (
1770
+ <div
1771
+ 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"
1772
+ style={{ left: `${x}%`, top: `calc(${y}% + 22px)` }}
1773
+ >
1774
+ moving
1775
  </div>
1776
  )}
1777
+
1778
+ <div
1779
+ className="absolute text-[9px] text-slate-500/70 font-mono pointer-events-none"
1780
+ style={{ left: `${x}%`, top: `calc(${y}% + 38px)` }}
1781
+ >
1782
+ {zoneLabel}
1783
+ </div>
1784
  </div>
1785
  ))}
1786
  </div>
1787
 
1788
+ {showMinimap && (
1789
+ <div
1790
+ 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"
1791
+ onMouseDown={(event) => event.stopPropagation()}
1792
+ onClick={(event) => {
1793
+ event.stopPropagation()
1794
+ const target = event.currentTarget
1795
+ const rect = target.getBoundingClientRect()
1796
+ const x = clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100)
1797
+ const y = clamp(((event.clientY - rect.top) / rect.height) * 100, 0, 100)
1798
+ focusMapPoint(x, y)
1799
+ }}
1800
+ >
1801
+ <div className="text-[9px] text-slate-300 uppercase tracking-wider mb-1">Minimap</div>
1802
+ <div className="relative w-full h-[calc(100%-16px)] rounded-sm overflow-hidden border border-white/10 bg-[#111a2f]">
1803
+ {roomLayoutState.map((room) => (
1804
+ <div
1805
+ key={`mini-${room.id}`}
1806
+ className="absolute border border-white/15 bg-white/10"
1807
+ style={{ left: `${room.x}%`, top: `${room.y}%`, width: `${room.w}%`, height: `${room.h}%` }}
1808
+ />
1809
+ ))}
1810
+ <div className="absolute left-[14%] top-[47%] w-[72%] h-[4%] bg-[#6f80a7]" />
1811
+ {renderedWorkers.map((worker) => (
1812
+ <button
1813
+ key={`mini-worker-${worker.agent.id}`}
1814
+ 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`}
1815
+ style={{ left: `${worker.x}%`, top: `${worker.y}%` }}
1816
+ onClick={(event) => {
1817
+ event.stopPropagation()
1818
+ setSelectedAgent(worker.agent)
1819
+ focusMapPoint(worker.x, worker.y)
1820
+ }}
1821
+ title={worker.agent.name}
1822
+ />
1823
+ ))}
1824
+ </div>
1825
  </div>
1826
+ )}
1827
+
1828
+ {showEvents && (
1829
+ <div
1830
+ 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"
1831
+ onWheel={(event) => event.stopPropagation()}
1832
+ >
1833
+ <div className="text-[10px] text-slate-300 uppercase tracking-wider">Office Events</div>
1834
+ <div className="flex items-center gap-2 text-[10px] text-slate-400">
1835
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-300" />Busy Heat</span>
1836
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-300" />Idle Heat</span>
1837
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-sky-300" />Other</span>
1838
+ </div>
1839
+ <div className="space-y-1.5 max-h-36 overflow-y-auto pr-1" onWheel={(event) => event.stopPropagation()}>
1840
+ {officeEvents.length === 0 && (
1841
+ <div className="text-[11px] text-slate-500">No events yet. Click a room/desk or run an action.</div>
1842
+ )}
1843
+ {officeEvents.map((event) => (
1844
+ <div key={event.id} className="text-[11px] rounded px-2 py-1 bg-black/35 border border-white/10">
1845
+ <div className="flex items-center justify-between gap-2">
1846
+ <span
1847
+ className={`uppercase text-[9px] ${
1848
+ event.severity === 'good'
1849
+ ? 'text-emerald-300'
1850
+ : event.severity === 'warn'
1851
+ ? 'text-amber-300'
1852
+ : 'text-sky-300'
1853
+ }`}
1854
+ >
1855
+ {event.kind}
1856
+ </span>
1857
+ <span className="text-slate-500 text-[9px]">{formatLastSeen(Math.floor(event.at / 1000))}</span>
1858
+ </div>
1859
+ <div className="text-slate-200">{event.message}</div>
1860
+ </div>
1861
+ ))}
1862
+ </div>
1863
+ {selectedHotspot && (
1864
+ <div className="rounded border border-white/10 bg-black/35 p-2">
1865
+ <div className="flex items-center justify-between">
1866
+ <div className="text-[11px] font-semibold text-white">{selectedHotspot.label}</div>
1867
+ <div className="text-[9px] uppercase text-slate-400">{selectedHotspot.kind}</div>
1868
+ </div>
1869
+ <div className="mt-1.5 space-y-1">
1870
+ {selectedHotspot.stats.map((line) => (
1871
+ <div key={line} className="text-[10px] text-slate-300">{line}</div>
1872
+ ))}
1873
+ </div>
1874
+ <div className="mt-2 grid grid-cols-3 gap-1">
1875
+ <button onClick={() => nudgeSelectedHotspot(0, -1)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Up</button>
1876
+ <button onClick={() => nudgeSelectedHotspot(-1, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Left</button>
1877
+ <button onClick={() => nudgeSelectedHotspot(1, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Right</button>
1878
+ <button onClick={() => nudgeSelectedHotspot(0, 1)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Down</button>
1879
+ <button onClick={() => nudgeSelectedHotspot(-0.5, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Fine -X</button>
1880
+ <button onClick={() => nudgeSelectedHotspot(0.5, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Fine +X</button>
1881
+ </div>
1882
+ {selectedHotspot.kind === 'room' && (
1883
+ <div className="mt-1.5 grid grid-cols-2 gap-1">
1884
+ <button onClick={() => resizeSelectedRoom(1, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Wider</button>
1885
+ <button onClick={() => resizeSelectedRoom(-1, 0)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Narrower</button>
1886
+ <button onClick={() => resizeSelectedRoom(0, 1)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Taller</button>
1887
+ <button onClick={() => resizeSelectedRoom(0, -1)} className="text-[10px] rounded border border-white/10 py-1 hover:bg-white/10">Shorter</button>
1888
+ </div>
1889
+ )}
1890
+ </div>
1891
+ )}
1892
+ </div>
1893
+ )}
1894
  </div>
1895
  </div>
1896
  ) : (
 
1984
  <span className="font-medium">Session:</span> <code className="font-mono">{selectedAgent.session_key}</code>
1985
  </div>
1986
  )}
1987
+
1988
+ <div className="pt-1">
1989
+ <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1.5">Quick Actions</div>
1990
+ <div className="grid grid-cols-3 gap-1.5">
1991
+ <button
1992
+ onClick={() => executeAgentAction(selectedAgent, 'focus')}
1993
+ className="h-8 px-2 rounded border border-border bg-secondary text-[11px] hover:bg-surface-2"
1994
+ >
1995
+ Focus
1996
+ </button>
1997
+ <button
1998
+ onClick={() => executeAgentAction(selectedAgent, 'pair')}
1999
+ className="h-8 px-2 rounded border border-border bg-secondary text-[11px] hover:bg-surface-2"
2000
+ >
2001
+ Pair
2002
+ </button>
2003
+ <button
2004
+ onClick={() => executeAgentAction(selectedAgent, 'break')}
2005
+ className="h-8 px-2 rounded border border-border bg-secondary text-[11px] hover:bg-surface-2"
2006
+ >
2007
+ Break
2008
+ </button>
2009
+ </div>
2010
+ </div>
2011
+
2012
+ {isLocalMode && (
2013
+ <div className="pt-1">
2014
+ <button
2015
+ onClick={() => openFlightDeck(selectedAgent)}
2016
+ disabled={flightDeckLaunching}
2017
+ className="w-full h-9 px-3 rounded-md border border-border bg-secondary text-foreground text-xs hover:bg-surface-2 transition-smooth"
2018
+ >
2019
+ {flightDeckLaunching ? 'Opening Flight Deck...' : 'Open in Flight Deck'}
2020
+ </button>
2021
+ <div className="text-[10px] text-muted-foreground mt-1">
2022
+ Private/pro companion app for session deep-dive
2023
+ </div>
2024
+ </div>
2025
+ )}
2026
+ </div>
2027
+ </div>
2028
+ </div>
2029
+ )}
2030
+
2031
+ {showFlightDeckModal && (
2032
+ <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[60] p-4" onClick={() => setShowFlightDeckModal(false)}>
2033
+ <div className="bg-card border border-border rounded-xl max-w-md w-full p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
2034
+ <div className="flex items-start justify-between gap-3">
2035
+ <div>
2036
+ <h3 className="text-lg font-semibold text-foreground">Flight Deck Required</h3>
2037
+ <p className="text-sm text-muted-foreground mt-1">
2038
+ Flight Deck is the private/pro companion app for Mission Control.
2039
+ </p>
2040
+ </div>
2041
+ <button
2042
+ onClick={() => setShowFlightDeckModal(false)}
2043
+ className="text-muted-foreground hover:text-foreground text-xl"
2044
+ >
2045
+ Γ—
2046
+ </button>
2047
+ </div>
2048
+
2049
+ <div className="mt-4 rounded-lg border border-border bg-secondary/40 p-3 text-sm text-muted-foreground">
2050
+ It looks like Flight Deck is not installed on this machine.
2051
+ Install it to open agent sessions with richer controls and diagnostics.
2052
+ </div>
2053
+
2054
+ <div className="mt-5 flex items-center justify-end gap-2">
2055
+ <button
2056
+ onClick={() => setShowFlightDeckModal(false)}
2057
+ className="h-9 px-3 rounded-md border border-border text-sm text-foreground hover:bg-secondary/60 transition-smooth"
2058
+ >
2059
+ Maybe Later
2060
+ </button>
2061
+ <a
2062
+ href={flightDeckDownloadUrl}
2063
+ target="_blank"
2064
+ rel="noreferrer"
2065
+ 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"
2066
+ >
2067
+ Download Flight Deck
2068
+ </a>
2069
+ </div>
2070
+ </div>
2071
+ </div>
2072
+ )}
2073
+
2074
+ {launchToast && (
2075
+ <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">
2076
+ <div className="flex items-start gap-2">
2077
+ <span
2078
+ className={`mt-1 inline-block h-2.5 w-2.5 rounded-full ${
2079
+ launchToast.kind === 'success'
2080
+ ? 'bg-green-400'
2081
+ : launchToast.kind === 'info'
2082
+ ? 'bg-blue-400'
2083
+ : 'bg-red-400'
2084
+ }`}
2085
+ />
2086
+ <div>
2087
+ <div className="text-sm font-semibold text-foreground">{launchToast.title}</div>
2088
+ <div className="text-xs text-muted-foreground mt-0.5">{launchToast.detail}</div>
2089
  </div>
2090
  </div>
2091
  </div>
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
  <button
@@ -630,21 +726,27 @@ export function SuperAdminPanel() {
630
  )}
631
  </td>
632
  <td className="px-3 py-2 text-right relative">
633
- <button
634
- onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
635
- className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
636
- >
637
- Actions
638
- </button>
639
- {openActionMenu === menuKey && (
640
- <div className="absolute right-3 top-10 z-20 w-44 rounded-md border border-border bg-card shadow-xl text-left">
641
  <button
642
- onClick={() => openDecommissionDialog(tenant)}
643
- className="w-full px-3 py-2 text-xs text-red-300 hover:bg-red-500/10"
644
  >
645
- Queue Decommission
646
  </button>
647
- </div>
 
 
 
 
 
 
 
 
 
 
648
  )}
649
  </td>
650
  </tr>
@@ -743,42 +845,53 @@ export function SuperAdminPanel() {
743
  <div>Appr: {job.approved_by || '-'}</div>
744
  </td>
745
  <td className="px-3 py-2 text-right relative">
746
- <button
747
- onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
748
- className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
749
- >
750
- Actions
751
- </button>
752
- {openActionMenu === menuKey && (
753
- <div className="absolute right-3 top-10 z-20 w-40 rounded-md border border-border bg-card shadow-xl text-left">
754
- <button
755
- onClick={() => loadJobDetail(job.id)}
756
- className="w-full px-3 py-2 text-xs text-foreground hover:bg-secondary/40"
757
- >
758
- View events
759
- </button>
760
- <button
761
- onClick={() => setJobState(job.id, 'approve')}
762
- disabled={busyJobId === job.id || !['queued', 'rejected', 'failed'].includes(job.status)}
763
- className="w-full px-3 py-2 text-xs text-emerald-400 hover:bg-emerald-500/10 disabled:opacity-40"
764
- >
765
- Approve
766
- </button>
767
- <button
768
- onClick={() => setJobState(job.id, 'reject')}
769
- disabled={busyJobId === job.id || !['queued', 'approved', 'failed'].includes(job.status)}
770
- className="w-full px-3 py-2 text-xs text-amber-400 hover:bg-amber-500/10 disabled:opacity-40"
771
- >
772
- Reject
773
- </button>
774
  <button
775
- onClick={() => runJob(job.id)}
776
- disabled={busyJobId === job.id || job.status !== 'approved'}
777
- className="w-full px-3 py-2 text-xs text-primary hover:bg-primary/10 disabled:opacity-40"
778
  >
779
- {busyJobId === job.id ? 'Running...' : 'Run'}
780
  </button>
781
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
782
  )}
783
  </td>
784
  </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
  <button
 
726
  )}
727
  </td>
728
  <td className="px-3 py-2 text-right relative">
729
+ {isLocal && tenant.id < 0 ? (
730
+ <span className="text-[11px] text-muted-foreground">Local read-only</span>
731
+ ) : (
732
+ <>
 
 
 
 
733
  <button
734
+ onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
735
+ className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
736
  >
737
+ Actions
738
  </button>
739
+ {openActionMenu === menuKey && (
740
+ <div className="absolute right-3 top-10 z-20 w-44 rounded-md border border-border bg-card shadow-xl text-left">
741
+ <button
742
+ onClick={() => openDecommissionDialog(tenant)}
743
+ className="w-full px-3 py-2 text-xs text-red-300 hover:bg-red-500/10"
744
+ >
745
+ Queue Decommission
746
+ </button>
747
+ </div>
748
+ )}
749
+ </>
750
  )}
751
  </td>
752
  </tr>
 
845
  <div>Appr: {job.approved_by || '-'}</div>
846
  </td>
847
  <td className="px-3 py-2 text-right relative">
848
+ {isLocal && job.id < 0 ? (
849
+ <button
850
+ onClick={() => loadJobDetail(job.id)}
851
+ className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
852
+ >
853
+ View
854
+ </button>
855
+ ) : (
856
+ <>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
857
  <button
858
+ onClick={() => setOpenActionMenu((cur) => (cur === menuKey ? null : menuKey))}
859
+ className="h-7 px-2 rounded border border-border text-xs hover:bg-secondary/60"
 
860
  >
861
+ Actions
862
  </button>
863
+ {openActionMenu === menuKey && (
864
+ <div className="absolute right-3 top-10 z-20 w-40 rounded-md border border-border bg-card shadow-xl text-left">
865
+ <button
866
+ onClick={() => loadJobDetail(job.id)}
867
+ className="w-full px-3 py-2 text-xs text-foreground hover:bg-secondary/40"
868
+ >
869
+ View events
870
+ </button>
871
+ <button
872
+ onClick={() => setJobState(job.id, 'approve')}
873
+ disabled={busyJobId === job.id || !['queued', 'rejected', 'failed'].includes(job.status)}
874
+ className="w-full px-3 py-2 text-xs text-emerald-400 hover:bg-emerald-500/10 disabled:opacity-40"
875
+ >
876
+ Approve
877
+ </button>
878
+ <button
879
+ onClick={() => setJobState(job.id, 'reject')}
880
+ disabled={busyJobId === job.id || !['queued', 'approved', 'failed'].includes(job.status)}
881
+ className="w-full px-3 py-2 text-xs text-amber-400 hover:bg-amber-500/10 disabled:opacity-40"
882
+ >
883
+ Reject
884
+ </button>
885
+ <button
886
+ onClick={() => runJob(job.id)}
887
+ disabled={busyJobId === job.id || job.status !== 'approved'}
888
+ className="w-full px-3 py-2 text-xs text-primary hover:bg-primary/10 disabled:opacity-40"
889
+ >
890
+ {busyJobId === job.id ? 'Running...' : 'Run'}
891
+ </button>
892
+ </div>
893
+ )}
894
+ </>
895
  )}
896
  </td>
897
  </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
+ }