From ffb02577728dd2bc9c246f5c281b57120299cb5f Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Wed, 4 Mar 2026 21:22:10 +0700 Subject: [PATCH 1/5] feat: upgrade local-mode virtual office and flight deck integration --- .../cc0-hero/ATTRIBUTION-CC0-HERO.txt | 4 + public/office-sprites/cc0-hero/player.png | Bin 0 -> 13967 bytes .../cc0-hero/player_full_animation.png | Bin 0 -> 50616 bytes public/office-sprites/desk.svg | 11 + public/office-sprites/floor-tile.svg | 7 + .../kenney/ATTRIBUTION-KENNEY.txt | 22 + public/office-sprites/kenney/chairDesk.png | Bin 0 -> 997 bytes .../office-sprites/kenney/computerScreen.png | Bin 0 -> 353 bytes public/office-sprites/kenney/desk.png | Bin 0 -> 560 bytes public/office-sprites/kenney/floorFull.png | Bin 0 -> 795 bytes public/office-sprites/kenney/plantSmall1.png | Bin 0 -> 257 bytes public/office-sprites/kenney/plantSmall2.png | Bin 0 -> 248 bytes public/office-sprites/kenney/rugRectangle.png | Bin 0 -> 418 bytes public/office-sprites/kenney/tableCross.png | Bin 0 -> 512 bytes public/office-sprites/lounge-rug.svg | 6 + public/office-sprites/plant.svg | 8 + public/office-sprites/worker-base.svg | 10 + public/office-sprites/worker-idle-a.svg | 10 + public/office-sprites/worker-idle-b.svg | 10 + public/office-sprites/worker-type-a.svg | 10 + public/office-sprites/worker-type-b.svg | 10 + public/office-sprites/worker-walk-a.svg | 10 + public/office-sprites/worker-walk-b.svg | 10 + src/app/api/cron/route.ts | 14 +- src/app/api/local/flight-deck/route.ts | 124 ++ src/app/api/local/terminal/route.ts | 47 + src/app/api/scheduler/route.ts | 9 +- src/app/api/sessions/route.ts | 66 +- .../panels/cron-management-panel.tsx | 123 +- src/components/panels/office-panel.tsx | 1894 ++++++++++++++++- src/components/panels/super-admin-panel.tsx | 217 +- src/components/panels/webhook-panel.tsx | 94 + src/lib/codex-sessions.ts | 219 ++ src/lib/office-layout.ts | 132 ++ 34 files changed, 2914 insertions(+), 153 deletions(-) create mode 100644 public/office-sprites/cc0-hero/ATTRIBUTION-CC0-HERO.txt create mode 100644 public/office-sprites/cc0-hero/player.png create mode 100644 public/office-sprites/cc0-hero/player_full_animation.png create mode 100644 public/office-sprites/desk.svg create mode 100644 public/office-sprites/floor-tile.svg create mode 100644 public/office-sprites/kenney/ATTRIBUTION-KENNEY.txt create mode 100644 public/office-sprites/kenney/chairDesk.png create mode 100644 public/office-sprites/kenney/computerScreen.png create mode 100644 public/office-sprites/kenney/desk.png create mode 100644 public/office-sprites/kenney/floorFull.png create mode 100644 public/office-sprites/kenney/plantSmall1.png create mode 100644 public/office-sprites/kenney/plantSmall2.png create mode 100644 public/office-sprites/kenney/rugRectangle.png create mode 100644 public/office-sprites/kenney/tableCross.png create mode 100644 public/office-sprites/lounge-rug.svg create mode 100644 public/office-sprites/plant.svg create mode 100644 public/office-sprites/worker-base.svg create mode 100644 public/office-sprites/worker-idle-a.svg create mode 100644 public/office-sprites/worker-idle-b.svg create mode 100644 public/office-sprites/worker-type-a.svg create mode 100644 public/office-sprites/worker-type-b.svg create mode 100644 public/office-sprites/worker-walk-a.svg create mode 100644 public/office-sprites/worker-walk-b.svg create mode 100644 src/app/api/local/flight-deck/route.ts create mode 100644 src/app/api/local/terminal/route.ts create mode 100644 src/lib/codex-sessions.ts create mode 100644 src/lib/office-layout.ts 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 0000000000000000000000000000000000000000..63fe14cf2126553a81e062e66be6f27c51047424 GIT binary patch literal 13967 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 D!>93t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9fa2a092f31279d2cdfb8f758839dac2bc8d0f84 GIT binary patch literal 50616 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

