diff --git a/public/office-sprites/cc0-hero/ATTRIBUTION-CC0-HERO.txt b/public/office-sprites/cc0-hero/ATTRIBUTION-CC0-HERO.txt new file mode 100644 index 0000000..5ad085e --- /dev/null +++ b/public/office-sprites/cc0-hero/ATTRIBUTION-CC0-HERO.txt @@ -0,0 +1,4 @@ +Source: OpenGameArt - "Hero character sprite sheet" by Fry +URL: https://opengameart.org/content/hero-character-sprite-sheet +License: CC0 1.0 Universal +Notes: Used for Virtual Office worker character rendering. diff --git a/public/office-sprites/cc0-hero/player.png b/public/office-sprites/cc0-hero/player.png new file mode 100644 index 0000000..63fe14c Binary files /dev/null and b/public/office-sprites/cc0-hero/player.png differ diff --git a/public/office-sprites/cc0-hero/player_full_animation.png b/public/office-sprites/cc0-hero/player_full_animation.png new file mode 100644 index 0000000..9fa2a09 Binary files /dev/null and b/public/office-sprites/cc0-hero/player_full_animation.png differ diff --git a/public/office-sprites/desk.svg b/public/office-sprites/desk.svg new file mode 100644 index 0000000..846c936 --- /dev/null +++ b/public/office-sprites/desk.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/office-sprites/floor-tile.svg b/public/office-sprites/floor-tile.svg new file mode 100644 index 0000000..e5add69 --- /dev/null +++ b/public/office-sprites/floor-tile.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/office-sprites/kenney/ATTRIBUTION-KENNEY.txt b/public/office-sprites/kenney/ATTRIBUTION-KENNEY.txt new file mode 100644 index 0000000..d5e9a1c --- /dev/null +++ b/public/office-sprites/kenney/ATTRIBUTION-KENNEY.txt @@ -0,0 +1,22 @@ + + + Furniture Pack (1.0) Exclusive + + Created/distributed by Kenney (www.kenney.nl) + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting (Kenney or www.kenney.nl), this is not mandatory. + + ------------------------------ + + Donate: http://support.kenney.nl + Request: http://request.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + @KenneyNL \ No newline at end of file diff --git a/public/office-sprites/kenney/chairDesk.png b/public/office-sprites/kenney/chairDesk.png new file mode 100644 index 0000000..2ec1ae2 Binary files /dev/null and b/public/office-sprites/kenney/chairDesk.png differ diff --git a/public/office-sprites/kenney/computerScreen.png b/public/office-sprites/kenney/computerScreen.png new file mode 100644 index 0000000..34b0230 Binary files /dev/null and b/public/office-sprites/kenney/computerScreen.png differ diff --git a/public/office-sprites/kenney/desk.png b/public/office-sprites/kenney/desk.png new file mode 100644 index 0000000..af1d8eb Binary files /dev/null and b/public/office-sprites/kenney/desk.png differ diff --git a/public/office-sprites/kenney/floorFull.png b/public/office-sprites/kenney/floorFull.png new file mode 100644 index 0000000..321c933 Binary files /dev/null and b/public/office-sprites/kenney/floorFull.png differ diff --git a/public/office-sprites/kenney/plantSmall1.png b/public/office-sprites/kenney/plantSmall1.png new file mode 100644 index 0000000..8a2e736 Binary files /dev/null and b/public/office-sprites/kenney/plantSmall1.png differ diff --git a/public/office-sprites/kenney/plantSmall2.png b/public/office-sprites/kenney/plantSmall2.png new file mode 100644 index 0000000..4496c75 Binary files /dev/null and b/public/office-sprites/kenney/plantSmall2.png differ diff --git a/public/office-sprites/kenney/rugRectangle.png b/public/office-sprites/kenney/rugRectangle.png new file mode 100644 index 0000000..2e15455 Binary files /dev/null and b/public/office-sprites/kenney/rugRectangle.png differ diff --git a/public/office-sprites/kenney/tableCross.png b/public/office-sprites/kenney/tableCross.png new file mode 100644 index 0000000..31ca9b2 Binary files /dev/null and b/public/office-sprites/kenney/tableCross.png differ diff --git a/public/office-sprites/lounge-rug.svg b/public/office-sprites/lounge-rug.svg new file mode 100644 index 0000000..b464758 --- /dev/null +++ b/public/office-sprites/lounge-rug.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/office-sprites/plant.svg b/public/office-sprites/plant.svg new file mode 100644 index 0000000..a214f2b --- /dev/null +++ b/public/office-sprites/plant.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/office-sprites/worker-base.svg b/public/office-sprites/worker-base.svg new file mode 100644 index 0000000..3a58bbf --- /dev/null +++ b/public/office-sprites/worker-base.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/office-sprites/worker-idle-a.svg b/public/office-sprites/worker-idle-a.svg new file mode 100644 index 0000000..8a9f35f --- /dev/null +++ b/public/office-sprites/worker-idle-a.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/office-sprites/worker-idle-b.svg b/public/office-sprites/worker-idle-b.svg new file mode 100644 index 0000000..06bf030 --- /dev/null +++ b/public/office-sprites/worker-idle-b.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/office-sprites/worker-type-a.svg b/public/office-sprites/worker-type-a.svg new file mode 100644 index 0000000..f432fe1 --- /dev/null +++ b/public/office-sprites/worker-type-a.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/office-sprites/worker-type-b.svg b/public/office-sprites/worker-type-b.svg new file mode 100644 index 0000000..20bf2cd --- /dev/null +++ b/public/office-sprites/worker-type-b.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/office-sprites/worker-walk-a.svg b/public/office-sprites/worker-walk-a.svg new file mode 100644 index 0000000..85cd85c --- /dev/null +++ b/public/office-sprites/worker-walk-a.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/office-sprites/worker-walk-b.svg b/public/office-sprites/worker-walk-b.svg new file mode 100644 index 0000000..34bfe2c --- /dev/null +++ b/public/office-sprites/worker-walk-b.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index 8973ab8..69e10ee 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -95,18 +95,6 @@ async function saveCronFile(data: OpenClawCronFile): Promise { } } -/** Deduplicate jobs by name โ€” keep the latest (by createdAtMs) per unique name */ -function deduplicateJobs(jobs: OpenClawCronJob[]): OpenClawCronJob[] { - const latest = new Map() - for (const job of jobs) { - const existing = latest.get(job.name) - if (!existing || (job.createdAtMs ?? 0) > (existing.createdAtMs ?? 0)) { - latest.set(job.name, job) - } - } - return [...latest.values()] -} - function mapLastStatus(status?: string): 'success' | 'error' | 'running' | undefined { if (!status) return undefined const s = status.toLowerCase() @@ -157,7 +145,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ jobs: [] }) } - const jobs = deduplicateJobs(cronFile.jobs).map(mapOpenClawJob) + const jobs = cronFile.jobs.map(mapOpenClawJob) return NextResponse.json({ jobs }) } diff --git a/src/app/api/local/flight-deck/route.ts b/src/app/api/local/flight-deck/route.ts new file mode 100644 index 0000000..6c5af64 --- /dev/null +++ b/src/app/api/local/flight-deck/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server' +import { existsSync, statSync } from 'node:fs' +import { requireRole } from '@/lib/auth' +import { runCommand } from '@/lib/command' + +const DEFAULT_DOWNLOAD_URL = 'https://flightdeck.example.com/download' +const DEFAULT_INSTALL_PATHS = [ + '/Applications/Flight Deck.app', + '/Applications/Flight Desk.app', +] + +function getConfiguredFlightDeckPath(): string | null { + const fromEnv = String(process.env.FLIGHT_DECK_PATH || '').trim() + return fromEnv || null +} + +function getFlightDeckBaseUrl(): string { + const fromEnv = String(process.env.FLIGHT_DECK_URL || '').trim() + if (fromEnv) return fromEnv + return 'http://127.0.0.1:4177' +} + +function getFlightDeckLaunchUrl(): string { + const fromEnv = String(process.env.FLIGHT_DECK_LAUNCH_URL || '').trim() + if (fromEnv) return fromEnv + return 'flightdeck://open' +} + +function isInstalled(targetPath: string): boolean { + try { + return existsSync(targetPath) && statSync(targetPath).isDirectory() + } catch { + return false + } +} + +function resolveFlightDeckInstallPath(): string | null { + const configured = getConfiguredFlightDeckPath() + if (configured && isInstalled(configured)) return configured + for (const candidate of DEFAULT_INSTALL_PATHS) { + if (isInstalled(candidate)) return candidate + } + return configured +} + +/** + * GET /api/local/flight-deck + * Check Flight Deck local installation status. + */ +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const installPath = resolveFlightDeckInstallPath() + const installed = installPath ? isInstalled(installPath) : false + + return NextResponse.json({ + installed, + installPath: installPath || null, + appUrl: getFlightDeckBaseUrl(), + downloadUrl: DEFAULT_DOWNLOAD_URL, + }) +} + +/** + * POST /api/local/flight-deck + * Build a Flight Deck URL for the selected agent/session. + */ +export async function POST(request: NextRequest) { + const auth = requireRole(request, 'operator') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const installPath = resolveFlightDeckInstallPath() + const installed = installPath ? isInstalled(installPath) : false + if (!installed) { + return NextResponse.json({ + installed: false, + error: 'Flight Deck is not installed locally.', + installPath: installPath || null, + downloadUrl: DEFAULT_DOWNLOAD_URL, + }, { status: 404 }) + } + + const body = await request.json().catch(() => ({})) + const agent = typeof body?.agent === 'string' ? body.agent : '' + const session = typeof body?.session === 'string' ? body.session : '' + + const webUrl = new URL(getFlightDeckBaseUrl()) + webUrl.searchParams.set('source', 'mission-control') + if (agent) webUrl.searchParams.set('agent', agent) + if (session) webUrl.searchParams.set('session', session) + + const launchUrl = new URL(getFlightDeckLaunchUrl()) + launchUrl.searchParams.set('source', 'mission-control') + if (agent) launchUrl.searchParams.set('agent', agent) + if (session) launchUrl.searchParams.set('session', session) + + try { + // Launch the native app directly; pass deep-link as payload. + await runCommand('open', ['-a', installPath!, launchUrl.toString()], { timeoutMs: 10_000 }) + } catch (error: any) { + try { + // Fallback for apps registered as URL handlers. + await runCommand('open', [launchUrl.toString()], { timeoutMs: 10_000 }) + } catch (fallbackError: any) { + return NextResponse.json({ + installed: true, + launched: false, + error: fallbackError?.message || error?.message || 'Failed to launch Flight Deck app.', + fallbackUrl: webUrl.toString(), + downloadUrl: DEFAULT_DOWNLOAD_URL, + }, { status: 500 }) + } + } + + return NextResponse.json({ + installed: true, + launched: true, + url: webUrl.toString(), + launchUrl: launchUrl.toString(), + }) +} + +export const dynamic = 'force-dynamic' diff --git a/src/app/api/local/terminal/route.ts b/src/app/api/local/terminal/route.ts new file mode 100644 index 0000000..4be6617 --- /dev/null +++ b/src/app/api/local/terminal/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server' +import { existsSync, statSync } from 'node:fs' +import { resolve } from 'node:path' +import { requireRole } from '@/lib/auth' +import { runCommand } from '@/lib/command' + +function isAllowedDirectory(input: string): boolean { + const cwd = resolve(input) + if (!cwd.startsWith('/')) return false + if (!(cwd.startsWith('/Users/') || cwd.startsWith('/tmp/') || cwd.startsWith('/var/folders/'))) { + return false + } + if (!existsSync(cwd)) return false + try { + return statSync(cwd).isDirectory() + } catch { + return false + } +} + +/** + * POST /api/local/terminal + * Body: { cwd: string } + * Opens a new local Terminal window at the given working directory. + */ +export async function POST(request: NextRequest) { + const auth = requireRole(request, 'operator') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const body = await request.json().catch(() => ({})) + const cwd = typeof body?.cwd === 'string' ? body.cwd.trim() : '' + if (!cwd) { + return NextResponse.json({ error: 'cwd is required' }, { status: 400 }) + } + if (!isAllowedDirectory(cwd)) { + return NextResponse.json({ error: 'cwd must be an existing safe local directory' }, { status: 400 }) + } + + try { + await runCommand('open', ['-a', 'Terminal', cwd], { timeoutMs: 10_000 }) + return NextResponse.json({ ok: true, message: `Opened Terminal at ${cwd}` }) + } catch (error: any) { + return NextResponse.json({ error: error?.message || 'Failed to open Terminal' }, { status: 500 }) + } +} + +export const dynamic = 'force-dynamic' diff --git a/src/app/api/scheduler/route.ts b/src/app/api/scheduler/route.ts index ae82213..4d83cea 100644 --- a/src/app/api/scheduler/route.ts +++ b/src/app/api/scheduler/route.ts @@ -21,10 +21,13 @@ export async function POST(request: NextRequest) { if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const body = await request.json().catch(() => ({})) - const taskId = body.task_id + const taskId = typeof body?.task_id === 'string' ? body.task_id : '' + const allowedTaskIds = new Set(getSchedulerStatus().map((task) => task.id)) - if (!taskId || !['auto_backup', 'auto_cleanup', 'agent_heartbeat'].includes(taskId)) { - return NextResponse.json({ error: 'task_id required: auto_backup, auto_cleanup, or agent_heartbeat' }, { status: 400 }) + if (!taskId || !allowedTaskIds.has(taskId)) { + return NextResponse.json({ + error: `task_id required: ${Array.from(allowedTaskIds).join(', ')}`, + }, { status: 400 }) } const result = await triggerTask(taskId) diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index 5ce93b8..5e02004 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getAllGatewaySessions } from '@/lib/sessions' import { syncClaudeSessions } from '@/lib/claude-sessions' +import { scanCodexSessions } from '@/lib/codex-sessions' import { getDatabase } from '@/lib/db' import { requireRole } from '@/lib/auth' import { logger } from '@/lib/logger' @@ -49,10 +50,12 @@ export async function GET(request: NextRequest) { return NextResponse.json({ sessions }) } - // Fallback: sync and read local Claude sessions from SQLite + // Fallback: sync and read local Claude + Codex sessions from disk/SQLite await syncClaudeSessions() const claudeSessions = getLocalClaudeSessions() - return NextResponse.json({ sessions: claudeSessions }) + const codexSessions = getLocalCodexSessions() + const merged = mergeLocalSessions(claudeSessions, codexSessions) + return NextResponse.json({ sessions: merged }) } catch (error) { logger.error({ err: error }, 'Sessions API error') return NextResponse.json({ sessions: [] }) @@ -89,6 +92,7 @@ function getLocalClaudeSessions() { toolUses: s.tool_uses || 0, estimatedCost: s.estimated_cost || 0, lastUserPrompt: s.last_user_prompt || null, + workingDir: s.project_path || null, } }) } catch (err) { @@ -97,6 +101,64 @@ function getLocalClaudeSessions() { } } +function getLocalCodexSessions() { + try { + const rows = scanCodexSessions(100) + + return rows.map((s) => { + const total = s.totalTokens || (s.inputTokens + s.outputTokens) + const lastMsg = s.lastMessageAt ? new Date(s.lastMessageAt).getTime() : 0 + const firstMsg = s.firstMessageAt ? new Date(s.firstMessageAt).getTime() : 0 + return { + id: s.sessionId, + key: s.projectSlug || s.sessionId, + agent: s.projectSlug || 'codex-local', + kind: 'codex-cli', + age: formatAge(lastMsg), + model: s.model || 'codex', + tokens: `${formatTokens(s.inputTokens || 0)}/${formatTokens(s.outputTokens || 0)}`, + channel: 'local', + flags: [], + active: s.isActive, + startTime: firstMsg, + lastActivity: lastMsg, + source: 'local' as const, + userMessages: s.userMessages || 0, + assistantMessages: s.assistantMessages || 0, + toolUses: 0, + estimatedCost: 0, + lastUserPrompt: null, + totalTokens: total, + workingDir: s.projectPath || null, + } + }) + } catch (err) { + logger.warn({ err }, 'Failed to read local Codex sessions') + return [] + } +} + +function mergeLocalSessions( + claudeSessions: Array>, + codexSessions: Array>, +) { + const merged = [...claudeSessions, ...codexSessions] + const deduped = new Map>() + + for (const session of merged) { + const id = String(session?.id || '') + if (!id) continue + const existing = deduped.get(id) + const currentActivity = Number(session?.lastActivity || 0) + const existingActivity = Number(existing?.lastActivity || 0) + if (!existing || currentActivity > existingActivity) deduped.set(id, session) + } + + return Array.from(deduped.values()) + .sort((a, b) => Number(b?.lastActivity || 0) - Number(a?.lastActivity || 0)) + .slice(0, 100) +} + function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m` if (n >= 1000) return `${Math.round(n / 1000)}k` diff --git a/src/components/panels/cron-management-panel.tsx b/src/components/panels/cron-management-panel.tsx index 7090834..646c131 100644 --- a/src/components/panels/cron-management-panel.tsx +++ b/src/components/panels/cron-management-panel.tsx @@ -51,7 +51,8 @@ function formatDateLabel(date: Date): string { } export function CronManagementPanel() { - const { cronJobs, setCronJobs } = useMissionControl() + const { cronJobs, setCronJobs, dashboardMode } = useMissionControl() + const isLocalMode = dashboardMode === 'local' const [isLoading, setIsLoading] = useState(false) const [showAddForm, setShowAddForm] = useState(false) const [selectedJob, setSelectedJob] = useState(null) @@ -90,15 +91,40 @@ export function CronManagementPanel() { const loadCronJobs = useCallback(async () => { setIsLoading(true) try { - const response = await fetch('/api/cron?action=list') - const data = await response.json() - setCronJobs(data.jobs || []) + const cronResponse = await fetch('/api/cron?action=list') + const cronData = await cronResponse.json() + const cronList = Array.isArray(cronData.jobs) ? cronData.jobs : [] + + if (!isLocalMode) { + setCronJobs(cronList) + return + } + + const schedulerResponse = await fetch('/api/scheduler') + const schedulerData = await schedulerResponse.json() + const schedulerTasks = Array.isArray(schedulerData.tasks) ? schedulerData.tasks : [] + const mappedSchedulerJobs: CronJob[] = schedulerTasks.map((task: any) => ({ + id: task.id, + name: task.name || task.id || 'scheduler-task', + schedule: 'system-managed automation', + command: `Built-in local automation (${task.id || 'unknown'})`, + agentId: 'mission-control-local', + delivery: 'local', + enabled: task.running ? true : !!task.enabled, + lastRun: typeof task.lastRun === 'number' ? task.lastRun : undefined, + nextRun: typeof task.nextRun === 'number' ? task.nextRun : undefined, + lastStatus: task.running + ? 'running' + : (task.lastResult?.ok === false ? 'error' : (task.lastResult?.ok === true ? 'success' : undefined)), + })) + + setCronJobs([...cronList, ...mappedSchedulerJobs]) } catch (error) { log.error('Failed to load cron jobs:', error) } finally { setIsLoading(false) } - }, [setCronJobs]) + }, [isLocalMode, setCronJobs]) useEffect(() => { loadCronJobs() @@ -122,9 +148,44 @@ export function CronManagementPanel() { loadAvailableModels() }, []) - const loadJobLogs = async (jobName: string) => { + const loadJobLogs = async (job: CronJob) => { + const isLocalAutomation = (job.delivery === 'local' && job.agentId === 'mission-control-local') + if (isLocalAutomation) { + const logs: Array<{ timestamp: number; message: string; level: string }> = [] + if (job.lastRun) { + logs.push({ + timestamp: job.lastRun, + message: `Last run recorded for ${job.name}`, + level: job.lastStatus === 'error' ? 'error' : 'info', + }) + } + if (job.lastError) { + logs.push({ + timestamp: job.lastRun || Date.now(), + message: `Error: ${job.lastError}`, + level: 'error', + }) + } + if (job.nextRun) { + logs.push({ + timestamp: Date.now(), + message: `Next scheduled run: ${new Date(job.nextRun).toLocaleString()}`, + level: 'info', + }) + } + if (logs.length === 0) { + logs.push({ + timestamp: Date.now(), + message: 'No scheduler telemetry available yet for this local automation task', + level: 'info', + }) + } + setJobLogs(logs) + return + } + try { - const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(jobName)}`) + const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(job.name)}`) const data = await response.json() setJobLogs(data.logs || []) } catch (error) { @@ -158,7 +219,24 @@ export function CronManagementPanel() { } const triggerJob = async (job: CronJob) => { + const isLocalAutomation = (job.delivery === 'local' && job.agentId === 'mission-control-local') try { + if (isLocalAutomation) { + const response = await fetch('/api/scheduler', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: job.id }), + }) + const result = await response.json() + if (response.ok && result.ok) { + alert(`Local automation executed: ${result.message}`) + } else { + alert(`Local automation failed: ${result.error || result.message || 'Unknown error'}`) + } + await loadCronJobs() + return + } + const response = await fetch('/api/cron', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -253,7 +331,7 @@ export function CronManagementPanel() { const handleJobSelect = (job: CronJob) => { setSelectedJob(job) - loadJobLogs(job.name) + loadJobLogs(job) } const getStatusColor = (status?: string) => { @@ -426,7 +504,11 @@ export function CronManagementPanel() {

