Spaces:
Sleeping
Sleeping
Nyk commited on
Commit Β·
ab90450
1
Parent(s): 6b2e74b
feat: upgrade local-mode virtual office and flight deck integration
Browse files- public/office-sprites/cc0-hero/ATTRIBUTION-CC0-HERO.txt +4 -0
- public/office-sprites/cc0-hero/player.png +3 -0
- public/office-sprites/cc0-hero/player_full_animation.png +3 -0
- public/office-sprites/desk.svg +11 -0
- public/office-sprites/floor-tile.svg +7 -0
- public/office-sprites/kenney/ATTRIBUTION-KENNEY.txt +22 -0
- public/office-sprites/kenney/chairDesk.png +3 -0
- public/office-sprites/kenney/computerScreen.png +3 -0
- public/office-sprites/kenney/desk.png +3 -0
- public/office-sprites/kenney/floorFull.png +3 -0
- public/office-sprites/kenney/plantSmall1.png +3 -0
- public/office-sprites/kenney/plantSmall2.png +3 -0
- public/office-sprites/kenney/rugRectangle.png +3 -0
- public/office-sprites/kenney/tableCross.png +3 -0
- public/office-sprites/lounge-rug.svg +6 -0
- public/office-sprites/plant.svg +8 -0
- public/office-sprites/worker-base.svg +10 -0
- public/office-sprites/worker-idle-a.svg +10 -0
- public/office-sprites/worker-idle-b.svg +10 -0
- public/office-sprites/worker-type-a.svg +10 -0
- public/office-sprites/worker-type-b.svg +10 -0
- public/office-sprites/worker-walk-a.svg +10 -0
- public/office-sprites/worker-walk-b.svg +10 -0
- src/app/api/cron/route.ts +1 -13
- src/app/api/local/flight-deck/route.ts +124 -0
- src/app/api/local/terminal/route.ts +47 -0
- src/app/api/scheduler/route.ts +6 -3
- src/app/api/sessions/route.ts +64 -2
- src/components/panels/cron-management-panel.tsx +107 -16
- src/components/panels/office-panel.tsx +1822 -62
- src/components/panels/super-admin-panel.tsx +165 -52
- src/components/panels/webhook-panel.tsx +94 -0
- src/lib/codex-sessions.ts +219 -0
- 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
|
public/office-sprites/cc0-hero/player_full_animation.png
ADDED
|
|
Git LFS Details
|
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
|
public/office-sprites/kenney/computerScreen.png
ADDED
|
|
Git LFS Details
|
public/office-sprites/kenney/desk.png
ADDED
|
|
Git LFS Details
|
public/office-sprites/kenney/floorFull.png
ADDED
|
|
Git LFS Details
|
public/office-sprites/kenney/plantSmall1.png
ADDED
|
|
Git LFS Details
|
public/office-sprites/kenney/plantSmall2.png
ADDED
|
|
Git LFS Details
|
public/office-sprites/kenney/rugRectangle.png
ADDED
|
|
Git LFS Details
|
public/office-sprites/kenney/tableCross.png
ADDED
|
|
Git LFS Details
|
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 =
|
| 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 || !
|
| 27 |
-
return NextResponse.json({
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 90 |
-
const
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
try {
|
| 123 |
-
const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(
|
| 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
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
|
|
|
|
|
|
| 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 |
-
{
|
|
|
|
| 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
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
| 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=
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
key={agent.id}
|
| 186 |
-
onClick={() =>
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
>
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
</div>
|
|
|
|
|
|
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
</div>
|
| 204 |
</div>
|
| 205 |
|
| 206 |
-
<
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
<
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
-
{agent.
|
| 215 |
-
<div
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
| 217 |
</div>
|
| 218 |
)}
|
| 219 |
|
| 220 |
-
{
|
| 221 |
-
<div
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
| 223 |
</div>
|
| 224 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
</div>
|
| 226 |
))}
|
| 227 |
</div>
|
| 228 |
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 140 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
<
|
| 634 |
-
|
| 635 |
-
|
| 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={() =>
|
| 643 |
-
className="
|
| 644 |
>
|
| 645 |
-
|
| 646 |
</button>
|
| 647 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 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={() =>
|
| 776 |
-
|
| 777 |
-
className="w-full px-3 py-2 text-xs text-primary hover:bg-primary/10 disabled:opacity-40"
|
| 778 |
>
|
| 779 |
-
|
| 780 |
</button>
|
| 781 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|