TW)a*^poC_FHP{wN?ZCiQ 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2ec1ae2c985c1bde8eba902c3521c99cd51426c0 GIT binary patch literal 997 zcmVPk z)ow`Ga7o#4N!f2n;C4#fb4k~6OWAQr*KkPGZAklzPx^~c)ow@CZb{yCO4@Nq)NxGN za!KEHO5Aiw-gZjebV<6tw$^h@w9d`6&(76yOx1Bq)NV)9ZAjg8O44&r({xX}!@Io0 zyVZ42x6#nlZb;Z~N!V{l-*-yXZAaH|N!M>l*ltPIa!aeRr@6ki`if7^=Ht)N$hyC` zy42FZ#=XGAyVr9}yT7-gzq-5F*4A=NxxKZs&CK0%OZkdV;&n;ncS+4`MzYP!w!5># zW=F_qMVwJSp;J8ehflA{%G-8Kol!oUqo90;h>fkXpi?~ahfzgSU1*Gy*>Flvbb)Yr zda7AJtXV!WQ)A_LN$Ge;<#tQebx-??Q0sa~=XXh}w5ry3QRI0_)NV;nSzz{yQ^L%{ zp})J=cTv^a(7xN)ZFzgxcTO-;W7Bj_xY5wmbx*j#xoC`)!pg$Zb4`!0v`%w@x6#qF z&(3;@ikhaRy~Mq^)6uxm(Z1N$*6irK!@S{OB^3Yw03mczPE!Dne~)lDI1msJ5SM>A zh^LP*r+^SR5I8tEI5;>sI5;?-D2x#R00DtXL_t&-(_>&@;N)aS0$dy%3?RUpU684T z07$uivEC zHDR^{LwQlYg{8Tj=G9U>PpIBVV7xh#Tn8Q>=jjXbrHfHF0LNZ42i)eiYlHCDiC266%_|(w^tK?Co13#Sj;14HY)k20Bq$N4c%LUxpzzC=e*DtgNK2rlzKoolsio5trf-pB9!=c<|!cJ!el@hbK72q?q_eN;A!#4s@PaNswPKgFpmFL`1~= zg82dg?Hm>f?GgJE1PY8+E8hXCNcVJc4B@z*oWQ^=7|5p~kk-KLCy*d+6c93nk+Igc zjN?IK+Jx!Tr#mnQDJ(d2bb+=WFVmwJ4pS#Oy7O2Eq@@U?Bqt}Qie8w+!pO?Xn_H)* v7A7VpmNz5$$Carmsi{w&Jh@_0nV7`T7QwUq+ic|qpp^`su6{1-oD!ML&yn=elj_Tm?9Gz%&ywiLj_%Kt^VFN~ z%Z~5QmF&)w?aq|*&yn-clIqQp>dcY!(39}cll9S)^U;&?&X4iVkS9%qpa1{>5p+^c zQz$SnFaR(xC@3f}FyAouFeu|&KH>lX0R>4!K~y-)m5$j~gD@0DqqR8IAthCTB8u9< z5v7Ck(EtCaCO3rKK*GZPy!Tm$>?pQ1q{^=1c#9lPf%*I#b6RjCxmNrgk(VpM3FZt4 zf&iL6X4QqjtsC|*jw2CRZJX}nf<~^)J#cf)+X}dEX~bHl5nm8A}KE8zN}9ZrB_V zG^}eKY#H4CL&rf?M_&e-XN~C~NfxtNmSq!Qd7*i|EXniy+gk(Kk85`uP)z6m0000dBMr z%8l;Lj`7cu?#zzq$c*dBjPK5m?aPhu&yniMjPlTu@6VCz%8csBjPK2l^3jv-&5!KL zjOoaW?8}Yu&yn)blj+Bc@6M3#&XMWJkMYow^U;&@(v$PjlBx@i$dB#Ij_S#e z?aYqu%#rQOknqox@6M9)(U$VimFvlm@z0g-&y?=Xk?qWl?#zws%8={IkL=5k?8=Yx z(w6knl=IS;@6D3&&y?}dmG91z^3ay^(U$bmm+sAy95)={00001bW%=JzZ*DqdjJ3e z2}wjjR9M5E(ra6sFc1acvC@?labc@e1y|i->-|zMZPnKQ|F29SStem-2tD85b0$2% z5zaBn0hJS*Zzcd=yL{>LnaZ}w<|>oPC4_QHPf)=~J+WyEbiJvRk|!hyd7J5U`U50RjCb49Cc2;&ZuiGs-4xu>yg%+- zjtas!t-S(?(|UJz{SOR3-Ev>A>s$fx;e++6U?g8@Q((FObVg%dmM<`hdpb#y3!u6A zWdxnH(FOgtn|1c#RyPGf9FLcXCxt5TdALrBqId&po#iervMf7?U{2_!KsX-jlG08= zZ=D~{P*w$dp*(X^{e^*bVLYp<>hKA~^^(<<)+(ytKe^V6#o`x)aa!8~igO>Xa`^M} ZzYl7arh}UwVqyRQ002ovPDHLkV1gAgua5u# literal 0 HcmV?d00001 diff --git a/public/office-sprites/kenney/plantSmall1.png b/public/office-sprites/kenney/plantSmall1.png new file mode 100644 index 0000000000000000000000000000000000000000..8a2e73692deb63ebcfc717babe393e3c1eae6ae1 GIT binary patch literal 257 zcmV+c0sj7pP) z(3dKYu!o2Iqmg~xq=*F7S-K2Z< zz{=dAU&f@$-JzTG#!a@5pY_V=(UExTPh;1UVTPn7O1R8Ce+sI}ZY5m9os400000NkvXX Hu0mjftFU?m literal 0 HcmV?d00001 diff --git a/public/office-sprites/kenney/plantSmall2.png b/public/office-sprites/kenney/plantSmall2.png new file mode 100644 index 0000000000000000000000000000000000000000..4496c754947b743326f9a76b83f1ee0f67642905 GIT binary patch literal 248 zcmVdKYx%#!TFyy(W7@yeI-&ymF8 zw(if8NZ$+gazA9 ynK&2(rKQ9~*cceNWrRcpSs56(I0eNSSr`B|Ljp5UNEyrk0000N6u_U&TB^0Z%1}4zXAXN010$bPE*hS|NsC0 z|3EM}`Z^l`008buL_t(o!{yis4uUWcg<(KDIwJ1-a{p_Z7>N-GFrChW#{U9eDD4oM zB!K1#hsm`yi%a2lg+5s_U&WFs727o{mE(0{(E0GIWa-qP^=c?(5x8dMwtu(uYFF#o ztmLHG)wPXPbSfT>mm+uk6tUx}5JnL?kQD33At`CdQz=08h}M{D`Od2LJ#7 M07*qoM6N<$g5LGL#{d8T literal 0 HcmV?d00001 diff --git a/public/office-sprites/kenney/tableCross.png b/public/office-sprites/kenney/tableCross.png new file mode 100644 index 0000000000000000000000000000000000000000..31ca9b232006e614f610c040058540cbafcf9a40 GIT binary patch literal 512 zcmV+b0{{JqP)5Rk@C=! z@6M3#&XMuZlJn7%^U{;-%Z=^Kjq}o!?#qqt%#G~IjjI^?#Q*>R7j#liQ*c;tSXgja zaBy%~Sa5Jya9D6y7+6?X_bE}#0003CNklsG=r5Ju6VARwa1P1;l~w)6(# z`#+v((lnW5(w6gouW#1CdL0D8Qnz_s=(f+YrzSAk!(Uz3Ke|uUm+Z{@Zi-bOV5%#n z&Pv5bN6_lthmeXd2cR$X-6vHZy}+71O zT7?`^)KZ5`H>en@bv{L+G!4T!Jg6EJ3a6Cy4D|~<6}HX6Q0^2XrnHk6qG$N-6BWg2 z3a)auEX13FeIlW3BpjtY2P(vSgI-0WQ0000 + + + + + 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 e25ccd4..7640199 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 ec2bac1..83d8d4c 100644 --- a/src/components/panels/cron-management-panel.tsx +++ b/src/components/panels/cron-management-panel.tsx @@ -48,7 +48,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) @@ -86,15 +87,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) { console.error('Failed to load cron jobs:', error) } finally { setIsLoading(false) } - }, [setCronJobs]) + }, [isLocalMode, setCronJobs]) useEffect(() => { loadCronJobs() @@ -118,9 +144,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) { @@ -154,7 +215,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' }, @@ -249,7 +327,7 @@ export function CronManagementPanel() { const handleJobSelect = (job: CronJob) => { setSelectedJob(job) - loadJobLogs(job.name) + loadJobLogs(job) } const getStatusColor = (status?: string) => { @@ -392,7 +470,11 @@ export function CronManagementPanel() {

Calendar View

-

Read-only schedule visibility across all cron jobs

+

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

@@ -658,6 +745,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 @@ -665,7 +753,7 @@ export function CronManagementPanel() {
- ))} + )})} )} @@ -687,6 +775,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 521301b..72d41dc 100644 --- a/src/components/panels/office-panel.tsx +++ b/src/components/panels/office-panel.tsx @@ -1,14 +1,119 @@ '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' -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 +} + +interface PersistedOfficePrefs { + version: 1 + viewMode: ViewMode + sidebarFilter: SidebarFilter + mapZoom: number + mapPan: { x: number; y: number } + timeTheme: TimeTheme + showSidebar: boolean + showMinimap: boolean + showEvents: boolean + roomLayout: MapRoom[] + mapProps: MapProp[] } const statusGlow: Record = { @@ -60,6 +165,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 @@ -71,32 +184,447 @@ 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' +} + +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 [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 [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 counts = useMemo(() => { const c = { idle: 0, busy: 0, error: 0, offline: 0 } @@ -104,15 +632,6 @@ export function OfficePanel() { return c }, [displayAgents]) - const desks: Desk[] = useMemo(() => { - const cols = Math.max(2, Math.ceil(Math.sqrt(displayAgents.length))) - return displayAgents.map((agent, i) => ({ - agent, - row: Math.floor(i / cols), - col: i % cols, - })) - }, [displayAgents]) - const roleGroups = useMemo(() => { const groups = new Map() for (const a of displayAgents) { @@ -123,11 +642,738 @@ export function OfficePanel() { return groups }, [displayAgents]) - if (loading && displayAgents.length === 0) { + const officeLayout = useMemo(() => buildOfficeLayout(displayAgents), [displayAgents]) + + 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') + 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, + 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, + 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(214,141,89,0.55) 0, rgba(48,66,109,0.9) 45%, rgba(17,24,41,1) 100%)', + gridLine: 'rgba(255,198,151,0.13)', + haze: 'radial-gradient(circle at 50% 30%, rgba(255,188,137,0.2), transparent 62%)', + glow: 'linear-gradient(to bottom, rgba(255,255,255,0.06), transparent 34%, rgba(0,0,0,0.16))', + corridor: '#3f3f54', + corridorStripe: '#ffca95', + } + } + if (timeTheme === 'day') { + return { + shell: 'radial-gradient(circle at 20% 10%, rgba(121,167,255,0.55) 0, rgba(33,62,112,0.9) 45%, rgba(13,22,40,1) 100%)', + gridLine: 'rgba(171,208,255,0.14)', + haze: 'radial-gradient(circle at 50% 30%, rgba(168,218,255,0.16), transparent 60%)', + glow: 'linear-gradient(to bottom, rgba(255,255,255,0.08), transparent 30%, rgba(0,0,0,0.12))', + corridor: '#3a4258', + corridorStripe: '#b8d5ff', + } + } + if (timeTheme === 'dusk') { + return { + shell: 'radial-gradient(circle at 20% 10%, rgba(180,112,164,0.45) 0, rgba(35,43,84,0.92) 45%, rgba(10,14,28,1) 100%)', + gridLine: 'rgba(198,156,255,0.13)', + haze: 'radial-gradient(circle at 50% 30%, rgba(221,164,255,0.18), transparent 62%)', + glow: 'linear-gradient(to bottom, rgba(255,255,255,0.05), transparent 30%, rgba(0,0,0,0.2))', + corridor: '#413b58', + corridorStripe: '#d7b0ff', + } + } + return { + shell: 'radial-gradient(circle at 20% 10%, rgba(51,86,153,0.7) 0, rgba(13,20,36,0.95) 40%, rgba(9,13,24,1) 100%)', + gridLine: 'rgba(99,121,166,0.14)', + haze: 'radial-gradient(circle at 50% 30%, rgba(75,132,255,0.16), transparent 60%)', + glow: 'linear-gradient(to bottom, rgba(255,255,255,0.03), transparent 30%, rgba(0,0,0,0.18))', + corridor: '#303746', + corridorStripe: '#9cc2ff', + } + }, [timeTheme]) + + 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]) + + if ((loading || (isLocalMode && localBootstrapping)) && displayAgents.length === 0) { return (
- Loading office... + + {isLocalMode ? 'Scanning local sessions...' : 'Loading office...'} +
) } @@ -175,64 +1421,476 @@ export function OfficePanel() {

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
+
{displayAgents.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) => ( + + ))} +
+
+ {filteredRosterRows.map(({ agent, minutesIdle, needsAttention }) => ( + + ))} + {filteredRosterRows.length === 0 && ( +
No workers in this filter.
+ )} +
+
+ )} + +
+
+
+ +
+ 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' && ( +
+ + + + +
+ )} +
+ )} +
+ )}
) : ( @@ -326,6 +1984,108 @@ 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 0851a71..a5c20ae 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.'}

- {openActionMenu === menuKey && ( -
+ {isLocal && tenant.id < 0 ? ( + Local read-only + ) : ( + <> -
+ {openActionMenu === menuKey && ( +
+ +
+ )} + )} @@ -743,42 +845,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) +} From 524dc0e88b02d10a72bf8e9067ddf5d5dd707a13 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 15:41:52 +0700 Subject: [PATCH 2/5] fix(office): hide inactive local sessions by default --- src/components/panels/office-panel.tsx | 50 +++++++++++++++++++------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/components/panels/office-panel.tsx b/src/components/panels/office-panel.tsx index 6145046..609482c 100644 --- a/src/components/panels/office-panel.tsx +++ b/src/components/panels/office-panel.tsx @@ -107,6 +107,7 @@ interface PersistedOfficePrefs { version: 1 viewMode: ViewMode sidebarFilter: SidebarFilter + showInactiveSessions: boolean mapZoom: number mapPan: { x: number; y: number } timeTheme: TimeTheme @@ -217,6 +218,10 @@ function inferLocalRole(row: SessionAgentRow): string { 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 @@ -471,6 +476,7 @@ export function OfficePanel() { const [showSidebar, setShowSidebar] = useState(true) const [showMinimap, setShowMinimap] = useState(true) const [showEvents, setShowEvents] = useState(true) + const [showInactiveSessions, setShowInactiveSessions] = useState(false) const [loading, setLoading] = useState(true) const [localBootstrapping, setLocalBootstrapping] = useState(isLocalMode) const [sidebarFilter, setSidebarFilter] = useState('all') @@ -628,23 +634,28 @@ export function OfficePanel() { return [] }, [agents, isLocalMode, localAgents, sessionAgents]) + const visibleDisplayAgents = useMemo(() => { + if (!isLocalMode || showInactiveSessions) return displayAgents + return displayAgents.filter((agent) => !isInactiveLocalSession(agent)) + }, [displayAgents, isLocalMode, showInactiveSessions]) + 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]) + }, [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 - }, [displayAgents]) + }, [visibleDisplayAgents]) - const officeLayout = useMemo(() => buildOfficeLayout(displayAgents), [displayAgents]) + const officeLayout = useMemo(() => buildOfficeLayout(visibleDisplayAgents), [visibleDisplayAgents]) const currentSeatMap = useMemo(() => { const seatMap = new Map() @@ -790,6 +801,7 @@ export function OfficePanel() { if (!prefs || prefs.version !== 1) return setViewMode(prefs.viewMode || 'office') setSidebarFilter(prefs.sidebarFilter || 'all') + setShowInactiveSessions(Boolean(prefs.showInactiveSessions)) setMapZoom(Number.isFinite(prefs.mapZoom) ? clamp(prefs.mapZoom, 0.8, 2.2) : 1) setMapPan({ x: Number.isFinite(prefs.mapPan?.x) ? prefs.mapPan.x : 0, @@ -816,6 +828,7 @@ export function OfficePanel() { version: 1, viewMode, sidebarFilter, + showInactiveSessions, mapZoom, mapPan, timeTheme, @@ -837,6 +850,7 @@ export function OfficePanel() { mapZoom, roomLayoutState, showEvents, + showInactiveSessions, showMinimap, showSidebar, sidebarFilter, @@ -1381,7 +1395,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) @@ -1398,11 +1412,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) @@ -1419,7 +1433,7 @@ export function OfficePanel() { return a.localeCompare(b) }) ) - }, [displayAgents]) + }, [visibleDisplayAgents]) const orgGroups = useMemo(() => { if (orgSegmentMode === 'role') return roleGroups @@ -1427,7 +1441,7 @@ export function OfficePanel() { return categoryGroups }, [categoryGroups, orgSegmentMode, roleGroups, statusGroups]) - if ((loading || (isLocalMode && localBootstrapping)) && displayAgents.length === 0) { + if ((loading || (isLocalMode && localBootstrapping)) && visibleDisplayAgents.length === 0) { return (
@@ -1474,7 +1488,7 @@ export function OfficePanel() {
- {displayAgents.length === 0 ? ( + {visibleDisplayAgents.length === 0 ? (
๐Ÿข

The office is empty

@@ -1486,7 +1500,7 @@ export function OfficePanel() {
TEAMY
-
{displayAgents.length} online
+
{visibleDisplayAgents.length} online
{([ @@ -1508,6 +1522,18 @@ export function OfficePanel() { ))}
+ {isLocalMode && ( + + )}
{filteredRosterRows.map(({ agent, minutesIdle, needsAttention }) => (
{isLocalMode && ( - +
+ + +
)}
{filteredRosterRows.map(({ agent, minutesIdle, needsAttention }) => ( From 1400f36237938d0cd1c473974ac39ac3fee8573e Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 15:52:58 +0700 Subject: [PATCH 4/5] feat(office): strengthen day-cycle visual themes --- src/components/panels/office-panel.tsx | 82 +++++++++++++++++++------- 1 file changed, 61 insertions(+), 21 deletions(-) diff --git a/src/components/panels/office-panel.tsx b/src/components/panels/office-panel.tsx index 0bbdbbd..8fc3bdf 100644 --- a/src/components/panels/office-panel.tsx +++ b/src/components/panels/office-panel.tsx @@ -101,6 +101,13 @@ interface ThemePalette { glow: string corridor: string corridorStripe: string + atmosphere: string + shadowVeil: string + floorFilter: string + spriteFilter: string + roomTone: string + floorOpacityA: number + floorOpacityB: number } interface PersistedOfficePrefs { @@ -879,41 +886,69 @@ export function OfficePanel() { const themePalette = useMemo(() => { if (timeTheme === 'dawn') { return { - shell: 'radial-gradient(circle at 20% 10%, rgba(214,141,89,0.55) 0, rgba(48,66,109,0.9) 45%, rgba(17,24,41,1) 100%)', - gridLine: 'rgba(255,198,151,0.13)', - haze: 'radial-gradient(circle at 50% 30%, rgba(255,188,137,0.2), transparent 62%)', - glow: 'linear-gradient(to bottom, rgba(255,255,255,0.06), transparent 34%, rgba(0,0,0,0.16))', + 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, } } if (timeTheme === 'day') { return { - shell: 'radial-gradient(circle at 20% 10%, rgba(121,167,255,0.55) 0, rgba(33,62,112,0.9) 45%, rgba(13,22,40,1) 100%)', - gridLine: 'rgba(171,208,255,0.14)', - haze: 'radial-gradient(circle at 50% 30%, rgba(168,218,255,0.16), transparent 60%)', - glow: 'linear-gradient(to bottom, rgba(255,255,255,0.08), transparent 30%, rgba(0,0,0,0.12))', + 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, } } if (timeTheme === 'dusk') { return { - shell: 'radial-gradient(circle at 20% 10%, rgba(180,112,164,0.45) 0, rgba(35,43,84,0.92) 45%, rgba(10,14,28,1) 100%)', - gridLine: 'rgba(198,156,255,0.13)', - haze: 'radial-gradient(circle at 50% 30%, rgba(221,164,255,0.18), transparent 62%)', - glow: 'linear-gradient(to bottom, rgba(255,255,255,0.05), transparent 30%, rgba(0,0,0,0.2))', + 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, } } return { - shell: 'radial-gradient(circle at 20% 10%, rgba(51,86,153,0.7) 0, rgba(13,20,36,0.95) 40%, rgba(9,13,24,1) 100%)', - gridLine: 'rgba(99,121,166,0.14)', - haze: 'radial-gradient(circle at 50% 30%, rgba(75,132,255,0.16), transparent 60%)', - glow: 'linear-gradient(to bottom, rgba(255,255,255,0.03), transparent 30%, rgba(0,0,0,0.18))', + 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, } }, [timeTheme]) @@ -1607,6 +1642,8 @@ export function OfficePanel() { >
+
+
MAIN FLOOR @@ -1651,7 +1688,8 @@ export function OfficePanel() { height: `${tile.h}%`, backgroundImage: `url('/office-sprites/kenney/floorFull.png')`, backgroundSize: '100% 100%', - opacity: tile.sprite ? 0.9 : 0.76, + opacity: tile.sprite ? themePalette.floorOpacityA : themePalette.floorOpacityB, + filter: themePalette.floorFilter, }} /> ))} @@ -1689,6 +1727,7 @@ export function OfficePanel() { height: `${room.h}%`, backgroundImage: `linear-gradient(to bottom right, rgba(255,255,255,0.04), rgba(0,0,0,0.1)), url('/office-sprites/kenney/floorFull.png')`, backgroundSize: 'auto, 22% 22%', + filter: themePalette.floorFilter, }} onClick={(event) => { event.stopPropagation() @@ -1712,7 +1751,7 @@ export function OfficePanel() { }) }} > -
+
{room.label}
@@ -1756,7 +1795,7 @@ export function OfficePanel() { fill unoptimized className="object-contain opacity-95" - style={{ imageRendering: 'pixelated' }} + style={{ imageRendering: 'pixelated', filter: themePalette.spriteFilter }} draggable={false} />
@@ -1808,7 +1847,7 @@ export function OfficePanel() { height={32} unoptimized className="w-16 h-9 object-contain opacity-95" - style={{ imageRendering: 'pixelated' }} + style={{ imageRendering: 'pixelated', filter: themePalette.spriteFilter }} draggable={false} />
@@ -1851,6 +1890,7 @@ export function OfficePanel() { return `${xPct}% ${yPct}%` })(), imageRendering: 'pixelated', + filter: themePalette.spriteFilter, transform: isMoving && Math.abs(direction.dx) > Math.abs(direction.dy) && direction.dx < 0 ? 'scaleX(-1)' : undefined, transformOrigin: 'center', }} From f68acc65c07a269f5175478800842e8f723de2ea Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 15:55:41 +0700 Subject: [PATCH 5/5] feat(office): animate day-cycle ambience --- src/components/panels/office-panel.tsx | 113 +++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/components/panels/office-panel.tsx b/src/components/panels/office-panel.tsx index 8fc3bdf..a70cb24 100644 --- a/src/components/panels/office-panel.tsx +++ b/src/components/panels/office-panel.tsx @@ -108,6 +108,7 @@ interface ThemePalette { roomTone: string floorOpacityA: number floorOpacityB: number + accentGlow: string } interface PersistedOfficePrefs { @@ -899,6 +900,7 @@ export function OfficePanel() { 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') { @@ -916,6 +918,7 @@ export function OfficePanel() { 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') { @@ -933,6 +936,7 @@ export function OfficePanel() { 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 { @@ -949,9 +953,25 @@ export function OfficePanel() { 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) @@ -1644,6 +1664,72 @@ export function OfficePanel() {
+ {timeTheme === 'dawn' && ( +
+ )} + {timeTheme === 'day' && ( + <> +
+
+ + )} + {timeTheme === 'dusk' && ( +
+ )} + {timeTheme === 'night' && ( + <> +
+ {nightSparkles.map((spark) => ( +
+ ))} + + )}
MAIN FLOOR @@ -2262,6 +2348,33 @@ export function OfficePanel() {
)} +
) }