Calendar View

-

Interactive schedule across all matching cron jobs

+

+ {isLocalMode + ? 'Read-only schedule visibility across local cron jobs and automations' + : 'Interactive schedule across all matching cron jobs'} +

@@ -734,6 +821,7 @@ export function CronManagementPanel() { e.stopPropagation() removeJob(job) }} + disabled={isLocalAutomation} className="px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors" > Remove @@ -741,7 +829,7 @@ export function CronManagementPanel() {
- ))} + )})} )} @@ -763,6 +851,9 @@ export function CronManagementPanel() {
Model: {selectedJob.model}
)}
Status: {selectedJob.enabled ? '๐ŸŸข Enabled' : '๐Ÿ”ด Disabled'}
+ {selectedJob.delivery === 'local' && selectedJob.agentId === 'mission-control-local' && ( +
Source: Local scheduler automation
+ )} {selectedJob.nextRun && (
Next run: {new Date(selectedJob.nextRun).toLocaleString()}
)} diff --git a/src/components/panels/office-panel.tsx b/src/components/panels/office-panel.tsx index ed0ada5..a70cb24 100644 --- a/src/components/panels/office-panel.tsx +++ b/src/components/panels/office-panel.tsx @@ -1,15 +1,129 @@ 'use client' -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import type { MouseEvent, WheelEvent } from 'react' +import Image from 'next/image' import { useMissionControl, Agent } from '@/store' +import { buildOfficeLayout } from '@/lib/office-layout' type ViewMode = 'office' | 'org-chart' type OrgSegmentMode = 'category' | 'role' | 'status' -interface Desk { - agent: Agent - row: number - col: number +interface SessionAgentRow { + id: string + key: string + agent: string + kind: string + model: string + active: boolean + lastActivity?: number + workingDir?: string | null +} + +interface SeatPosition { + seatKey: string + x: number + y: number +} + +interface MovingWorker { + id: string + agentId: number + initials: string + colorClass: string + startX: number + startY: number + endX: number + endY: number + startedAt: number + durationMs: number + progress: number + path: Array<{ x: number; y: number }> + pathLengths: number[] + totalLength: number + destinationTile: string +} + +type SidebarFilter = 'all' | 'working' | 'idle' | 'attention' + +interface MapRoom { + id: string + label: string + x: number + y: number + w: number + h: number + style: string +} + +interface MapProp { + id: string + x: number + y: number + w: number + h: number + style: string + border: string +} + +interface LaunchToast { + kind: 'success' | 'info' | 'error' + title: string + detail: string +} + +type OfficeAction = 'focus' | 'pair' | 'break' +type TimeTheme = 'dawn' | 'day' | 'dusk' | 'night' + +type HotspotKind = 'room' | 'desk' + +interface OfficeHotspot { + kind: HotspotKind + id: string + label: string + x: number + y: number + stats: string[] +} + +interface OfficeEvent { + id: string + kind: 'action' | 'room' | 'desk' + message: string + at: number + severity: 'info' | 'warn' | 'good' +} + +interface ThemePalette { + shell: string + gridLine: string + haze: string + glow: string + corridor: string + corridorStripe: string + atmosphere: string + shadowVeil: string + floorFilter: string + spriteFilter: string + roomTone: string + floorOpacityA: number + floorOpacityB: number + accentGlow: string +} + +interface PersistedOfficePrefs { + version: 1 + viewMode: ViewMode + sidebarFilter: SidebarFilter + localSessionFilter?: 'running' | 'not-running' + mapZoom: number + mapPan: { x: number; y: number } + timeTheme: TimeTheme + showSidebar: boolean + showMinimap: boolean + showEvents: boolean + roomLayout: MapRoom[] + mapProps: MapProp[] } const statusGlow: Record = { @@ -61,6 +175,14 @@ function hashColor(name: string): string { return colors[Math.abs(hash) % colors.length] } +function hashNumber(value: string): number { + let hash = 0 + for (let i = 0; i < value.length; i += 1) { + hash = value.charCodeAt(i) + ((hash << 5) - hash) + } + return Math.abs(hash) +} + function formatLastSeen(ts?: number): string { if (!ts) return 'Never seen' const diff = Date.now() - ts * 1000 @@ -72,59 +194,1255 @@ function formatLastSeen(ts?: number): string { return `${Math.floor(h / 24)}d ago` } +function easeInOut(progress: number): number { + if (progress <= 0) return 0 + if (progress >= 1) return 1 + return progress < 0.5 + ? 2 * progress * progress + : 1 - Math.pow(-2 * progress + 2, 2) / 2 +} + +function getStatusEmote(status: Agent['status']): string { + if (status === 'busy') return '๐Ÿ’ผ' + if (status === 'idle') return 'โ˜•' + if (status === 'error') return 'โš ๏ธ' + return '๐Ÿ’ค' +} + +function inferLocalRole(row: SessionAgentRow): string { + const context = [ + String(row.agent || ''), + String(row.key || ''), + String(row.workingDir || ''), + String(row.kind || ''), + ].join(' ').toLowerCase() + + if (/frontend|ui|ux|design|landing|web/.test(context)) return 'frontend-engineer' + if (/backend|api|server|platform|infra|ops|sre|deploy|k8s|docker/.test(context)) return 'ops-engineer' + if (/research|science|ml|ai|llm|data|analytics/.test(context)) return 'research-analyst' + if (/qa|test|e2e|spec|validation/.test(context)) return 'qa-engineer' + if (/product|pm|roadmap|strategy/.test(context)) return 'product-manager' + if (/codex|claude|agent/.test(context)) return 'software-engineer' + return row.kind || 'local-session' +} + +function isInactiveLocalSession(agent: Agent): boolean { + return Boolean((agent.config as any)?.localSession) && agent.status !== 'busy' +} + +const MAP_COLS = 24 +const MAP_ROWS = 16 + +const ROOM_LAYOUT: MapRoom[] = [ + { id: 'eng', label: 'Engineering', x: 16, y: 22, w: 28, h: 22, style: 'bg-[#2a3558]' }, + { id: 'product', label: 'Product', x: 48, y: 22, w: 24, h: 22, style: 'bg-[#213a4d]' }, + { id: 'ops', label: 'Operations', x: 16, y: 49, w: 24, h: 24, style: 'bg-[#2f2f52]' }, + { id: 'research', label: 'Research', x: 44, y: 49, w: 22, h: 24, style: 'bg-[#2b334c]' }, + { id: 'lounge', label: 'Lounge', x: 70, y: 49, w: 16, h: 24, style: 'bg-[#2e4438]' }, +] + +const MAP_PROPS: MapProp[] = [ + { id: 'desk-a', x: 22, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' }, + { id: 'desk-b', x: 33, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' }, + { id: 'desk-c', x: 52, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' }, + { id: 'desk-d', x: 61, y: 30, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' }, + { id: 'desk-e', x: 22, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' }, + { id: 'desk-f', x: 31, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' }, + { id: 'desk-g', x: 48, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' }, + { id: 'desk-h', x: 57, y: 58, w: 8, h: 2.8, style: 'bg-[#33465f]', border: 'border-[#8aa9d8]/60' }, + { id: 'plant-l', x: 14, y: 47, w: 3, h: 5, style: 'bg-emerald-400/60', border: 'border-emerald-200/35' }, + { id: 'plant-r', x: 84, y: 47, w: 3, h: 5, style: 'bg-emerald-400/60', border: 'border-emerald-200/35' }, + { id: 'kitchen', x: 72, y: 57, w: 12, h: 10, style: 'bg-[#334137]', border: 'border-[#88d4a3]/35' }, +] + +const LOUNGE_WAYPOINTS = [ + { x: 74, y: 60 }, + { x: 79, y: 60 }, + { x: 82, y: 66 }, + { x: 76, y: 68 }, +] + +function getPropSprite(propId: string): string { + if (propId === 'desk-a' || propId === 'desk-b' || propId === 'desk-e' || propId === 'desk-f') return '/office-sprites/kenney/desk.png' + if (propId.startsWith('desk-')) return '/office-sprites/kenney/tableCross.png' + if (propId === 'plant-l') return '/office-sprites/kenney/plantSmall1.png' + if (propId === 'plant-r') return '/office-sprites/kenney/plantSmall2.png' + if (propId === 'kitchen') return '/office-sprites/kenney/rugRectangle.png' + return '' +} + +const HERO_SHEET_COLS = 6 +const HERO_SHEET_ROWS = 7 + +function getWorkerHeroFrame(status: Agent['status'], isMoving: boolean, frame: number) { + const phase = frame % 2 + const walkCol = phase === 0 ? 1 : 3 + if (isMoving) return { col: walkCol, row: 3 } // side-walk row + if (status === 'busy') return { col: walkCol, row: 0 } // forward loop as typing proxy + if (status === 'error') return { col: 5, row: 6 } + return { col: phase === 0 ? 0 : 5, row: 0 } // idle pulse +} + +interface WorkerVariant { + id: string + filter: string + accent: string +} + +const WORKER_VARIANTS: WorkerVariant[] = [ + { id: 'default', filter: 'none', accent: 'border-cyan-300/60' }, + { id: 'warm', filter: 'hue-rotate(18deg) saturate(1.08)', accent: 'border-amber-300/60' }, + { id: 'cool', filter: 'hue-rotate(-20deg) saturate(1.1)', accent: 'border-sky-300/60' }, + { id: 'mint', filter: 'hue-rotate(42deg) saturate(1.08)', accent: 'border-emerald-300/60' }, + { id: 'violet', filter: 'hue-rotate(64deg) saturate(1.12)', accent: 'border-violet-300/60' }, +] + +function getWorkerVariant(name: string): WorkerVariant { + return WORKER_VARIANTS[hashNumber(name) % WORKER_VARIANTS.length] +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)) +} + +function toTile(xPercent: number, yPercent: number) { + const col = clamp(Math.round((xPercent / 100) * (MAP_COLS - 1)), 0, MAP_COLS - 1) + const row = clamp(Math.round((yPercent / 100) * (MAP_ROWS - 1)), 0, MAP_ROWS - 1) + return { col, row } +} + +function tileToPercent(col: number, row: number) { + const x = (col / (MAP_COLS - 1)) * 100 + const y = (row / (MAP_ROWS - 1)) * 100 + return { x, y } +} + +function buildWalkabilityGrid() { + const walkable: boolean[][] = Array.from({ length: MAP_ROWS }, () => Array.from({ length: MAP_COLS }, () => true)) + // Border walls + for (let r = 0; r < MAP_ROWS; r += 1) { + walkable[r][0] = false + walkable[r][MAP_COLS - 1] = false + } + for (let c = 0; c < MAP_COLS; c += 1) { + walkable[0][c] = false + walkable[MAP_ROWS - 1][c] = false + } + + // Block static furniture/obstacles so routes prefer corridor lanes. + const obstacleRects = [ + { c1: 5, c2: 8, r1: 4, r2: 5 }, + { c1: 9, c2: 12, r1: 4, r2: 5 }, + { c1: 13, c2: 16, r1: 4, r2: 5 }, + { c1: 17, c2: 20, r1: 4, r2: 5 }, + { c1: 5, c2: 8, r1: 9, r2: 10 }, + { c1: 9, c2: 12, r1: 9, r2: 10 }, + { c1: 13, c2: 16, r1: 9, r2: 10 }, + { c1: 17, c2: 20, r1: 9, r2: 10 }, + { c1: 18, c2: 21, r1: 10, r2: 13 }, + ] + for (const rect of obstacleRects) { + for (let r = rect.r1; r <= rect.r2; r += 1) { + for (let c = rect.c1; c <= rect.c2; c += 1) { + if (r >= 0 && r < MAP_ROWS && c >= 0 && c < MAP_COLS) walkable[r][c] = false + } + } + } + + // Keep a central horizontal corridor open. + const corridorRow = 7 + for (let c = 1; c < MAP_COLS - 1; c += 1) walkable[corridorRow][c] = true + return walkable +} + +function tileKey(col: number, row: number): string { + return `${col},${row}` +} + +function findGridPath(start: { col: number; row: number }, end: { col: number; row: number }, walkable: boolean[][]) { + const inBounds = (col: number, row: number) => row >= 0 && row < MAP_ROWS && col >= 0 && col < MAP_COLS + const key = (col: number, row: number) => `${col},${row}` + const parse = (k: string) => { + const [c, r] = k.split(',').map(Number) + return { col: c, row: r } + } + + const open = new Set([key(start.col, start.row)]) + const cameFrom = new Map() + const gScore = new Map([[key(start.col, start.row), 0]]) + const fScore = new Map([[key(start.col, start.row), Math.abs(start.col - end.col) + Math.abs(start.row - end.row)]]) + + while (open.size > 0) { + let currentKey = '' + let lowest = Number.POSITIVE_INFINITY + for (const k of open) { + const f = fScore.get(k) ?? Number.POSITIVE_INFINITY + if (f < lowest) { + lowest = f + currentKey = k + } + } + if (!currentKey) break + + const current = parse(currentKey) + if (current.col === end.col && current.row === end.row) { + const path = [current] + let ck = currentKey + while (cameFrom.has(ck)) { + ck = cameFrom.get(ck)! + path.push(parse(ck)) + } + path.reverse() + return path + } + + open.delete(currentKey) + const neighbors = [ + { col: current.col + 1, row: current.row }, + { col: current.col - 1, row: current.row }, + { col: current.col, row: current.row + 1 }, + { col: current.col, row: current.row - 1 }, + ] + + for (const n of neighbors) { + if (!inBounds(n.col, n.row)) continue + if (!walkable[n.row][n.col]) continue + const nk = key(n.col, n.row) + const tentative = (gScore.get(currentKey) ?? Number.POSITIVE_INFINITY) + 1 + if (tentative >= (gScore.get(nk) ?? Number.POSITIVE_INFINITY)) continue + cameFrom.set(nk, currentKey) + gScore.set(nk, tentative) + fScore.set(nk, tentative + Math.abs(n.col - end.col) + Math.abs(n.row - end.row)) + open.add(nk) + } + } + + return [start, end] +} + +function buildPath(startX: number, startY: number, endX: number, endY: number, blockedTiles: Set = new Set()) { + const walkable = buildWalkabilityGrid() + const startTile = toTile(startX, startY) + const endTile = toTile(endX, endY) + for (const tile of blockedTiles) { + const [col, row] = tile.split(',').map(Number) + if (!Number.isFinite(col) || !Number.isFinite(row)) continue + if (row < 0 || row >= MAP_ROWS || col < 0 || col >= MAP_COLS) continue + walkable[row][col] = false + } + // Start/end must always be traversable. + walkable[startTile.row][startTile.col] = true + walkable[endTile.row][endTile.col] = true + const tilePath = findGridPath(startTile, endTile, walkable) + const path = tilePath.map((tile) => tileToPercent(tile.col, tile.row)) + const pathLengths: number[] = [0] + let totalLength = 0 + for (let i = 1; i < path.length; i += 1) { + const dx = path[i].x - path[i - 1].x + const dy = path[i].y - path[i - 1].y + totalLength += Math.hypot(dx, dy) + pathLengths.push(totalLength) + } + return { path, pathLengths, totalLength } +} + +function pointAlongPath(path: Array<{ x: number; y: number }>, pathLengths: number[], totalLength: number, progress: number) { + if (path.length === 0) return { x: 0, y: 0 } + if (path.length === 1 || totalLength <= 0) return path[path.length - 1] + const target = totalLength * clamp(progress, 0, 1) + let idx = 1 + while (idx < pathLengths.length && pathLengths[idx] < target) idx += 1 + const prevIdx = Math.max(0, idx - 1) + const prevLen = pathLengths[prevIdx] ?? 0 + const nextLen = pathLengths[Math.min(idx, pathLengths.length - 1)] ?? totalLength + const local = nextLen > prevLen ? (target - prevLen) / (nextLen - prevLen) : 0 + const a = path[prevIdx] + const b = path[Math.min(idx, path.length - 1)] + return { + x: a.x + (b.x - a.x) * local, + y: a.y + (b.y - a.y) * local, + } +} + export function OfficePanel() { - const { agents } = useMissionControl() + const { agents, dashboardMode, currentUser } = useMissionControl() + const isLocalMode = dashboardMode === 'local' const [localAgents, setLocalAgents] = useState([]) + const [sessionAgents, setSessionAgents] = useState([]) const [viewMode, setViewMode] = useState('office') const [orgSegmentMode, setOrgSegmentMode] = useState('category') const [selectedAgent, setSelectedAgent] = useState(null) + const [showFlightDeckModal, setShowFlightDeckModal] = useState(false) + const [flightDeckDownloadUrl, setFlightDeckDownloadUrl] = useState('https://flightdeck.example.com/download') + const [flightDeckLaunching, setFlightDeckLaunching] = useState(false) + const [launchToast, setLaunchToast] = useState(null) + const [selectedHotspot, setSelectedHotspot] = useState(null) + const [agentActionOverrides, setAgentActionOverrides] = useState>(new Map()) + const [officeEvents, setOfficeEvents] = useState([]) + const [roomLayoutState, setRoomLayoutState] = useState(() => ROOM_LAYOUT.map((room) => ({ ...room }))) + const [mapPropsState, setMapPropsState] = useState(() => MAP_PROPS.map((prop) => ({ ...prop }))) + const [showSidebar, setShowSidebar] = useState(true) + const [showMinimap, setShowMinimap] = useState(true) + const [showEvents, setShowEvents] = useState(true) + const [localSessionFilter, setLocalSessionFilter] = useState<'running' | 'not-running'>('running') const [loading, setLoading] = useState(true) + const [localBootstrapping, setLocalBootstrapping] = useState(isLocalMode) + const [sidebarFilter, setSidebarFilter] = useState('all') + const [spriteFrame, setSpriteFrame] = useState(0) + const [timeTheme, setTimeTheme] = useState('night') + const [mapZoom, setMapZoom] = useState(1) + const [mapPan, setMapPan] = useState({ x: 0, y: 0 }) + const mapViewportRef = useRef(null) + const localBootstrapRetries = useRef(0) + const mapDragActiveRef = useRef(false) + const mapDragOriginRef = useRef({ x: 0, y: 0 }) + const mapPanStartRef = useRef({ x: 0, y: 0 }) + const prevStatusRef = useRef>(new Map()) + const transitionTimersRef = useRef>>(new Map()) + const launchToastTimerRef = useRef | null>(null) + const roamReturnTimersRef = useRef>>(new Map()) + const movingAgentIdsRef = useRef>(new Set()) + const movingWorkersRef = useRef([]) + const renderedWorkersRef = useRef>([]) + const [transitioningAgentIds, setTransitioningAgentIds] = useState>(new Set()) + const previousSeatMapRef = useRef>(new Map()) + const [movingWorkers, setMovingWorkers] = useState([]) const fetchAgents = useCallback(async () => { + let nextLocalAgents: Agent[] = [] + let nextSessionAgents: Agent[] = [] + try { - const res = await fetch('/api/agents') - if (res.ok) { - const data = await res.json() - setLocalAgents(data.agents || []) + const [agentRes, sessionRes] = await Promise.all([ + fetch('/api/agents'), + isLocalMode ? fetch('/api/sessions') : Promise.resolve(null), + ]) + + if (agentRes.ok) { + const data = await agentRes.json() + nextLocalAgents = Array.isArray(data.agents) ? data.agents : [] + setLocalAgents(nextLocalAgents) + } + + if (isLocalMode && sessionRes?.ok) { + const sessionJson = await sessionRes.json().catch(() => ({})) + const rows = Array.isArray(sessionJson?.sessions) ? sessionJson.sessions as SessionAgentRow[] : [] + const byAgent = new Map() + let idx = 0 + + for (const row of rows) { + const name = String(row.agent || '').trim() + if (!name) continue + const existing = byAgent.get(name) + const nowSec = Math.floor(Date.now() / 1000) + const lastSeenSec = row.lastActivity ? Math.floor(row.lastActivity / 1000) : nowSec + const inferredRole = inferLocalRole(row) + const candidate: Agent = { + id: -5000 - idx, + name, + role: inferredRole, + status: row.active ? 'busy' : 'idle', + last_seen: lastSeenSec, + last_activity: `${row.kind || 'session'} ยท ${row.model || 'unknown model'}`, + session_key: row.key || row.id, + created_at: nowSec, + updated_at: nowSec, + config: { + localSession: { + sessionId: row.id, + key: row.key, + workingDir: row.workingDir || null, + kind: row.kind || 'session', + }, + }, + } + + const existingLastSeen = existing?.last_seen || 0 + const candidateLastSeen = candidate.last_seen || 0 + const shouldReplace = + !existing || + (existing.status !== 'busy' && candidate.status === 'busy') || + (existing.status === candidate.status && candidateLastSeen > existingLastSeen) + + if (shouldReplace) { + byAgent.set(name, candidate) + idx += 1 + } + } + + nextSessionAgents = Array.from(byAgent.values()) + setSessionAgents(nextSessionAgents) } } catch { /* ignore */ } + + if (isLocalMode) { + const hasAnyAgents = nextLocalAgents.length > 0 || nextSessionAgents.length > 0 + if (hasAnyAgents) setLocalBootstrapping(false) + if (!hasAnyAgents && localBootstrapRetries.current < 5) { + localBootstrapRetries.current += 1 + setLoading(true) + setTimeout(() => { + void fetchAgents() + }, 700) + return + } + } + setLoading(false) - }, []) + }, [isLocalMode]) useEffect(() => { fetchAgents() }, [fetchAgents]) + useEffect(() => { + if (!isLocalMode) { + setLocalBootstrapping(false) + return + } + setLocalBootstrapping(true) + const bootstrapTimer = setTimeout(() => { + setLocalBootstrapping(false) + }, 4500) + return () => clearTimeout(bootstrapTimer) + }, [isLocalMode]) + useEffect(() => { const interval = setInterval(fetchAgents, 10000) return () => clearInterval(interval) }, [fetchAgents]) - const displayAgents = agents.length > 0 ? agents : localAgents + useEffect(() => { + const interval = setInterval(() => { + setSpriteFrame((current) => (current + 1) % 2) + }, 380) + return () => clearInterval(interval) + }, []) + + const displayAgents = useMemo(() => { + if (agents.length > 0) return agents + if (isLocalMode) { + const merged = new Map() + for (const agent of [...sessionAgents, ...localAgents]) { + const key = String(agent.name || '').trim().toLowerCase() + if (!key) continue + const existing = merged.get(key) + if (!existing) { + merged.set(key, agent) + continue + } + const existingLastSeen = existing.last_seen || 0 + const candidateLastSeen = agent.last_seen || 0 + const shouldReplace = + (existing.status !== 'busy' && agent.status === 'busy') || + (existing.status === agent.status && candidateLastSeen > existingLastSeen) + if (shouldReplace) merged.set(key, agent) + } + return Array.from(merged.values()) + } + if (localAgents.length > 0) return localAgents + return [] + }, [agents, isLocalMode, localAgents, sessionAgents]) + + const visibleDisplayAgents = useMemo(() => { + if (!isLocalMode) return displayAgents + if (localSessionFilter === 'not-running') { + return displayAgents.filter((agent) => isInactiveLocalSession(agent)) + } + return displayAgents.filter((agent) => !isInactiveLocalSession(agent)) + }, [displayAgents, isLocalMode, localSessionFilter]) const counts = useMemo(() => { const c = { idle: 0, busy: 0, error: 0, offline: 0 } - for (const a of displayAgents) c[a.status] = (c[a.status] || 0) + 1 + for (const a of visibleDisplayAgents) c[a.status] = (c[a.status] || 0) + 1 return c - }, [displayAgents]) - - const desks: Desk[] = useMemo(() => { - const cols = Math.max(2, Math.ceil(Math.sqrt(displayAgents.length))) - return displayAgents.map((agent, i) => ({ - agent, - row: Math.floor(i / cols), - col: i % cols, - })) - }, [displayAgents]) + }, [visibleDisplayAgents]) const roleGroups = useMemo(() => { const groups = new Map() - for (const a of displayAgents) { + for (const a of visibleDisplayAgents) { const role = a.role || 'Unassigned' if (!groups.has(role)) groups.set(role, []) groups.get(role)!.push(a) } return groups + }, [visibleDisplayAgents]) + + const officeLayout = useMemo(() => buildOfficeLayout(visibleDisplayAgents), [visibleDisplayAgents]) + + const currentSeatMap = useMemo(() => { + const seatMap = new Map() + const zoneSeatTemplates: Record> = { + engineering: [{ x: 24, y: 36 }, { x: 32, y: 36 }, { x: 24, y: 42 }, { x: 32, y: 42 }], + product: [{ x: 54, y: 36 }, { x: 62, y: 36 }, { x: 54, y: 42 }, { x: 62, y: 42 }], + operations: [{ x: 24, y: 64 }, { x: 32, y: 64 }, { x: 24, y: 70 }, { x: 32, y: 70 }], + research: [{ x: 50, y: 64 }, { x: 58, y: 64 }, { x: 50, y: 70 }, { x: 58, y: 70 }], + quality: [{ x: 58, y: 64 }, { x: 66, y: 64 }, { x: 58, y: 70 }, { x: 66, y: 70 }], + 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 }], + } + const fallbackByZone: Record = { + engineering: ['operations', 'general'], + product: ['research', 'general'], + operations: ['engineering', 'general'], + research: ['product', 'general'], + quality: ['research', 'general'], + general: ['general'], + } + + const usageByZone = new Map() + const pullSeat = (zoneId: string) => { + const templates = zoneSeatTemplates[zoneId] || zoneSeatTemplates.general + const used = usageByZone.get(zoneId) || 0 + const chosen = templates[used % templates.length] || { x: 38, y: 47 } + const overflowBand = Math.floor(used / templates.length) + usageByZone.set(zoneId, used + 1) + return { + x: chosen.x, + y: chosen.y + overflowBand * 3.5, + } + } + + for (let zoneIndex = 0; zoneIndex < officeLayout.length; zoneIndex += 1) { + const zone = officeLayout[zoneIndex].zone + const sortedWorkers = [...officeLayout[zoneIndex].workers].sort((a, b) => a.agent.name.localeCompare(b.agent.name)) + + for (const worker of sortedWorkers) { + const primaryTemplates = zoneSeatTemplates[zone.id] || zoneSeatTemplates.general + const primaryUsed = usageByZone.get(zone.id) || 0 + const inPrimaryCapacity = primaryUsed < primaryTemplates.length * 2 + const targetZone = inPrimaryCapacity ? zone.id : (fallbackByZone[zone.id] || ['general'])[0] + const seat = pullSeat(targetZone) + const x = clamp(seat.x, 8, 92) + const y = clamp(seat.y, 12, 92) + seatMap.set(worker.agent.id, { + seatKey: `${targetZone}:${worker.anchor.seatLabel}`, + x, + y, + }) + } + } + return seatMap + }, [officeLayout]) + + const gameWorkers = useMemo(() => { + const workers: Array<{ agent: Agent; x: number; y: number; zoneLabel: string; seatLabel: string }> = [] + for (let zoneIndex = 0; zoneIndex < officeLayout.length; zoneIndex += 1) { + const zone = officeLayout[zoneIndex] + for (const worker of zone.workers) { + const seat = currentSeatMap.get(worker.agent.id) + if (!seat) continue + workers.push({ + agent: worker.agent, + x: seat.x, + y: seat.y, + zoneLabel: zone.zone.label, + seatLabel: worker.anchor.seatLabel, + }) + } + } + return workers + }, [currentSeatMap, officeLayout]) + + const floorTiles = useMemo(() => { + const tiles: Array<{ id: string; x: number; y: number; w: number; h: number; sprite: boolean }> = [] + const tileW = 100 / MAP_COLS + const tileH = 100 / MAP_ROWS + for (let row = 0; row < MAP_ROWS; row += 1) { + for (let col = 0; col < MAP_COLS; col += 1) { + tiles.push({ + id: `tile-${row}-${col}`, + x: col * tileW, + y: row * tileH, + w: tileW, + h: tileH, + sprite: (row + col) % 2 === 0, + }) + } + } + return tiles + }, []) + + const movingPositionByAgent = useMemo(() => { + const positions = new Map() + for (const worker of movingWorkers) { + const eased = easeInOut(worker.progress) + positions.set( + worker.agentId, + pointAlongPath(worker.path, worker.pathLengths, worker.totalLength, eased), + ) + } + return positions + }, [movingWorkers]) + + const movingDirectionByAgent = useMemo(() => { + const directions = new Map() + for (const worker of movingWorkers) { + directions.set(worker.agentId, { + dx: worker.endX - worker.startX, + dy: worker.endY - worker.startY, + }) + } + return directions + }, [movingWorkers]) + + const renderedWorkers = useMemo(() => { + return gameWorkers.map((worker) => { + const movingPosition = movingPositionByAgent.get(worker.agent.id) + return { + ...worker, + x: movingPosition?.x ?? worker.x, + y: movingPosition?.y ?? worker.y, + isMoving: Boolean(movingPosition), + direction: movingDirectionByAgent.get(worker.agent.id) || { dx: 0, dy: 0 }, + variant: getWorkerVariant(worker.agent.name), + } + }) + }, [gameWorkers, movingDirectionByAgent, movingPositionByAgent]) + + const officePrefsKey = useMemo(() => { + const userPart = currentUser?.id ? `u${currentUser.id}` : `guest-${currentUser?.username || 'anon'}` + const pathPart = typeof window === 'undefined' ? 'server' : window.location.pathname.replace(/[^a-zA-Z0-9/_-]/g, '_') + return `mc-office-prefs:v1:${dashboardMode}:${userPart}:${pathPart}` + }, [currentUser?.id, currentUser?.username, dashboardMode]) + + useEffect(() => { + if (typeof window === 'undefined') return + try { + const raw = window.localStorage.getItem(officePrefsKey) + if (!raw) return + const prefs = JSON.parse(raw) as PersistedOfficePrefs + if (!prefs || prefs.version !== 1) return + setViewMode(prefs.viewMode || 'office') + setSidebarFilter(prefs.sidebarFilter || 'all') + setLocalSessionFilter( + prefs.localSessionFilter === 'not-running' ? 'not-running' : 'running', + ) + setMapZoom(Number.isFinite(prefs.mapZoom) ? clamp(prefs.mapZoom, 0.8, 2.2) : 1) + setMapPan({ + x: Number.isFinite(prefs.mapPan?.x) ? prefs.mapPan.x : 0, + y: Number.isFinite(prefs.mapPan?.y) ? prefs.mapPan.y : 0, + }) + setTimeTheme(prefs.timeTheme || 'night') + setShowSidebar(prefs.showSidebar !== false) + setShowMinimap(prefs.showMinimap !== false) + setShowEvents(prefs.showEvents !== false) + if (Array.isArray(prefs.roomLayout) && prefs.roomLayout.length > 0) { + setRoomLayoutState(prefs.roomLayout.map((room) => ({ ...room }))) + } + if (Array.isArray(prefs.mapProps) && prefs.mapProps.length > 0) { + setMapPropsState(prefs.mapProps.map((prop) => ({ ...prop }))) + } + } catch { + // ignore corrupted local preferences + } + }, [officePrefsKey]) + + useEffect(() => { + if (typeof window === 'undefined') return + const payload: PersistedOfficePrefs = { + version: 1, + viewMode, + sidebarFilter, + localSessionFilter, + mapZoom, + mapPan, + timeTheme, + showSidebar, + showMinimap, + showEvents, + roomLayout: roomLayoutState, + mapProps: mapPropsState, + } + try { + window.localStorage.setItem(officePrefsKey, JSON.stringify(payload)) + } catch { + // ignore storage failures + } + }, [ + officePrefsKey, + mapPan, + mapPropsState, + mapZoom, + localSessionFilter, + roomLayoutState, + showEvents, + showMinimap, + showSidebar, + sidebarFilter, + timeTheme, + viewMode, + ]) + + useEffect(() => { + const updateThemeFromClock = () => { + const hour = new Date().getHours() + if (hour >= 6 && hour < 11) setTimeTheme('dawn') + else if (hour >= 11 && hour < 17) setTimeTheme('day') + else if (hour >= 17 && hour < 20) setTimeTheme('dusk') + else setTimeTheme('night') + } + updateThemeFromClock() + const interval = setInterval(updateThemeFromClock, 60_000) + return () => clearInterval(interval) + }, []) + + const themePalette = useMemo(() => { + if (timeTheme === 'dawn') { + return { + shell: 'radial-gradient(circle at 20% 10%, rgba(255,177,108,0.52) 0, rgba(78,82,132,0.9) 48%, rgba(19,24,41,1) 100%)', + gridLine: 'rgba(255,212,166,0.2)', + haze: 'radial-gradient(circle at 52% 26%, rgba(255,205,146,0.34), transparent 62%)', + glow: 'linear-gradient(to bottom, rgba(255,238,210,0.16), transparent 35%, rgba(0,0,0,0.2))', + corridor: '#3f3f54', + corridorStripe: '#ffca95', + atmosphere: 'radial-gradient(circle at 15% 8%, rgba(255,191,122,0.34), transparent 46%), radial-gradient(circle at 82% 18%, rgba(255,224,184,0.18), transparent 40%)', + shadowVeil: 'linear-gradient(to bottom, rgba(27,22,35,0.15), rgba(13,17,33,0.38))', + floorFilter: 'hue-rotate(-8deg) saturate(1.02) brightness(1.1) contrast(1.03)', + spriteFilter: 'hue-rotate(-4deg) saturate(1.04) brightness(1.05)', + roomTone: 'linear-gradient(to bottom right, rgba(255,219,167,0.2), rgba(82,67,96,0.12))', + floorOpacityA: 0.95, + floorOpacityB: 0.8, + accentGlow: 'rgba(255,183,120,0.32)', + } + } + if (timeTheme === 'day') { + return { + shell: 'radial-gradient(circle at 20% 12%, rgba(164,203,255,0.48) 0, rgba(41,76,128,0.88) 46%, rgba(16,26,46,1) 100%)', + gridLine: 'rgba(183,218,255,0.24)', + haze: 'radial-gradient(circle at 52% 28%, rgba(196,236,255,0.25), transparent 58%)', + glow: 'linear-gradient(to bottom, rgba(255,255,255,0.14), transparent 30%, rgba(4,16,33,0.1))', + corridor: '#3a4258', + corridorStripe: '#b8d5ff', + atmosphere: 'radial-gradient(circle at 18% 5%, rgba(183,230,255,0.3), transparent 45%), radial-gradient(circle at 84% 16%, rgba(216,241,255,0.2), transparent 42%)', + shadowVeil: 'linear-gradient(to bottom, rgba(16,30,49,0.08), rgba(9,18,35,0.24))', + floorFilter: 'hue-rotate(6deg) saturate(1.08) brightness(1.2) contrast(1.04)', + spriteFilter: 'hue-rotate(4deg) saturate(1.08) brightness(1.08)', + roomTone: 'linear-gradient(to bottom right, rgba(196,236,255,0.18), rgba(81,116,171,0.08))', + floorOpacityA: 0.98, + floorOpacityB: 0.86, + accentGlow: 'rgba(176,232,255,0.3)', + } + } + if (timeTheme === 'dusk') { + return { + shell: 'radial-gradient(circle at 20% 10%, rgba(222,129,187,0.44) 0, rgba(45,44,91,0.92) 47%, rgba(12,14,30,1) 100%)', + gridLine: 'rgba(224,169,255,0.2)', + haze: 'radial-gradient(circle at 48% 30%, rgba(247,172,220,0.24), transparent 62%)', + glow: 'linear-gradient(to bottom, rgba(255,220,245,0.1), transparent 30%, rgba(0,0,0,0.24))', + corridor: '#413b58', + corridorStripe: '#d7b0ff', + atmosphere: 'radial-gradient(circle at 14% 10%, rgba(255,160,198,0.27), transparent 44%), radial-gradient(circle at 85% 18%, rgba(198,150,255,0.18), transparent 40%)', + shadowVeil: 'linear-gradient(to bottom, rgba(29,20,46,0.18), rgba(9,9,24,0.42))', + floorFilter: 'hue-rotate(20deg) saturate(1.05) brightness(0.95) contrast(1.05)', + spriteFilter: 'hue-rotate(18deg) saturate(1.08) brightness(0.98)', + roomTone: 'linear-gradient(to bottom right, rgba(244,164,209,0.17), rgba(88,62,126,0.16))', + floorOpacityA: 0.9, + floorOpacityB: 0.75, + accentGlow: 'rgba(232,141,206,0.27)', + } + } + return { + shell: 'radial-gradient(circle at 22% 10%, rgba(57,93,161,0.72) 0, rgba(12,20,38,0.95) 42%, rgba(8,12,22,1) 100%)', + gridLine: 'rgba(115,139,191,0.2)', + haze: 'radial-gradient(circle at 50% 30%, rgba(89,148,255,0.19), transparent 60%)', + glow: 'linear-gradient(to bottom, rgba(240,248,255,0.05), transparent 30%, rgba(0,0,0,0.24))', + corridor: '#303746', + corridorStripe: '#9cc2ff', + atmosphere: 'radial-gradient(circle at 16% 7%, rgba(93,141,255,0.26), transparent 45%), radial-gradient(circle at 82% 15%, rgba(133,169,255,0.16), transparent 42%)', + shadowVeil: 'linear-gradient(to bottom, rgba(8,13,25,0.34), rgba(5,8,18,0.56))', + floorFilter: 'hue-rotate(26deg) saturate(0.9) brightness(0.72) contrast(1.1)', + spriteFilter: 'hue-rotate(18deg) saturate(0.94) brightness(0.84)', + roomTone: 'linear-gradient(to bottom right, rgba(94,133,207,0.17), rgba(19,27,52,0.24))', + floorOpacityA: 0.84, + floorOpacityB: 0.66, + accentGlow: 'rgba(116,152,255,0.26)', + } + }, [timeTheme]) + + const nightSparkles = useMemo( + () => + Array.from({ length: 14 }, (_, idx) => { + const seed = hashNumber(`night-${idx}`) + return { + id: idx, + x: 6 + (seed % 88), + y: 6 + ((seed >> 3) % 38), + delay: (seed % 7) * 0.4, + size: 2 + (seed % 3), + } + }), + [], + ) + + const heatmapPoints = useMemo(() => { + return renderedWorkers.map((worker) => { + const action = agentActionOverrides.get(worker.agent.id) + let intensity = worker.agent.status === 'busy' ? 0.95 : worker.agent.status === 'idle' ? 0.45 : 0.7 + if (action === 'focus') intensity += 0.25 + if (action === 'pair') intensity += 0.15 + if (worker.isMoving) intensity += 0.2 + const radius = worker.agent.status === 'busy' ? 14 : 10 + const hue = worker.agent.status === 'busy' ? 'rgba(255,191,84,' : worker.agent.status === 'idle' ? 'rgba(88,220,139,' : 'rgba(120,189,255,' + return { + id: worker.agent.id, + x: worker.x, + y: worker.y, + radius, + color: `${hue}${Math.min(0.85, Math.max(0.2, intensity)).toFixed(2)})`, + } + }) + }, [agentActionOverrides, renderedWorkers]) + + const rosterRows = useMemo(() => { + return gameWorkers.map(({ agent }) => { + const minutesIdle = agent.last_seen ? Math.floor((Date.now() / 1000 - agent.last_seen) / 60) : Number.POSITIVE_INFINITY + const needsAttention = isLocalMode && agent.status === 'idle' && minutesIdle >= 15 + return { + agent, + minutesIdle, + needsAttention, + } + }) + }, [gameWorkers, isLocalMode]) + + const filteredRosterRows = useMemo(() => { + if (sidebarFilter === 'all') return rosterRows + if (sidebarFilter === 'working') return rosterRows.filter((row) => row.agent.status === 'busy') + if (sidebarFilter === 'idle') return rosterRows.filter((row) => row.agent.status === 'idle') + return rosterRows.filter((row) => row.needsAttention) + }, [rosterRows, sidebarFilter]) + + const pathEdges = useMemo(() => { + const edges: Array<{ x1: number; y1: number; x2: number; y2: number }> = [] + const zoneGroups = new Map>() + for (const worker of gameWorkers) { + if (!zoneGroups.has(worker.zoneLabel)) zoneGroups.set(worker.zoneLabel, []) + zoneGroups.get(worker.zoneLabel)!.push({ x: worker.x, y: worker.y }) + } + + for (const points of zoneGroups.values()) { + const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y) + for (let i = 0; i < sorted.length - 1; i += 1) { + edges.push({ + x1: sorted[i].x, + y1: sorted[i].y + 2, + x2: sorted[i + 1].x, + y2: sorted[i + 1].y + 2, + }) + } + } + + // Trunk corridor and vertical connectors to mimic an office hallway system. + edges.push({ x1: 16, y1: 47, x2: 84, y2: 47 }) + edges.push({ x1: 30, y1: 33, x2: 30, y2: 47 }) + edges.push({ x1: 60, y1: 33, x2: 60, y2: 47 }) + edges.push({ x1: 28, y1: 47, x2: 28, y2: 68 }) + edges.push({ x1: 54, y1: 47, x2: 54, y2: 68 }) + + return edges + }, [gameWorkers]) + + const enqueueMovement = useCallback( + (agent: Agent, startX: number, startY: number, endX: number, endY: number, durationMs = 2200) => { + const blockedTiles = new Set() + for (const worker of renderedWorkersRef.current) { + if (worker.agent.id === agent.id) continue + const tile = toTile(worker.x, worker.y) + blockedTiles.add(tileKey(tile.col, tile.row)) + } + for (const moving of movingWorkersRef.current) { + if (moving.agentId === agent.id) continue + blockedTiles.add(moving.destinationTile) + } + const destination = toTile(endX, endY) + const movement: MovingWorker = { + id: `${agent.id}-${Date.now()}-${Math.floor(Math.random() * 1000)}`, + agentId: agent.id, + initials: getInitials(agent.name), + colorClass: hashColor(agent.name), + startX, + startY, + endX, + endY, + startedAt: Date.now(), + durationMs, + progress: 0, + ...buildPath(startX, startY, endX, endY, blockedTiles), + destinationTile: tileKey(destination.col, destination.row), + } + setMovingWorkers((current) => { + if (current.some((item) => item.agentId === agent.id)) return current + return [...current, movement] + }) + }, + [], + ) + + useEffect(() => { + const prev = prevStatusRef.current + const next = new Map() + const toAnimate: number[] = [] + + for (const agent of displayAgents) { + next.set(agent.id, agent.status) + const prevStatus = prev.get(agent.id) + if (prevStatus && prevStatus !== agent.status) { + toAnimate.push(agent.id) + } + } + + prevStatusRef.current = next + + if (toAnimate.length === 0) return + setTransitioningAgentIds((current) => { + const updated = new Set(current) + for (const id of toAnimate) updated.add(id) + return updated + }) + + for (const id of toAnimate) { + const existingTimer = transitionTimersRef.current.get(id) + if (existingTimer) clearTimeout(existingTimer) + const timer = setTimeout(() => { + setTransitioningAgentIds((current) => { + const updated = new Set(current) + updated.delete(id) + return updated + }) + transitionTimersRef.current.delete(id) + }, 2200) + transitionTimersRef.current.set(id, timer) + } }, [displayAgents]) + useEffect(() => { + const previous = previousSeatMapRef.current + + for (const agent of displayAgents) { + const currentSeat = currentSeatMap.get(agent.id) + const previousSeat = previous.get(agent.id) + if (!currentSeat || !previousSeat) continue + if (currentSeat.seatKey === previousSeat.seatKey) continue + + enqueueMovement(agent, previousSeat.x, previousSeat.y, currentSeat.x, currentSeat.y, 1800) + } + + previousSeatMapRef.current = currentSeatMap + }, [currentSeatMap, displayAgents, enqueueMovement]) + + useEffect(() => { + if (movingWorkers.length === 0) return + + let rafId: number | null = null + const step = () => { + const now = Date.now() + setMovingWorkers((current) => { + if (current.length === 0) return current + const updated = current + .map((worker) => { + const linear = (now - worker.startedAt) / worker.durationMs + const progress = Math.max(0, Math.min(1, linear)) + return { ...worker, progress } + }) + .filter((worker) => worker.progress < 1) + return updated + }) + rafId = window.requestAnimationFrame(step) + } + + rafId = window.requestAnimationFrame(step) + return () => { + if (rafId != null) window.cancelAnimationFrame(rafId) + } + }, [movingWorkers.length]) + + useEffect(() => { + movingWorkersRef.current = movingWorkers + movingAgentIdsRef.current = new Set(movingWorkers.map((worker) => worker.agentId)) + }, [movingWorkers]) + + useEffect(() => { + renderedWorkersRef.current = renderedWorkers + }, [renderedWorkers]) + + const pushOfficeEvent = useCallback((event: Omit) => { + const next: OfficeEvent = { + ...event, + id: `${Date.now()}-${Math.floor(Math.random() * 1000)}`, + at: Date.now(), + } + setOfficeEvents((current) => [next, ...current].slice(0, 12)) + }, []) + + useEffect(() => { + if (!isLocalMode) return + const interval = setInterval(() => { + const activeMovingIds = movingAgentIdsRef.current + const idleCandidates = renderedWorkersRef.current + .filter((worker) => worker.agent.status === 'idle' && !worker.isMoving && !activeMovingIds.has(worker.agent.id)) + .sort((a, b) => a.agent.name.localeCompare(b.agent.name)) + .slice(0, 2) + + if (idleCandidates.length === 0) return + const cycle = Math.floor(Date.now() / 14_000) + + for (const worker of idleCandidates) { + const waypoint = LOUNGE_WAYPOINTS[(hashNumber(worker.agent.name) + cycle) % LOUNGE_WAYPOINTS.length] + enqueueMovement(worker.agent, worker.x, worker.y, waypoint.x, waypoint.y, 2200) + + const existingReturnTimer = roamReturnTimersRef.current.get(worker.agent.id) + if (existingReturnTimer) clearTimeout(existingReturnTimer) + const returnTimer = setTimeout(() => { + const seat = currentSeatMap.get(worker.agent.id) + if (seat) { + enqueueMovement(worker.agent, waypoint.x, waypoint.y, seat.x, seat.y, 2200) + } + roamReturnTimersRef.current.delete(worker.agent.id) + }, 2700) + roamReturnTimersRef.current.set(worker.agent.id, returnTimer) + } + }, 14_000) + return () => clearInterval(interval) + }, [currentSeatMap, enqueueMovement, isLocalMode]) + + useEffect(() => { + const interval = setInterval(() => { + const workers = renderedWorkersRef.current + if (workers.length === 0) return + const sample = workers[Math.floor(Math.random() * workers.length)] + const mood = sample.agent.status === 'busy' ? 'good' : sample.agent.status === 'idle' ? 'warn' : 'info' + pushOfficeEvent({ + kind: 'room', + severity: mood, + message: `${sample.zoneLabel}: ${sample.agent.name} status is ${statusLabel[sample.agent.status].toLowerCase()}.`, + }) + }, 22000) + return () => clearInterval(interval) + }, [pushOfficeEvent]) + + useEffect(() => { + const timers = transitionTimersRef.current + const roamTimers = roamReturnTimersRef.current + return () => { + for (const timer of timers.values()) clearTimeout(timer) + timers.clear() + for (const timer of roamTimers.values()) clearTimeout(timer) + roamTimers.clear() + if (launchToastTimerRef.current) { + clearTimeout(launchToastTimerRef.current) + launchToastTimerRef.current = null + } + } + }, []) + + const showLaunchToast = (toast: LaunchToast) => { + setLaunchToast(toast) + if (launchToastTimerRef.current) { + clearTimeout(launchToastTimerRef.current) + } + launchToastTimerRef.current = setTimeout(() => { + setLaunchToast(null) + launchToastTimerRef.current = null + }, 5000) + } + + const executeAgentAction = useCallback((agent: Agent, action: OfficeAction) => { + setAgentActionOverrides((current) => { + const next = new Map(current) + next.set(agent.id, action) + return next + }) + + if (action === 'focus') { + pushOfficeEvent({ kind: 'action', severity: 'good', message: `${agent.name} is now in deep focus mode.` }) + return + } + + if (action === 'pair') { + const partner = renderedWorkersRef.current.find((worker) => worker.agent.id !== agent.id)?.agent + pushOfficeEvent({ + kind: 'action', + severity: 'info', + message: partner + ? `${agent.name} started a pairing session with ${partner.name}.` + : `${agent.name} started a solo pairing prep session.`, + }) + return + } + + const worker = renderedWorkersRef.current.find((item) => item.agent.id === agent.id) + const waypoint = LOUNGE_WAYPOINTS[hashNumber(agent.name) % LOUNGE_WAYPOINTS.length] + if (worker) { + enqueueMovement(agent, worker.x, worker.y, waypoint.x, waypoint.y, 2200) + pushOfficeEvent({ kind: 'action', severity: 'warn', message: `${agent.name} is taking a short lounge break.` }) + return + } + pushOfficeEvent({ kind: 'action', severity: 'warn', message: `${agent.name} requested a break.` }) + }, [enqueueMovement, pushOfficeEvent]) + + const openFlightDeck = async (agent: Agent) => { + setFlightDeckLaunching(true) + try { + const res = await fetch('/api/local/flight-deck', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agent: agent.name, + session: agent.session_key || '', + }), + }) + const json = await res.json().catch(() => ({})) + if (!res.ok || json?.installed === false) { + if (typeof json?.downloadUrl === 'string' && json.downloadUrl) { + setFlightDeckDownloadUrl(json.downloadUrl) + } + setShowFlightDeckModal(true) + showLaunchToast({ + kind: 'info', + title: 'Flight Deck not installed', + detail: 'Install Flight Deck to open this session.', + }) + return + } + if (!json?.launched) { + // Fallback for environments where native launch fails. + if (typeof json?.fallbackUrl === 'string' && json.fallbackUrl) { + window.open(json.fallbackUrl, '_blank', 'noopener,noreferrer') + showLaunchToast({ + kind: 'info', + title: 'Opened browser fallback', + detail: 'Native launch failed, opened Flight Deck web fallback.', + }) + return + } + showLaunchToast({ + kind: 'error', + title: 'Flight Deck launch failed', + detail: json?.error || 'Unable to launch Flight Deck for this session.', + }) + return + } + showLaunchToast({ + kind: 'success', + title: 'Opened in Flight Deck', + detail: 'Launched native Flight Deck app for this session.', + }) + } catch { + setShowFlightDeckModal(true) + showLaunchToast({ + kind: 'error', + title: 'Flight Deck request failed', + detail: 'Could not reach local launch endpoint.', + }) + } finally { + setFlightDeckLaunching(false) + } + } + + const resetMapView = () => { + setMapZoom(1) + setMapPan({ x: 0, y: 0 }) + } + + const onMapWheel = (event: WheelEvent) => { + event.preventDefault() + const delta = event.deltaY > 0 ? -0.08 : 0.08 + setMapZoom((current) => Math.min(2.2, Math.max(0.8, Number((current + delta).toFixed(2))))) + } + + const onMapMouseDown = (event: MouseEvent) => { + mapDragActiveRef.current = true + mapDragOriginRef.current = { x: event.clientX, y: event.clientY } + mapPanStartRef.current = { ...mapPan } + } + + const onMapMouseMove = (event: MouseEvent) => { + if (!mapDragActiveRef.current) return + const dx = event.clientX - mapDragOriginRef.current.x + const dy = event.clientY - mapDragOriginRef.current.y + setMapPan({ + x: mapPanStartRef.current.x + dx, + y: mapPanStartRef.current.y + dy, + }) + } + + const endMapDrag = () => { + mapDragActiveRef.current = false + } + + const focusMapPoint = useCallback( + (xPercent: number, yPercent: number) => { + const viewport = mapViewportRef.current + if (!viewport) return + const rect = viewport.getBoundingClientRect() + const nextPanX = rect.width / 2 - (xPercent / 100) * rect.width * mapZoom + const nextPanY = rect.height / 2 - (yPercent / 100) * rect.height * mapZoom + setMapPan({ x: nextPanX, y: nextPanY }) + }, + [mapZoom], + ) + + const nudgeSelectedHotspot = useCallback((dx: number, dy: number) => { + if (!selectedHotspot) return + if (selectedHotspot.kind === 'room') { + setRoomLayoutState((current) => + current.map((room) => { + if (room.id !== selectedHotspot.id) return room + return { + ...room, + x: clamp(room.x + dx, 2, 94 - room.w), + y: clamp(room.y + dy, 8, 94 - room.h), + } + }), + ) + setSelectedHotspot((current) => + current ? { ...current, x: clamp(current.x + dx, 2, 98), y: clamp(current.y + dy, 8, 98) } : current, + ) + return + } + setMapPropsState((current) => + current.map((prop) => { + if (prop.id !== selectedHotspot.id) return prop + return { + ...prop, + x: clamp(prop.x + dx, 2, 98 - prop.w), + y: clamp(prop.y + dy, 8, 98 - prop.h), + } + }), + ) + setSelectedHotspot((current) => + current ? { ...current, x: clamp(current.x + dx, 2, 98), y: clamp(current.y + dy, 8, 98) } : current, + ) + }, [selectedHotspot]) + + const resizeSelectedRoom = useCallback((dw: number, dh: number) => { + if (!selectedHotspot || selectedHotspot.kind !== 'room') return + setRoomLayoutState((current) => + current.map((room) => { + if (room.id !== selectedHotspot.id) return room + const nextW = clamp(room.w + dw, 10, 40) + const nextH = clamp(room.h + dh, 10, 36) + return { + ...room, + w: nextW, + h: nextH, + x: clamp(room.x, 2, 98 - nextW), + y: clamp(room.y, 8, 98 - nextH), + } + }), + ) + }, [selectedHotspot]) + + const resetOfficeLayout = useCallback(() => { + setRoomLayoutState(ROOM_LAYOUT.map((room) => ({ ...room }))) + setMapPropsState(MAP_PROPS.map((prop) => ({ ...prop }))) + setMapZoom(1) + setMapPan({ x: 0, y: 0 }) + setShowSidebar(true) + setShowMinimap(true) + setShowEvents(true) + setSelectedHotspot(null) + pushOfficeEvent({ kind: 'room', severity: 'info', message: 'Office layout reset to defaults.' }) + }, [pushOfficeEvent]) + const categoryGroups = useMemo(() => { const groups = new Map() const getCategory = (agent: Agent): string => { @@ -137,7 +1455,7 @@ export function OfficePanel() { return 'Other' } - for (const a of displayAgents) { + for (const a of visibleDisplayAgents) { const category = getCategory(a) if (!groups.has(category)) groups.set(category, []) groups.get(category)!.push(a) @@ -154,11 +1472,11 @@ export function OfficePanel() { return a.localeCompare(b) }) ) - }, [displayAgents]) + }, [visibleDisplayAgents]) const statusGroups = useMemo(() => { const groups = new Map() - for (const a of displayAgents) { + for (const a of visibleDisplayAgents) { const key = statusLabel[a.status] || a.status if (!groups.has(key)) groups.set(key, []) groups.get(key)!.push(a) @@ -175,7 +1493,7 @@ export function OfficePanel() { return a.localeCompare(b) }) ) - }, [displayAgents]) + }, [visibleDisplayAgents]) const orgGroups = useMemo(() => { if (orgSegmentMode === 'role') return roleGroups @@ -183,11 +1501,13 @@ export function OfficePanel() { return categoryGroups }, [categoryGroups, orgSegmentMode, roleGroups, statusGroups]) - if (loading && displayAgents.length === 0) { + if ((loading || (isLocalMode && localBootstrapping)) && visibleDisplayAgents.length === 0) { return (
- Loading office... + + {isLocalMode ? 'Scanning local sessions...' : 'Loading office...'} +
) } @@ -228,71 +1548,578 @@ export function OfficePanel() {
- {displayAgents.length === 0 ? ( + {visibleDisplayAgents.length === 0 ? (
๐Ÿข

The office is empty

Add agents to see them appear here

) : viewMode === 'office' ? ( -
-
-
Main Floor
- -
- {desks.map(({ agent }) => ( -
setSelectedAgent(agent)} - className={`relative group cursor-pointer rounded-xl border-2 p-4 transition-all duration-300 hover:scale-[1.03] hover:z-10 shadow-lg ${statusGlow[agent.status]}`} - style={{ background: 'var(--card)' }} +
+ {showSidebar && ( +
+
+
TEAMY
+
{visibleDisplayAgents.length} online
+
+
+ {([ + { key: 'all', label: 'All' }, + { key: 'working', label: 'Working' }, + { key: 'idle', label: 'Idle' }, + { key: 'attention', label: 'Needs Attention' }, + ] as Array<{ key: SidebarFilter; label: string }>).map((item) => ( + + ))} +
+ {isLocalMode && ( +
+ + +
+ )} +
+ {filteredRosterRows.map(({ agent, minutesIdle, needsAttention }) => ( + + ))} + {filteredRosterRows.length === 0 && ( +
No workers in this filter.
+ )} +
+
+ )} + +
+
+
+
+
+ {timeTheme === 'dawn' && ( +
+ )} + {timeTheme === 'day' && ( + <> +
+
+ + )} + {timeTheme === 'dusk' && ( +
+ )} + {timeTheme === 'night' && ( + <> +
+ {nightSparkles.map((spark) => ( +
+ ))} + + )} + +
+ MAIN FLOOR +
+
+ + {Math.round(mapZoom * 100)}% + + +
+
+ {(['dawn', 'day', 'dusk', 'night'] as TimeTheme[]).map((item) => ( + + ))} +
+
+ + + + +
+ +
+
+ {floorTiles.map((tile) => ( +
+ ))} +
+ + {/* Corridor base */} +
+
+ +
+ {heatmapPoints.map((point) => ( +
+ ))} +
+ + {/* Zone rooms */} + {roomLayoutState.map((room) => ( +
{ + event.stopPropagation() + const activeInRoom = renderedWorkers.filter((worker) => worker.zoneLabel === room.label).length + setSelectedHotspot({ + kind: 'room', + id: room.id, + label: room.label, + x: room.x + room.w / 2, + y: room.y + room.h / 2, + stats: [ + `${activeInRoom} workers present`, + `${Math.round(room.w * room.h)} tile area`, + 'Click worker to inspect session', + ], + }) + pushOfficeEvent({ + kind: 'room', + severity: 'info', + message: `${room.label} room inspected (${activeInRoom} workers).`, + }) + }} + > +
+
+ {room.label} +
+
+ ))} + + {/* Props / furniture */} + {mapPropsState.map((prop) => ( +
{ + event.stopPropagation() + const nearest = renderedWorkers + .slice() + .sort((a, b) => Math.hypot(a.x - prop.x, a.y - prop.y) - Math.hypot(b.x - prop.x, b.y - prop.y))[0] + setSelectedHotspot({ + kind: 'desk', + id: prop.id, + label: prop.id.replace(/^desk-/, 'Desk ').replace(/^plant-/, 'Plant ').replace(/^kitchen$/, 'Lounge Rug'), + x: prop.x + prop.w / 2, + y: prop.y + prop.h / 2, + stats: [ + nearest ? `Nearest worker: ${nearest.agent.name}` : 'No nearby worker', + `Footprint ${prop.w.toFixed(1)}x${prop.h.toFixed(1)}`, + 'Use action buttons in agent modal', + ], + }) + pushOfficeEvent({ + kind: 'desk', + severity: 'info', + message: `${prop.id} inspected${nearest ? ` near ${nearest.agent.name}` : ''}.`, + }) + }} + > + +
+ ))} + + + + {renderedWorkers.map(({ agent, x, y, zoneLabel, seatLabel, isMoving, direction }) => ( +
+
+ +
+
+
+ + +
- {agent.last_activity && ( -
- {agent.last_activity} + + + {agentActionOverrides.has(agent.id) && ( +
+ {agentActionOverrides.get(agent.id)}
)} - {agent.taskStats && agent.taskStats.in_progress > 0 && ( -
- {agent.taskStats.in_progress} + {(transitioningAgentIds.has(agent.id) || isMoving) && ( +
+ moving
)} + +
+ {zoneLabel} +
))}
-
- ๐Ÿชด -
- โ˜• Break room -
- ๐Ÿชด + {showMinimap && ( +
event.stopPropagation()} + onClick={(event) => { + event.stopPropagation() + const target = event.currentTarget + const rect = target.getBoundingClientRect() + const x = clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100) + const y = clamp(((event.clientY - rect.top) / rect.height) * 100, 0, 100) + focusMapPoint(x, y) + }} + > +
Minimap
+
+ {roomLayoutState.map((room) => ( +
+ ))} +
+ {renderedWorkers.map((worker) => ( +
+ )} + + {showEvents && ( +
event.stopPropagation()} + > +
Office Events
+
+ Busy Heat + Idle Heat + Other +
+
event.stopPropagation()}> + {officeEvents.length === 0 && ( +
No events yet. Click a room/desk or run an action.
+ )} + {officeEvents.map((event) => ( +
+
+ + {event.kind} + + {formatLastSeen(Math.floor(event.at / 1000))} +
+
{event.message}
+
+ ))} +
+ {selectedHotspot && ( +
+
+
{selectedHotspot.label}
+
{selectedHotspot.kind}
+
+
+ {selectedHotspot.stats.map((line) => ( +
{line}
+ ))} +
+
+ + + + + + +
+ {selectedHotspot.kind === 'room' && ( +
+ + + + +
+ )} +
+ )} +
+ )}
) : ( @@ -415,10 +2242,139 @@ export function OfficePanel() { Session: {selectedAgent.session_key}
)} + +
+
Quick Actions
+
+ + + +
+
+ + {isLocalMode && ( +
+ +
+ Private/pro companion app for session deep-dive +
+
+ )}
)} + + {showFlightDeckModal && ( +
setShowFlightDeckModal(false)}> +
e.stopPropagation()}> +
+
+

Flight Deck Required

+

+ Flight Deck is the private/pro companion app for Mission Control. +

+
+ +
+ +
+ It looks like Flight Deck is not installed on this machine. + Install it to open agent sessions with richer controls and diagnostics. +
+ +
+ + + Download Flight Deck + +
+
+
+ )} + + {launchToast && ( +
+
+ +
+
{launchToast.title}
+
{launchToast.detail}
+
+
+
+ )} +
) } diff --git a/src/components/panels/super-admin-panel.tsx b/src/components/panels/super-admin-panel.tsx index 05be490..c2ffd10 100644 --- a/src/components/panels/super-admin-panel.tsx +++ b/src/components/panels/super-admin-panel.tsx @@ -63,16 +63,32 @@ interface GatewayOption { is_primary?: number } +interface SchedulerTask { + id: string + name: string + enabled: boolean + lastRun: number | null + nextRun: number + running: boolean + lastResult?: { + ok: boolean + message: string + timestamp: number + } +} + const TENANT_PAGE_SIZE = 8 const JOB_PAGE_SIZE = 8 export function SuperAdminPanel() { - const { currentUser } = useMissionControl() + const { currentUser, dashboardMode } = useMissionControl() + const isLocal = dashboardMode === 'local' const [tenants, setTenants] = useState([]) const [jobs, setJobs] = useState([]) const [selectedJobId, setSelectedJobId] = useState(null) const [selectedJobEvents, setSelectedJobEvents] = useState([]) + const [localJobEvents, setLocalJobEvents] = useState>({}) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null) @@ -123,25 +139,96 @@ export function SuperAdminPanel() { const load = useCallback(async () => { try { - const [tenantsRes, jobsRes, gatewaysRes] = await Promise.all([ + const [tenantsRes, jobsRes, gatewaysRes, schedulerRes] = await Promise.all([ fetch('/api/super/tenants', { cache: 'no-store' }), fetch('/api/super/provision-jobs?limit=250', { cache: 'no-store' }), fetch('/api/gateways', { cache: 'no-store' }), + isLocal ? fetch('/api/scheduler', { cache: 'no-store' }) : Promise.resolve(null), ]) const tenantsJson = await tenantsRes.json().catch(() => ({})) const jobsJson = await jobsRes.json().catch(() => ({})) const gatewaysJson = await gatewaysRes.json().catch(() => ({})) + const schedulerJson = schedulerRes ? await schedulerRes.json().catch(() => ({})) : {} if (!tenantsRes.ok) throw new Error(tenantsJson?.error || 'Failed to load tenants') if (!jobsRes.ok) throw new Error(jobsJson?.error || 'Failed to load provision jobs') - const tenantRows = Array.isArray(tenantsJson?.tenants) ? tenantsJson.tenants : [] - const jobRows = Array.isArray(jobsJson?.jobs) ? jobsJson.jobs : [] + let tenantRows = Array.isArray(tenantsJson?.tenants) ? tenantsJson.tenants : [] + let jobRows = Array.isArray(jobsJson?.jobs) ? jobsJson.jobs : [] const gatewayRows = Array.isArray(gatewaysJson?.gateways) ? gatewaysJson.gateways : [] + const schedulerTasks: SchedulerTask[] = Array.isArray(schedulerJson?.tasks) ? schedulerJson.tasks : [] + const localEvents: Record = {} + + if (isLocal) { + if (tenantRows.length === 0) { + const primaryGateway = gatewayRows.find((gw: any) => Number(gw?.is_primary) === 1) + const now = Math.floor(Date.now() / 1000) + tenantRows = [{ + id: -1, + slug: 'local-system', + display_name: 'Local Mission Control', + linux_user: currentUser?.username || 'local', + created_by: 'local', + owner_gateway: primaryGateway?.name || 'local', + status: 'active', + plan_tier: 'local', + gateway_port: Number(primaryGateway?.port || 0) || null, + dashboard_port: null, + created_at: now, + latest_job_id: null, + latest_job_status: null, + }] + } + + if (jobRows.length === 0 && schedulerTasks.length > 0) { + jobRows = schedulerTasks.map((task, index) => { + const id = -1000 - index + const status = task.running + ? 'running' + : (!task.enabled ? 'cancelled' : (task.lastResult?.ok === false ? 'failed' : (task.lastRun ? 'completed' : 'queued'))) + const eventRows: ProvisionEvent[] = [] + if (task.lastResult) { + eventRows.push({ + id: id * -10, + level: task.lastResult.ok ? 'info' : 'error', + step_key: task.id, + message: task.lastResult.message, + created_at: Math.floor(task.lastResult.timestamp / 1000), + }) + } + eventRows.push({ + id: id * -10 + 1, + level: 'info', + step_key: task.id, + message: `Next run: ${new Date(task.nextRun).toLocaleString()}`, + created_at: Math.floor(Date.now() / 1000), + }) + localEvents[id] = eventRows + + const lastRunSec = task.lastRun ? Math.floor(task.lastRun / 1000) : null + return { + id, + tenant_id: -1, + tenant_slug: 'local-system', + tenant_display_name: 'Local Mission Control', + job_type: 'automation', + status, + dry_run: 1, + requested_by: 'scheduler', + approved_by: null, + started_at: lastRunSec, + completed_at: status !== 'running' ? lastRunSec : null, + error_text: task.lastResult?.ok === false ? task.lastResult.message : null, + created_at: lastRunSec || Math.floor(task.nextRun / 1000), + } as ProvisionJob + }) + } + } setTenants(tenantRows) setJobs(jobRows) + setLocalJobEvents(localEvents) setGatewayOptions(gatewayRows.map((g: any) => ({ id: Number(g.id), name: String(g.name), status: g.status, is_primary: g.is_primary }))) setGatewayLoadError(gatewaysRes.ok ? null : (gatewaysJson?.error || 'Failed to load gateways')) setError(null) @@ -150,9 +237,16 @@ export function SuperAdminPanel() { } finally { setLoading(false) } - }, []) + }, [currentUser?.username, isLocal]) const loadJobDetail = useCallback(async (jobId: number) => { + if (isLocal && jobId < 0) { + setSelectedJobId(jobId) + setSelectedJobEvents(localJobEvents[jobId] || []) + setActiveTab('events') + return + } + try { const res = await fetch(`/api/super/provision-jobs/${jobId}`, { cache: 'no-store' }) const json = await res.json().catch(() => ({})) @@ -163,7 +257,7 @@ export function SuperAdminPanel() { } catch (e: any) { showFeedback(false, e?.message || 'Failed to load job details') } - }, []) + }, [isLocal, localJobEvents]) useEffect(() => { load() @@ -406,7 +500,9 @@ export function SuperAdminPanel() {

Super Mission Control

- Multi-tenant provisioning control plane with approval gates and safer destructive actions. + {isLocal + ? 'Local control plane view over scheduler automations and runtime state.' + : 'Multi-tenant provisioning control plane with approval gates and safer destructive actions.'}

@@ -645,21 +741,27 @@ export function SuperAdminPanel() { )} - - {openActionMenu === menuKey && ( -
+ {isLocal && tenant.id < 0 ? ( + Local read-only + ) : ( + <> -
+ {openActionMenu === menuKey && ( +
+ +
+ )} + )} @@ -758,42 +860,53 @@ export function SuperAdminPanel() {
Appr: {job.approved_by || '-'}
- - {openActionMenu === menuKey && ( -
+ {isLocal && job.id < 0 ? ( + + ) : ( + <> - - - -
+ {openActionMenu === menuKey && ( +
+ + + + +
+ )} + )} diff --git a/src/components/panels/webhook-panel.tsx b/src/components/panels/webhook-panel.tsx index 3d5f5f1..3e84b07 100644 --- a/src/components/panels/webhook-panel.tsx +++ b/src/components/panels/webhook-panel.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { useSmartPoll } from '@/lib/use-smart-poll' +import { useMissionControl } from '@/store' interface Webhook { id: number @@ -33,6 +34,16 @@ interface Delivery { created_at: number } +interface SchedulerTask { + id: string + name: string + enabled: boolean + lastRun: number | null + nextRun: number | null + running: boolean + lastResult?: { ok: boolean; message: string; timestamp: number } +} + const AVAILABLE_EVENTS = [ { value: '*', label: 'All events', description: 'Receive all event types' }, { value: 'agent.error', label: 'Agent error', description: 'Agent enters error state' }, @@ -48,7 +59,10 @@ const AVAILABLE_EVENTS = [ ] export function WebhookPanel() { + const { dashboardMode } = useMissionControl() + const isLocalMode = dashboardMode === 'local' const [webhooks, setWebhooks] = useState([]) + const [webhookAutomations, setWebhookAutomations] = useState([]) const [deliveries, setDeliveries] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') @@ -57,6 +71,7 @@ export function WebhookPanel() { const [testingId, setTestingId] = useState(null) const [testResult, setTestResult] = useState(null) const [newSecret, setNewSecret] = useState(null) + const [runningAutomationId, setRunningAutomationId] = useState(null) const fetchWebhooks = useCallback(async () => { try { @@ -88,9 +103,30 @@ export function WebhookPanel() { } catch { /* silent */ } }, [selectedWebhook]) + const fetchWebhookAutomations = useCallback(async () => { + if (!isLocalMode) { + setWebhookAutomations([]) + return + } + try { + const res = await fetch('/api/scheduler') + if (!res.ok) return + const data = await res.json() + const tasks = Array.isArray(data.tasks) ? data.tasks : [] + const webhookTasks = tasks.filter((task: SchedulerTask) => + typeof task.id === 'string' && task.id.includes('webhook') + ) + setWebhookAutomations(webhookTasks) + } catch { + // Keep UI usable if scheduler endpoint is unavailable. + } + }, [isLocalMode]) + useEffect(() => { fetchWebhooks() }, [fetchWebhooks]) useEffect(() => { fetchDeliveries() }, [fetchDeliveries]) + useEffect(() => { fetchWebhookAutomations() }, [fetchWebhookAutomations]) useSmartPoll(fetchWebhooks, 60000, { pauseWhenDisconnected: true }) + useSmartPoll(fetchWebhookAutomations, 60000, { pauseWhenDisconnected: true }) async function handleCreate(form: { name: string; url: string; events: string[] }) { try { @@ -142,6 +178,29 @@ export function WebhookPanel() { } } + async function handleRunAutomation(taskId: string) { + setRunningAutomationId(taskId) + try { + const res = await fetch('/api/scheduler', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId }), + }) + const data = await res.json() + setTestResult({ + success: !!data.ok && res.ok, + error: data.error || (!data.ok ? data.message : null), + duration_ms: undefined, + status_code: res.status, + }) + await fetchWebhookAutomations() + } catch { + setTestResult({ success: false, error: 'Failed to run local automation' }) + } finally { + setRunningAutomationId(null) + } + } + function formatTime(ts: number) { return new Date(ts * 1000).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', @@ -223,6 +282,41 @@ export function WebhookPanel() { {/* Webhook list */}
+ {isLocalMode && webhookAutomations.length > 0 && ( +
+

Local Webhook Automations

+

+ Local scheduler tasks that support webhook delivery and retries +

+
+ {webhookAutomations.map((task) => ( +
+
+
+
+ + {task.name} + {task.id} +
+
+ {task.nextRun ? `Next run ${formatTime(task.nextRun / 1000)}` : 'No next run scheduled'} + {task.lastResult?.message ? ` ยท ${task.lastResult.message}` : ''} +
+
+ +
+
+ ))} +
+
+ )} + {loading && webhooks.length === 0 ? (
{[...Array(3)].map((_, i) =>
)} diff --git a/src/lib/codex-sessions.ts b/src/lib/codex-sessions.ts new file mode 100644 index 0000000..5bcbf00 --- /dev/null +++ b/src/lib/codex-sessions.ts @@ -0,0 +1,219 @@ +import { readdirSync, readFileSync, statSync } from 'fs' +import { basename, join } from 'path' +import { config } from './config' +import { logger } from './logger' + +const ACTIVE_THRESHOLD_MS = 5 * 60 * 1000 +const DEFAULT_FILE_SCAN_LIMIT = 120 + +export interface CodexSessionStats { + sessionId: string + projectSlug: string + projectPath: string | null + model: string | null + userMessages: number + assistantMessages: number + inputTokens: number + outputTokens: number + totalTokens: number + firstMessageAt: string | null + lastMessageAt: string | null + isActive: boolean +} + +interface ParsedFile { + path: string + mtimeMs: number +} + +function asObject(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function deriveSessionId(filePath: string): string { + const name = basename(filePath, '.jsonl') + const match = name.match(/([0-9a-f]{8,}-[0-9a-f-]{8,})$/i) + return match?.[1] || name +} + +function listRecentCodexSessionFiles(limit: number): ParsedFile[] { + const root = join(config.homeDir, '.codex', 'sessions') + const files: ParsedFile[] = [] + const stack = [root] + + while (stack.length > 0) { + const dir = stack.pop() + if (!dir) continue + + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + continue + } + + for (const entry of entries) { + const fullPath = join(dir, entry) + let stat + try { + stat = statSync(fullPath) + } catch { + continue + } + + if (stat.isDirectory()) { + stack.push(fullPath) + continue + } + + if (!stat.isFile() || !fullPath.endsWith('.jsonl')) continue + files.push({ path: fullPath, mtimeMs: stat.mtimeMs }) + } + } + + files.sort((a, b) => b.mtimeMs - a.mtimeMs) + return files.slice(0, Math.max(1, limit)) +} + +function parseCodexSessionFile(filePath: string): CodexSessionStats | null { + let content: string + try { + content = readFileSync(filePath, 'utf-8') + } catch { + return null + } + + const lines = content.split('\n').filter(Boolean) + if (lines.length === 0) return null + + let sessionId = deriveSessionId(filePath) + let projectPath: string | null = null + let model: string | null = null + let userMessages = 0 + let assistantMessages = 0 + let inputTokens = 0 + let outputTokens = 0 + let totalTokens = 0 + let firstMessageAt: string | null = null + let lastMessageAt: string | null = null + + for (const line of lines) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + + const entry = asObject(parsed) + if (!entry) continue + + const timestamp = asString(entry.timestamp) + if (timestamp) { + if (!firstMessageAt) firstMessageAt = timestamp + lastMessageAt = timestamp + } + + const entryType = asString(entry.type) + const payload = asObject(entry.payload) + + if (entryType === 'session_meta' && payload) { + const metaId = asString(payload.id) + if (metaId) sessionId = metaId + + const cwd = asString(payload.cwd) + if (cwd) projectPath = cwd + + const metaModel = asString(payload.model) + if (metaModel) model = metaModel + + const startedAt = asString(payload.timestamp) + if (startedAt && !firstMessageAt) firstMessageAt = startedAt + continue + } + + if (entryType === 'response_item' && payload) { + const payloadType = asString(payload.type) + const role = asString(payload.role) + if (payloadType === 'message' && role === 'user') userMessages++ + if (payloadType === 'message' && role === 'assistant') assistantMessages++ + continue + } + + if (entryType === 'event_msg' && payload) { + const msgType = asString(payload.type) + if (msgType !== 'token_count') continue + + const info = asObject(payload.info) + const totals = info ? asObject(info.total_token_usage) : null + if (totals) { + const inTokens = asNumber(totals.input_tokens) || 0 + const cached = asNumber(totals.cached_input_tokens) || 0 + const outTokens = asNumber(totals.output_tokens) || 0 + const allTokens = asNumber(totals.total_tokens) || (inTokens + cached + outTokens) + inputTokens = Math.max(inputTokens, inTokens + cached) + outputTokens = Math.max(outputTokens, outTokens) + totalTokens = Math.max(totalTokens, allTokens) + } + + const limits = asObject(payload.rate_limits) + const limitName = limits ? asString(limits.limit_name) : null + if (!model && limitName) model = limitName + } + } + + if (!lastMessageAt && !firstMessageAt) return null + + const projectSlug = projectPath + ? basename(projectPath) + : 'codex-local' + const lastMessageMs = lastMessageAt ? new Date(lastMessageAt).getTime() : 0 + const isActive = lastMessageMs > 0 && (Date.now() - lastMessageMs) < ACTIVE_THRESHOLD_MS + + return { + sessionId, + projectSlug, + projectPath, + model, + userMessages, + assistantMessages, + inputTokens, + outputTokens, + totalTokens, + firstMessageAt, + lastMessageAt: lastMessageAt || firstMessageAt, + isActive, + } +} + +export function scanCodexSessions(limit = DEFAULT_FILE_SCAN_LIMIT): CodexSessionStats[] { + try { + const files = listRecentCodexSessionFiles(limit) + const sessions: CodexSessionStats[] = [] + + for (const file of files) { + const parsed = parseCodexSessionFile(file.path) + if (parsed) sessions.push(parsed) + } + + sessions.sort((a, b) => { + const aTs = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0 + const bTs = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0 + return bTs - aTs + }) + + return sessions + } catch (err) { + logger.warn({ err }, 'Failed to scan Codex sessions') + return [] + } +} diff --git a/src/lib/office-layout.ts b/src/lib/office-layout.ts new file mode 100644 index 0000000..8fa2c44 --- /dev/null +++ b/src/lib/office-layout.ts @@ -0,0 +1,132 @@ +import type { Agent } from '@/store' + +export type OfficeZoneType = 'engineering' | 'operations' | 'research' | 'product' | 'quality' | 'general' + +export interface OfficeZoneDefinition { + id: OfficeZoneType + label: string + icon: string + accentClass: string + roleKeywords: string[] +} + +export interface WorkstationAnchor { + deskId: string + seatLabel: string + row: number + col: number + x: number + y: number +} + +export interface ZonedAgent { + agent: Agent + anchor: WorkstationAnchor +} + +export interface OfficeZoneLayout { + zone: OfficeZoneDefinition + workers: ZonedAgent[] +} + +export const OFFICE_ZONES: OfficeZoneDefinition[] = [ + { + id: 'engineering', + label: 'Engineering Bay', + icon: '๐Ÿง‘โ€๐Ÿ’ป', + accentClass: 'border-cyan-500/30 bg-cyan-500/10', + roleKeywords: ['engineer', 'dev', 'frontend', 'backend', 'fullstack', 'software'], + }, + { + id: 'operations', + label: 'Operations Pod', + icon: '๐Ÿ› ๏ธ', + accentClass: 'border-amber-500/30 bg-amber-500/10', + roleKeywords: ['ops', 'sre', 'infra', 'platform', 'reliability'], + }, + { + id: 'research', + label: 'Research Corner', + icon: '๐Ÿ”ฌ', + accentClass: 'border-violet-500/30 bg-violet-500/10', + roleKeywords: ['research', 'science', 'analyst', 'ai'], + }, + { + id: 'product', + label: 'Product Studio', + icon: '๐Ÿ“', + accentClass: 'border-emerald-500/30 bg-emerald-500/10', + roleKeywords: ['product', 'pm', 'design', 'ux', 'ui'], + }, + { + id: 'quality', + label: 'Quality Lab', + icon: '๐Ÿงช', + accentClass: 'border-rose-500/30 bg-rose-500/10', + roleKeywords: ['qa', 'test', 'quality'], + }, + { + id: 'general', + label: 'General Workspace', + icon: '๐Ÿข', + accentClass: 'border-slate-500/30 bg-slate-500/10', + roleKeywords: [], + }, +] + +function normalizeRole(role: string | undefined): string { + return String(role || '').toLowerCase() +} + +export function getZoneByRole(role: string | undefined): OfficeZoneDefinition { + const normalized = normalizeRole(role) + for (const zone of OFFICE_ZONES) { + if (zone.id === 'general') continue + if (zone.roleKeywords.some((keyword) => normalized.includes(keyword))) { + return zone + } + } + return OFFICE_ZONES.find((zone) => zone.id === 'general')! +} + +function buildAnchor(index: number, columnCount: number): WorkstationAnchor { + const row = Math.floor(index / columnCount) + const col = index % columnCount + const rowLabel = String.fromCharCode(65 + row) + const seatLabel = `${rowLabel}${col + 1}` + return { + deskId: `desk-${seatLabel.toLowerCase()}`, + seatLabel, + row, + col, + // Useful for future absolute-position movement/collision mechanics. + x: col * 220 + 110, + y: row * 160 + 80, + } +} + +export function buildOfficeLayout(agents: Agent[]): OfficeZoneLayout[] { + const zoneMap = new Map() + for (const zone of OFFICE_ZONES) zoneMap.set(zone.id, []) + + for (const agent of agents) { + const zone = getZoneByRole(agent.role) + zoneMap.get(zone.id)!.push(agent) + } + + const result: OfficeZoneLayout[] = [] + for (const zone of OFFICE_ZONES) { + const workers = zoneMap.get(zone.id) || [] + if (workers.length === 0) continue + + const columns = workers.length >= 8 ? 4 : workers.length >= 4 ? 3 : 2 + const zoned = workers.map((agent, i) => ({ + agent, + anchor: buildAnchor(i, columns), + })) + + result.push({ zone, workers: zoned }) + } + + return result.sort((a, b) => b.workers.length - a.workers.length) +}