建立 Codex Office 虛擬辦公室(模擬 + 實況雙模式)

摘要:
以 claude-office 為基底改造的 Codex CLI 版虛擬辦公室:終端機綠 + 冷灰主題、
>_ 游標標誌,實況模式改為監看 ~/.codex/sessions/ 的 rollout transcripts,
依 cwd 將 session 分組成專案顯示。

根本原因:
既有 claude-office 只能監看 Claude Code 活動,Codex CLI 的 session 格式
(rollout JSONL:session_meta / turn_context / event_msg / response_item)
與存放結構(YYYY/MM/DD 日期目錄)完全不同,需要獨立的轉譯器。

影響:
新專案,與 claude-office 並存:web 5182(0.0.0.0)、watch 5181(僅 127.0.0.1),
與 5179/5180 不衝突,可同時常駐 PM2。

修法:
- server/watch.mjs 重寫:遞迴掃描日期目錄、tail 新增位元組、
  由 session_meta/turn_context 的 cwd 分組專案,
  custom_tool_call/function_call/MCP → 工具事件,agent_message/task_complete → 回覆
- 主題改造:constants 配色(石墨綠)、Sora/Manrope/JetBrains Mono 字型、
  >_ 閃爍游標標誌、agents 改為 Codex/Sol/Nova/Mini、工具改為 exec/apply_patch 等
- live.ts 新增 thinking 事件(對應 reasoning),port 5181

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 14:02:32 +08:00
co-authored by Claude Fable 5
commit 7d9d477166
31 changed files with 5946 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.local
.DS_Store
+71
View File
@@ -0,0 +1,71 @@
# >_ Codex Office
Codex CLI 風格的 multi-agent 虛擬辦公室視覺化。與姊妹作 `claude-office` 同架構
(參考 [WW-AI-Lab/openclaw-office](https://github.com/WW-AI-Lab/openclaw-office),MIT),
改為 OpenAI Codex 的語意:終端機綠 + 冷灰科技感、`>_` 游標標誌、
工具氣泡顯示 exec / apply_patch / shell 等 Codex 工具。
## 功能
- **2D 平面圖辦公室** — 主力工位區、Worker 熱桌區、會議區、休息區,十字走廊尋路
- **Chibi 小人** — 依 id 確定性生成外觀;狀態驅動姿勢(站/坐/走)與動作(打字/說話/搖晃)
- **狀態表情氣泡** — 思考雲、工具膠囊(轉動齒輪 + 工具名)、對話氣泡、錯誤、生成火花
- **模擬導演** — 進場 → 接任務 → 思考 → 調用工具 → 派 worker → 回報 → 開會 → 倒咖啡
- **實況模式** — tail `~/.codex/sessions/` 的 rollout transcripts,依 cwd 分專案顯示真實活動
- **RWD** — 直版滿寬顯示辦公室,面板移到下方;側欄可收合
## 快速開始
```bash
npm install
npm run dev # http://localhost:5173(劇本模擬模式)
```
## 實況模式(LIVE)— 顯示真實 Codex CLI 活動
```bash
npm run watch # http://localhost:5181/events (SSE)
```
`server/watch.mjs` 輪詢 `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`,
只讀新增位元組,依 `session_meta` / `turn_context` 的 cwd 把 session 分組成專案:
| Rollout 內容 | 辦公室畫面 |
| --- | --- |
| 近 7 天有活動的專案(cwd) | 一位 agent 入座主力工位(最多 6 席) |
| `user_message` | 收到新任務(思考雲) |
| `reasoning` / `task_started` | 思考中 |
| `custom_tool_call` / `function_call` / MCP 工具 | 工具調用(齒輪膠囊 + 工具名) |
| `agent_message` / `task_complete` | 回覆中(對話氣泡) |
| 15 秒沒動靜 / 5 分鐘沒動靜 | 待命 / 離線(Zzz) |
注意:監看伺服器會讀取本機的 Codex 對話記錄,**只綁 127.0.0.1、不要對外開放**。
## PM2 部署
```bash
npm run build
pm2 start ecosystem.config.cjs
pm2 save
```
| App | Port | 綁定 | 說明 |
| --- | --- | --- | --- |
| `codex-office-web` | 5182 | 0.0.0.0 | 靜態服務 dist/(區網可看,模擬模式) |
| `codex-office-watch` | 5181 | 127.0.0.1 | rollout 監看,僅本機(實況模式限本機瀏覽器) |
與 claude-office 的 5179/5180 不衝突,可同時常駐。
## 架構
```
src/
├── lib/ # 幾何常數、外觀生成、走廊尋路、座位分配、台詞庫
├── store/ # Zustand:agents/links/meetings/events
├── sim/ # director.ts 模擬 FSM + runtime.ts rAF 迴圈
├── gateway/ # live.ts:SSE 事件 → store action
└── components/ # FloorPlan / Pawn / Emotes / 家具 / Header / SidePanel
server/
├── watch.mjs # Codex rollout 監看(零依賴,SSE)
└── serve.mjs # 生產用靜態伺服器(零依賴,SPA fallback)
```
+20
View File
@@ -0,0 +1,20 @@
module.exports = {
apps: [
{
name: "codex-office-web",
script: "server/serve.mjs",
cwd: __dirname,
env: { PORT: 5182 },
max_restarts: 5,
restart_delay: 3000,
},
{
name: "codex-office-watch",
script: "server/watch.mjs",
cwd: __dirname,
env: { PORT: 5181 },
max_restarts: 5,
restart_delay: 3000,
},
],
};
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codex Office — 虛擬辦公室</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Manrope:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%230f1512'/%3E%3Ctext x='14' y='68' font-size='44' font-family='monospace' font-weight='700' fill='%2310a37f'%3E%3E_%3C/text%3E%3C/svg%3E" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1859
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "codex-office",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"watch": "node server/watch.mjs"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zustand": "^5.0.5"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"typescript": "~5.8.3",
"vite": "^6.3.5"
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Minimal static server for the production build (dist/).
* Zero dependencies. SPA fallback to index.html.
* Binds 0.0.0.0 so the office is viewable from the LAN;
* the transcript watcher stays localhost-only.
*/
import { createServer } from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const PORT = Number(process.env.PORT || 5182);
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist");
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".json": "application/json",
".woff2": "font/woff2",
".map": "application/json",
};
const server = createServer(async (req, res) => {
try {
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
let filePath = path.normalize(path.join(ROOT, urlPath));
if (!filePath.startsWith(ROOT)) {
res.writeHead(403);
res.end("forbidden");
return;
}
const stat = await fs.stat(filePath).catch(() => null);
if (!stat || stat.isDirectory()) {
filePath = path.join(ROOT, "index.html");
}
const ext = path.extname(filePath);
const data = await fs.readFile(filePath);
res.writeHead(200, {
"Content-Type": MIME[ext] || "application/octet-stream",
"Cache-Control": urlPath.startsWith("/assets/")
? "public, max-age=31536000, immutable"
: "no-cache",
});
res.end(data);
} catch {
res.writeHead(500);
res.end("server error");
}
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`✳ Codex Office web http://localhost:${PORT} (serving dist/)`);
});
+245
View File
@@ -0,0 +1,245 @@
/**
* Codex Office live watcher.
*
* Tails the rollout-*.jsonl session transcripts under ~/.codex/sessions/
* (organised as YYYY/MM/DD/) and translates appended lines into office
* events, streamed over Server-Sent Events at http://localhost:5181/events.
*
* Sessions are grouped into office agents by their working directory (cwd),
* so each project appears as one pawn.
*
* Zero dependencies — plain Node 18+.
*/
import { createServer } from "node:http";
import { createReadStream, promises as fs } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
const PORT = Number(process.env.PORT || 5181);
const POLL_MS = 1500;
const ACTIVE_WINDOW_DAYS = 7;
const SESSIONS_ROOT = path.join(homedir(), ".codex", "sessions");
/** slug → { slug, name, cwd, lastActivity } */
const projects = new Map();
/** absolute file path → { offset, remainder, cwd, slug } */
const tails = new Map();
const clients = new Set();
function broadcast(event) {
const data = `data: ${JSON.stringify(event)}\n\n`;
for (const res of clients) res.write(data);
}
function slugOf(cwd) {
return cwd.replace(/[^a-zA-Z0-9]+/g, "-");
}
function nameOf(cwd) {
const seg = cwd.split(/[\\/]/).filter(Boolean);
return seg[seg.length - 1] || cwd;
}
function registerProject(cwd, lastActivity) {
const slug = slugOf(cwd);
let proj = projects.get(slug);
if (!proj) {
proj = { slug, name: nameOf(cwd), cwd, lastActivity };
projects.set(slug, proj);
broadcast({ type: "project", slug, name: proj.name, lastActivity });
} else if (lastActivity > proj.lastActivity) {
proj.lastActivity = lastActivity;
}
return proj;
}
function readRange(file, start, end) {
return new Promise((resolve, reject) => {
const chunks = [];
createReadStream(file, { start, end: Math.max(start, end - 1) })
.on("data", (c) => chunks.push(c))
.on("end", () => resolve(Buffer.concat(chunks)))
.on("error", reject);
});
}
/** Rollouts put session_meta (with cwd) in the first lines — sniff the head. */
async function sniffCwd(file, size) {
try {
const buf = await readRange(file, 0, Math.min(size, 8192));
const m = buf.toString("utf8").match(/"cwd":"((?:[^"\\]|\\.)*)"/);
if (m) return JSON.parse(`"${m[1]}"`);
} catch {
/* ignore */
}
return null;
}
function handleLine(tail, line) {
let o;
try {
o = JSON.parse(line);
} catch {
return;
}
const p = o.payload ?? {};
// cwd can appear in session_meta and change per turn_context
if ((o.type === "session_meta" || o.type === "turn_context") && typeof p.cwd === "string") {
tail.cwd = p.cwd;
tail.slug = slugOf(p.cwd);
registerProject(p.cwd, Date.now());
return;
}
if (!tail.slug) return;
const slug = tail.slug;
const proj = projects.get(slug);
if (proj) proj.lastActivity = Date.now();
if (o.type === "event_msg") {
switch (p.type) {
case "user_message":
broadcast({ type: "prompt", slug });
break;
case "task_started":
broadcast({ type: "thinking", slug });
break;
case "agent_message":
if (p.message) broadcast({ type: "speech", slug, text: String(p.message).slice(0, 80) });
break;
case "task_complete":
if (p.last_agent_message)
broadcast({ type: "speech", slug, text: String(p.last_agent_message).slice(0, 80) });
break;
case "mcp_tool_call_begin":
case "mcp_tool_call_end": {
const inv = p.invocation ?? {};
const name = [inv.server, inv.tool].filter(Boolean).join(":") || "mcp";
broadcast({ type: "tool", slug, tool: name });
break;
}
}
return;
}
if (o.type === "response_item") {
switch (p.type) {
case "custom_tool_call":
case "function_call":
broadcast({ type: "tool", slug, tool: p.name || "tool" });
break;
case "local_shell_call":
broadcast({ type: "tool", slug, tool: "shell" });
break;
case "reasoning":
broadcast({ type: "thinking", slug });
break;
}
}
}
/** Recursively collect .jsonl files under the YYYY/MM/DD tree (depth ≤ 3). */
async function collectFiles(dir, depth) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const files = [];
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory() && depth < 3) {
files.push(...(await collectFiles(full, depth + 1)));
} else if (e.isFile() && e.name.endsWith(".jsonl")) {
files.push(full);
}
}
return files;
}
async function pollOnce(initial) {
const files = await collectFiles(SESSIONS_ROOT, 0);
const cutoff = Date.now() - ACTIVE_WINDOW_DAYS * 86400000;
for (const full of files) {
let stat;
try {
stat = await fs.stat(full);
} catch {
continue;
}
let tail = tails.get(full);
if (!tail) {
// First sighting: skip history, only follow new appends.
tail = { offset: stat.size, remainder: "", cwd: null, slug: null };
tails.set(full, tail);
const cwd = await sniffCwd(full, stat.size);
if (cwd) {
tail.cwd = cwd;
tail.slug = slugOf(cwd);
if (stat.mtimeMs >= cutoff) {
const proj = registerProject(cwd, stat.mtimeMs);
if (initial) proj.lastActivity = stat.mtimeMs;
}
}
continue;
}
if (stat.size < tail.offset) {
tail.offset = stat.size;
tail.remainder = "";
}
if (stat.size > tail.offset) {
const buf = await readRange(full, tail.offset, stat.size);
tail.offset = stat.size;
const chunk = tail.remainder + buf.toString("utf8");
const lines = chunk.split("\n");
tail.remainder = lines.pop() ?? "";
for (const line of lines) {
if (line.trim()) handleLine(tail, line);
}
}
}
}
function snapshot() {
return {
type: "snapshot",
projects: [...projects.values()]
.sort((a, b) => b.lastActivity - a.lastActivity)
.map((p) => ({ slug: p.slug, name: p.name, lastActivity: p.lastActivity })),
subs: [],
};
}
const server = createServer((req, res) => {
if (req.url === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
});
res.write(`data: ${JSON.stringify(snapshot())}\n\n`);
clients.add(res);
req.on("close", () => clients.delete(res));
return;
}
res.writeHead(404, { "Access-Control-Allow-Origin": "*" });
res.end("not found");
});
await pollOnce(true);
// Transcripts are sensitive — never expose beyond this machine.
server.listen(PORT, "127.0.0.1", () => {
console.log(">_ Codex Office watcher");
console.log(` watching ${SESSIONS_ROOT}`);
console.log(` projects ${projects.size} active in last ${ACTIVE_WINDOW_DAYS} days`);
console.log(` SSE http://localhost:${PORT}/events`);
});
setInterval(() => pollOnce(false).catch(() => {}), POLL_MS);
+25
View File
@@ -0,0 +1,25 @@
import { useEffect } from "react";
import { FloorPlan } from "./components/FloorPlan";
import { HeaderBar } from "./components/HeaderBar";
import { SidePanel } from "./components/SidePanel";
import { startRuntime } from "./sim/runtime";
import { useOfficeStore } from "./store/office-store";
export default function App() {
const theme = useOfficeStore((s) => s.theme);
const panelOpen = useOfficeStore((s) => s.panelOpen);
useEffect(() => {
startRuntime();
}, []);
return (
<div className={`app ${theme}`}>
<HeaderBar />
<div className={`main-row ${panelOpen ? "" : "panel-closed"}`}>
<FloorPlan />
{panelOpen && <SidePanel />}
</div>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { memo, useState } from "react";
import type { VisualAgent } from "@/lib/types";
import { generateAppearance } from "@/lib/appearance";
import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants";
import { interpolatePath } from "@/lib/movement";
import { useOfficeStore } from "@/store/office-store";
import { Pawn, type PawnMotion, type PawnPose } from "./Pawn";
import { ErrorEmote, SparkleEmote, SpeechEmote, ThoughtEmote, ToolEmote, ZzzEmote } from "./Emotes";
const WALK_BOB_AMPLITUDE = 1.2;
const WALK_BOB_FREQ = 7;
const EMOTE_Y = -34;
interface AgentAvatarProps {
agent: VisualAgent;
}
export const AgentAvatar = memo(function AgentAvatar({ agent }: AgentAvatarProps) {
const selectedAgentId = useOfficeStore((s) => s.selectedAgentId);
const selectAgent = useOfficeStore((s) => s.selectAgent);
const isDark = useOfficeStore((s) => s.theme) === "dark";
const [hovered, setHovered] = useState(false);
const isSelected = selectedAgentId === agent.id;
const isWalking = agent.movement !== null;
const statusColor = STATUS_COLORS[agent.status];
const appearance = generateAppearance(agent.id);
const pose: PawnPose = isWalking
? "walk"
: agent.zone === "desk" || agent.zone === "hotDesk" || agent.zone === "meeting"
? "sit"
: "stand";
let motion: PawnMotion = "none";
if (!isWalking) {
if (agent.status === "error") motion = "shaking";
else if (agent.status === "speaking") motion = "talking";
else if (agent.status === "tool_calling" || agent.status === "thinking") motion = "typing";
}
// Walk bob + facing derived from movement progress (declarative — no rAF here).
let bobY = 0;
let facingLeft = false;
if (agent.movement) {
bobY = Math.sin(agent.movement.elapsed * WALK_BOB_FREQ * Math.PI * 2) * WALK_BOB_AMPLITUDE;
const p = agent.movement.elapsed / agent.movement.duration;
const ahead = interpolatePath(agent.movement.path, Math.min(p + 0.02, 1));
facingLeft = ahead.x < agent.position.x - 0.01;
}
const displayName = agent.name.length > 12 ? `${agent.name.slice(0, 12)}` : agent.name;
return (
<g
data-agent-id={agent.id}
transform={`translate(${agent.position.x}, ${agent.position.y + bobY})`}
style={{ cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
selectAgent(agent.id);
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{isSelected && (
<g style={{ animation: "selection-ring 1.4s ease-in-out infinite", transformBox: "fill-box", transformOrigin: "center" }}>
<ellipse cy={17.5} rx={16} ry={5} fill={statusColor} opacity={0.22} style={{ filter: `drop-shadow(0 0 6px ${statusColor})` }} />
<ellipse cy={17.5} rx={16} ry={5} fill="none" stroke={statusColor} strokeWidth={1.6} />
</g>
)}
{hovered && !isSelected && (
<ellipse cy={17.5} rx={14.5} ry={4.5} fill="none" stroke={statusColor} strokeWidth={1} opacity={0.5} />
)}
<g style={agent.status === "spawning" && !isWalking ? spawnStyle : undefined}>
<g transform={facingLeft ? "scale(-1, 1)" : undefined}>
<Pawn appearance={appearance} pose={pose} motion={motion} />
</g>
</g>
{!isWalking && (
<g transform={`translate(0, ${EMOTE_Y})`}>
{agent.status === "thinking" && <ThoughtEmote isDark={isDark} />}
{agent.status === "speaking" && <SpeechEmote text={agent.speech ?? ""} isDark={isDark} />}
{agent.status === "tool_calling" && agent.currentTool && (
<ToolEmote toolName={agent.currentTool.name} isDark={isDark} />
)}
{agent.status === "error" && <ErrorEmote />}
{agent.status === "offline" && <ZzzEmote isDark={isDark} />}
{agent.status === "spawning" && <SparkleEmote />}
</g>
)}
{/* name tag */}
<foreignObject x={-60} y={21} width={120} height={22} style={{ pointerEvents: "none" }}>
<div style={{ display: "flex", justifyContent: "center" }}>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
fontSize: "10px",
fontWeight: 600,
fontFamily: "'Manrope', system-ui, sans-serif",
color: isDark ? "#e8e4da" : "#3d3830",
backgroundColor: isDark ? "rgba(23,19,16,0.8)" : "rgba(255,252,245,0.88)",
backdropFilter: "blur(6px)",
borderRadius: "7px",
padding: "1px 7px",
whiteSpace: "nowrap",
border: `1px solid ${isDark ? "rgba(255,255,255,0.08)" : "rgba(120,90,60,0.18)"}`,
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
}}
>
<span
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
backgroundColor: statusColor,
flexShrink: 0,
boxShadow: `0 0 3px ${statusColor}`,
}}
/>
{agent.role === "lead" && <span style={{ color: "#10a37f", fontSize: "9px" }}></span>}
{displayName}
{agent.role === "subagent" && (
<span
style={{
fontSize: "8px",
fontWeight: 700,
color: "#fff",
backgroundColor: statusColor,
borderRadius: "4px",
padding: "0 3px",
lineHeight: "10px",
}}
>
S
</span>
)}
</span>
</div>
</foreignObject>
{hovered && (
<foreignObject x={-80} y={-70} width={160} height={30} style={{ pointerEvents: "none" }}>
<div style={{ display: "flex", justifyContent: "center" }}>
<span
style={{
fontSize: "11px",
fontWeight: 500,
fontFamily: "'Manrope', system-ui, sans-serif",
color: isDark ? "#e8e4da" : "#3d3830",
backgroundColor: isDark ? "rgba(23,19,16,0.92)" : "rgba(255,255,255,0.94)",
borderRadius: "8px",
padding: "4px 10px",
whiteSpace: "nowrap",
boxShadow: "0 4px 8px rgba(0,0,0,0.15)",
border: `1px solid ${isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)"}`,
}}
>
{agent.name} · {STATUS_LABELS[agent.status]}
</span>
</div>
</foreignObject>
)}
</g>
);
});
const spawnStyle: React.CSSProperties = {
animation: "agent-spawn 0.5s ease-out forwards",
transformBox: "fill-box",
transformOrigin: "center bottom",
};
+44
View File
@@ -0,0 +1,44 @@
interface ConnectionLineProps {
x1: number;
y1: number;
x2: number;
y2: number;
strength: number;
kind: "spawn" | "meeting";
}
/** Curved dashed line between two collaborating agents. */
export function ConnectionLine({ x1, y1, x2, y2, strength, kind }: ConnectionLineProps) {
const dx = x2 - x1;
const dy = y2 - y1;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const offset = Math.min(dist * 0.2, 50);
const cx = (x1 + x2) / 2 - (dy / dist) * offset;
const cy = (y1 + y2) / 2 + (dx / dist) * offset;
const pathData = `M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`;
const color = kind === "spawn" ? "#10a37f" : "#9a7fd1";
const isStrong = strength >= 0.5;
return (
<g style={{ pointerEvents: "none" }}>
<path
d={pathData}
fill="none"
stroke={color}
strokeWidth={(isStrong ? 3 : 1.5) + 2}
opacity={Math.max(0.04, strength * 0.15)}
style={{ filter: "blur(3px)" }}
/>
<path
d={pathData}
fill="none"
stroke={color}
strokeWidth={isStrong ? 2.4 : 1.5}
strokeDasharray={isStrong ? "10,6" : "6,4"}
opacity={Math.max(0.25, strength)}
style={{ animation: "dash-flow 1.2s linear infinite" }}
/>
</g>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { memo } from "react";
/** Emote bubbles above a pawn's head; all anchor at (0,0) just above the hair. */
const popBob: React.CSSProperties = {
transformBox: "fill-box",
transformOrigin: "center bottom",
animation: "emote-pop 0.25s ease-out, emote-bob 2s ease-in-out 0.25s infinite",
};
export const ThoughtEmote = memo(function ThoughtEmote({ isDark }: { isDark: boolean }) {
const fill = isDark ? "#e8e4da" : "#ffffff";
const stroke = isDark ? "#9a9284" : "#cbc4b4";
return (
<g style={popBob}>
<circle cx={-4} cy={-2} r={1.6} fill={fill} stroke={stroke} strokeWidth={0.6} />
<circle cx={-7} cy={-7} r={2.4} fill={fill} stroke={stroke} strokeWidth={0.6} />
<g transform="translate(0, -16)">
<ellipse cx={0} cy={0} rx={13} ry={8} fill={fill} stroke={stroke} strokeWidth={0.8} />
<ellipse cx={-8} cy={-3} rx={6} ry={5} fill={fill} />
<ellipse cx={7} cy={-3} rx={6.5} ry={5.5} fill={fill} />
<ellipse cx={0} cy={-5} rx={7} ry={5.5} fill={fill} />
{[0, 1, 2].map((i) => (
<circle
key={i}
cx={(i - 1) * 5}
cy={-1}
r={1.8}
fill="#5b8dd9"
style={{ animation: `thinking-dots 1.2s ease-in-out ${i * 0.18}s infinite` }}
/>
))}
</g>
</g>
);
});
export const SpeechEmote = memo(function SpeechEmote({ text, isDark }: { text: string; isDark: boolean }) {
const snippet = text.replace(/\s+/g, " ").trim().slice(-46);
const bg = isDark ? "rgba(238,234,226,0.96)" : "rgba(255,255,255,0.96)";
return (
<g style={popBob}>
<path d="M -3 -3 L 0 3 L 4 -3 Z" fill={bg} stroke="#c3aee8" strokeWidth={0.8} />
<foreignObject x={-62} y={-40} width={124} height={38} style={{ pointerEvents: "none" }}>
<div style={{ display: "flex", justifyContent: "center", alignItems: "flex-end", height: "100%" }}>
<div
style={{
fontSize: "9px",
lineHeight: "11px",
fontWeight: 600,
color: "#43306b",
backgroundColor: bg,
border: "1px solid #c3aee8",
borderRadius: "8px",
padding: "3px 7px",
maxWidth: "120px",
maxHeight: "34px",
overflow: "hidden",
overflowWrap: "anywhere",
boxShadow: "0 2px 4px rgba(0,0,0,0.12)",
}}
>
{snippet || "…"}
</div>
</div>
</foreignObject>
</g>
);
});
export const ToolEmote = memo(function ToolEmote({ toolName, isDark }: { toolName: string; isDark: boolean }) {
const bg = isDark ? "#0d2b21" : "#eafaf3";
return (
<g style={popBob}>
<path d="M -3 -3 L 0 3 L 4 -3 Z" fill={bg} stroke="#10a37f" strokeWidth={0.8} />
<g transform="translate(0, -13)">
<rect
x={-32}
y={-10}
width={64}
height={17}
rx={8.5}
fill={bg}
stroke="#10a37f"
strokeWidth={1}
style={{ filter: "drop-shadow(0 2px 3px rgba(0,0,0,0.12))" }}
/>
<g transform="translate(-23, -1.5)">
<g style={{ animation: "gear-spin 2s linear infinite", transformBox: "fill-box", transformOrigin: "center" }}>
<Gear />
</g>
</g>
<text
x={4}
y={2}
textAnchor="middle"
fontSize={8.5}
fontWeight={700}
fill={isDark ? "#7fe0c0" : "#0b7a5e"}
fontFamily="'JetBrains Mono', ui-monospace, monospace"
>
{toolName.length > 11 ? `${toolName.slice(0, 10)}` : toolName}
</text>
</g>
</g>
);
});
function Gear() {
const teeth = Array.from({ length: 8 }, (_, i) => (
<rect key={i} x={-1.2} y={-6.2} width={2.4} height={3} rx={0.8} fill="#10a37f" transform={`rotate(${i * 45})`} />
));
return (
<g>
{teeth}
<circle r={4.2} fill="#10a37f" />
<circle r={1.7} fill="#eafaf3" />
</g>
);
}
export const ErrorEmote = memo(function ErrorEmote() {
return (
<g
style={{
transformBox: "fill-box",
transformOrigin: "center bottom",
animation: "emote-pop 0.2s ease-out, emote-bounce 0.7s ease-in-out 0.2s infinite",
}}
>
<g transform="translate(0, -10)">
<path
d="M 0 -10 L 8.5 5 L -8.5 5 Z"
fill="#d14343"
stroke="#a02c2c"
strokeWidth={1}
strokeLinejoin="round"
style={{ filter: "drop-shadow(0 2px 3px rgba(0,0,0,0.25))" }}
/>
<rect x={-1.2} y={-5.5} width={2.4} height={5.5} rx={1.2} fill="#fff" />
<circle cx={0} cy={2.5} r={1.4} fill="#fff" />
</g>
</g>
);
});
export const ZzzEmote = memo(function ZzzEmote({ isDark }: { isDark: boolean }) {
const color = isDark ? "#9a9284" : "#8a8578";
return (
<g>
{[0, 1, 2].map((i) => (
<text
key={i}
x={i * 4}
y={-i * 5}
fontSize={7 + i * 2}
fontWeight={700}
fill={color}
fontFamily="ui-rounded, system-ui"
style={{ animation: `zzz-float 2.4s ease-out ${i * 0.8}s infinite`, transformBox: "fill-box" }}
>
z
</text>
))}
</g>
);
});
export const SparkleEmote = memo(function SparkleEmote() {
const sparkles = [
{ x: -12, y: -6, s: 1, d: 0 },
{ x: 10, y: -12, s: 0.8, d: 0.3 },
{ x: 0, y: -18, s: 1.1, d: 0.6 },
];
return (
<g>
{sparkles.map((sp, i) => (
<g key={i} transform={`translate(${sp.x}, ${sp.y}) scale(${sp.s})`}>
<path
d="M 0 -5 L 1.3 -1.3 L 5 0 L 1.3 1.3 L 0 5 L -1.3 1.3 L -5 0 L -1.3 -1.3 Z"
fill="#3fb0bc"
style={{
animation: `sparkle-twinkle 1.1s ease-in-out ${sp.d}s infinite`,
transformBox: "fill-box",
transformOrigin: "center",
}}
/>
</g>
))}
</g>
);
});
+416
View File
@@ -0,0 +1,416 @@
import { useMemo } from "react";
import {
DARK,
ENTRANCE,
LIGHT,
MEETING_CENTER,
OFFICE,
SVG_HEIGHT,
SVG_WIDTH,
ZONES,
ZONE_LABELS,
type OfficeColors,
} from "@/lib/constants";
import { DESK_SLOTS, HOT_DESK_SLOTS, meetingSeats } from "@/lib/positions";
import { useOfficeStore } from "@/store/office-store";
import type { VisualAgent } from "@/lib/types";
import { AgentAvatar } from "./AgentAvatar";
import { ConnectionLine } from "./ConnectionLine";
import { Chair, CoffeeMachine, CoffeeTable, Desk, MeetingTable, Plant, Sofa } from "./furniture";
export function FloorPlan() {
const agents = useOfficeStore((s) => s.agents);
const links = useOfficeStore((s) => s.links);
const meetings = useOfficeStore((s) => s.meetings);
const selectAgent = useOfficeStore((s) => s.selectAgent);
const isDark = useOfficeStore((s) => s.theme) === "dark";
const C = isDark ? DARK : LIGHT;
const agentList = useMemo(
() => [...agents.values()].sort((a, b) => a.position.y - b.position.y),
[agents],
);
const agentById = (id: string): VisualAgent | undefined => agents.get(id);
return (
<div className="floorplan-wrap">
<svg
viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
className="floorplan-svg"
preserveAspectRatio="xMidYMid meet"
onClick={() => selectAgent(null)}
>
<defs>
<filter id="building-shadow" x="-3%" y="-3%" width="106%" height="106%">
<feDropShadow dx="0" dy="3" stdDeviation="6" floodOpacity={isDark ? 0.5 : 0.15} />
</filter>
<pattern id="corridor-tiles" width="28" height="28" patternUnits="userSpaceOnUse">
<rect width="28" height="28" fill={C.corridor} />
<rect x="0.5" y="0.5" width="27" height="27" rx="2" fill="none" stroke={C.tile} strokeWidth="0.6" />
</pattern>
<pattern id="wood-floor" width="160" height="26" patternUnits="userSpaceOnUse">
<rect width="160" height="26" fill={C.woodA} />
<g stroke={C.woodLine} strokeWidth="0.8">
<line x1="0" y1="0.4" x2="160" y2="0.4" />
<line x1="0" y1="13.4" x2="160" y2="13.4" />
</g>
<g stroke={C.woodLine} strokeWidth="0.6" opacity="0.7">
<line x1="56" y1="0" x2="56" y2="13" />
<line x1="128" y1="13" x2="128" y2="26" />
</g>
</pattern>
<pattern id="lounge-carpet" width="10" height="10" patternUnits="userSpaceOnUse">
<rect width="10" height="10" fill={C.carpet} />
<circle cx="2.5" cy="2.5" r="0.7" fill={C.carpetDot} opacity="0.6" />
<circle cx="7.5" cy="7.5" r="0.7" fill={C.carpetDot} opacity="0.6" />
</pattern>
</defs>
{/* building shell */}
<rect
x={OFFICE.x}
y={OFFICE.y}
width={OFFICE.width}
height={OFFICE.height}
rx={OFFICE.cornerRadius}
fill={C.corridor}
stroke={C.wall}
strokeWidth={OFFICE.wallThickness}
filter="url(#building-shadow)"
/>
<CorridorFloor colors={C} />
{/* zone floors */}
{(Object.keys(ZONES) as Array<keyof typeof ZONES>).map((key) => (
<rect
key={`floor-${key}`}
x={ZONES[key].x}
y={ZONES[key].y}
width={ZONES[key].width}
height={ZONES[key].height}
fill={key === "lounge" ? "url(#lounge-carpet)" : "url(#wood-floor)"}
/>
))}
<PartitionWalls colors={C} />
<DoorOpenings colors={C} />
{/* zone labels */}
{(Object.keys(ZONES) as Array<keyof typeof ZONES>).map((key) => (
<text
key={`label-${key}`}
x={ZONES[key].x + 16}
y={ZONES[key].y + 26}
fontSize={12}
fontWeight={700}
letterSpacing="0.1em"
fill={C.labelText}
fontFamily="'Manrope', system-ui, sans-serif"
>
{ZONE_LABELS[key].zh} · {ZONE_LABELS[key].en}
</text>
))}
{/* desk zone: 6 fixed workstations */}
{DESK_SLOTS.map((slot, i) => (
<DeskUnit key={`desk-${i}`} x={slot.x} y={slot.y} isDark={isDark} agents={agents} zone="desk" slot={i} />
))}
{/* hot desk zone: 8 subagent desks */}
{HOT_DESK_SLOTS.map((slot, i) => (
<DeskUnit key={`hot-${i}`} x={slot.x} y={slot.y} isDark={isDark} agents={agents} zone="hotDesk" slot={i} />
))}
{/* meeting zone */}
<MeetingArea meetingsCount={meetings.length} attendeeCount={meetings[0]?.agentIds.length ?? 0} colors={C} isDark={isDark} />
{/* lounge */}
<LoungeDecor colors={C} isDark={isDark} />
<EntranceDoor colors={C} />
{/* collaboration links */}
{links.map((link) => {
const source = agentById(link.sourceId);
const target = agentById(link.targetId);
if (!source || !target) return null;
return (
<ConnectionLine
key={`${link.sourceId}-${link.targetId}`}
x1={source.position.x}
y1={source.position.y}
x2={target.position.x}
y2={target.position.y}
strength={link.strength}
kind={link.kind}
/>
);
})}
{/* agents, painter's order */}
{agentList.map((agent) => (
<AgentAvatar key={agent.id} agent={agent} />
))}
</svg>
</div>
);
}
/* ── pieces ── */
function DeskUnit({
x,
y,
isDark,
agents,
zone,
slot,
}: {
x: number;
y: number;
isDark: boolean;
agents: Map<string, VisualAgent>;
zone: "desk" | "hotDesk";
slot: number;
}) {
// The seat point (agent.homePosition) is 18px above the unit centre.
let active = false;
for (const a of agents.values()) {
if (a.homeZone === zone && a.homeSlot === slot && !a.movement && a.zone === zone) {
active = a.status === "thinking" || a.status === "tool_calling" || a.status === "speaking";
break;
}
}
return (
<g transform={`translate(${x}, ${y})`}>
<Chair x={0} y={-14} isDark={isDark} />
<Desk x={0} y={26} isDark={isDark} active={active} />
</g>
);
}
function MeetingArea({
meetingsCount,
attendeeCount,
colors,
isDark,
}: {
meetingsCount: number;
attendeeCount: number;
colors: OfficeColors;
isDark: boolean;
}) {
const center = MEETING_CENTER;
const hasMeeting = meetingsCount > 0 && attendeeCount > 0;
const seatR = hasMeeting ? Math.min(74 + attendeeCount * 6, 108) : 88;
const tableR = hasMeeting ? seatR - 28 : 54;
const chairPts = hasMeeting
? meetingSeats(attendeeCount, center)
: Array.from({ length: 6 }, (_, i) => {
const angle = (2 * Math.PI * i) / 6 - Math.PI / 2;
return { x: center.x + Math.cos(angle) * seatR, y: center.y + Math.sin(angle) * seatR };
});
return (
<g>
{/* rug */}
<g transform={`translate(${center.x}, ${center.y})`} opacity={0.85}>
<circle r={seatR + 34} fill={colors.rug} />
<circle r={seatR + 28} fill="none" stroke={colors.rugBorder} strokeWidth={2} strokeDasharray="10 6" />
</g>
{chairPts.map((p, i) => (
<Chair key={`mchair-${i}`} x={Math.round(p.x)} y={Math.round(p.y)} isDark={isDark} />
))}
<MeetingTable x={center.x} y={center.y} radius={tableR} isDark={isDark} />
</g>
);
}
function CorridorFloor({ colors }: { colors: OfficeColors }) {
const cw = OFFICE.corridorWidth;
const hy = OFFICE.y + (OFFICE.height - cw) / 2;
const vx = OFFICE.x + (OFFICE.width - cw) / 2;
return (
<g>
<rect x={OFFICE.x} y={hy} width={OFFICE.width} height={cw} fill="url(#corridor-tiles)" />
<rect x={vx} y={OFFICE.y} width={cw} height={OFFICE.height} fill="url(#corridor-tiles)" />
<line
x1={OFFICE.x}
y1={hy + cw / 2}
x2={OFFICE.x + OFFICE.width}
y2={hy + cw / 2}
stroke={colors.corridorLine}
strokeWidth={0.5}
strokeDasharray="8 6"
opacity={0.6}
/>
<line
x1={vx + cw / 2}
y1={OFFICE.y}
x2={vx + cw / 2}
y2={OFFICE.y + OFFICE.height}
stroke={colors.corridorLine}
strokeWidth={0.5}
strokeDasharray="8 6"
opacity={0.6}
/>
</g>
);
}
function PartitionWalls({ colors }: { colors: OfficeColors }) {
const wallW = 4;
const cw = OFFICE.corridorWidth;
const midX = OFFICE.x + (OFFICE.width - cw) / 2;
const midY = OFFICE.y + (OFFICE.height - cw) / 2;
const walls = [
{ x: midX - wallW / 2, y: OFFICE.y, w: wallW, h: midY - OFFICE.y },
{ x: midX - wallW / 2, y: midY + cw, w: wallW, h: OFFICE.y + OFFICE.height - midY - cw },
{ x: midX + cw - wallW / 2, y: OFFICE.y, w: wallW, h: midY - OFFICE.y },
{ x: midX + cw - wallW / 2, y: midY + cw, w: wallW, h: OFFICE.y + OFFICE.height - midY - cw },
{ x: OFFICE.x, y: midY - wallW / 2, w: midX - OFFICE.x, h: wallW },
{ x: midX + cw, y: midY - wallW / 2, w: OFFICE.x + OFFICE.width - midX - cw, h: wallW },
{ x: OFFICE.x, y: midY + cw - wallW / 2, w: midX - OFFICE.x, h: wallW },
{ x: midX + cw, y: midY + cw - wallW / 2, w: OFFICE.x + OFFICE.width - midX - cw, h: wallW },
];
return (
<g>
{walls.map((w, i) => (
<rect key={i} x={w.x} y={w.y} width={w.w} height={w.h} fill={colors.wallFill} stroke={colors.wall} strokeWidth={0.5} />
))}
</g>
);
}
/** Door gaps aligned with the walk paths: south doors for the top rooms, north doors for the bottom rooms. */
function DoorOpenings({ colors }: { colors: OfficeColors }) {
const cw = OFFICE.corridorWidth;
const midY = OFFICE.y + (OFFICE.height - cw) / 2;
const doorWidth = 44;
const doors = [
{ cx: ZONES.desk.x + ZONES.desk.width / 2, cy: midY },
{ cx: ZONES.meeting.x + ZONES.meeting.width / 2, cy: midY },
{ cx: ZONES.hotDesk.x + ZONES.hotDesk.width / 2, cy: midY + cw },
{ cx: ZONES.lounge.x + ZONES.lounge.width / 2, cy: midY + cw },
];
return (
<g>
{doors.map((d, i) => {
const half = doorWidth / 2;
return (
<g key={i}>
<rect x={d.cx - half} y={d.cy - 3} width={doorWidth} height={6} fill={colors.corridor} />
<path
d={`M ${d.cx - half} ${d.cy} A ${half} ${half} 0 0 1 ${d.cx + half} ${d.cy}`}
fill="none"
stroke={colors.doorArc}
strokeWidth={0.8}
strokeDasharray="3 2"
opacity={0.5}
/>
</g>
);
})}
</g>
);
}
function LoungeDecor({ colors, isDark }: { colors: OfficeColors; isDark: boolean }) {
const lz = ZONES.lounge;
// Reception + logo wall sit right-of-centre so the entrance aisle stays clear.
const rx = lz.x + lz.width * 0.72;
const wallW = 176;
const wallH = 34;
const wallY = lz.y + lz.height * 0.56;
return (
<g>
<Sofa x={lz.x + 118} y={lz.y + 66} rotation={0} isDark={isDark} />
<Sofa x={lz.x + 118} y={lz.y + 202} rotation={180} isDark={isDark} />
<CoffeeTable x={lz.x + 118} y={lz.y + 134} isDark={isDark} />
<CoffeeMachine x={lz.x + 388} y={lz.y + 64} isDark={isDark} />
{/* logo wall */}
<rect x={rx - wallW / 2} y={wallY} width={wallW} height={wallH} rx={5} fill={colors.logoBg} />
<rect x={rx - wallW / 2} y={wallY} width={wallW} height={3} rx={1.5} fill="#10a37f" />
<text
x={rx}
y={wallY + wallH / 2 + 6}
textAnchor="middle"
fill={colors.logoText}
fontSize={16}
fontWeight={600}
fontFamily="'Sora', system-ui, sans-serif"
letterSpacing="0.06em"
>
<tspan fill="#10a37f">&gt;_</tspan> Codex Office
</text>
{/* reception desk */}
<rect
x={rx - 70}
y={wallY + wallH + 14}
width={140}
height={24}
rx={12}
fill={isDark ? "#54432e" : "#d9b98a"}
stroke={isDark ? "#3d3021" : "#bd9c6c"}
strokeWidth={1.2}
/>
<Plant x={rx - wallW / 2 - 26} y={wallY + wallH / 2} />
<Plant x={rx + wallW / 2 + 26} y={wallY + wallH / 2} />
<Plant x={lz.x + 34} y={lz.y + lz.height - 44} />
<Plant x={ZONES.desk.x + 30} y={ZONES.desk.y + 44} />
<Plant x={ZONES.meeting.x + ZONES.meeting.width - 34} y={ZONES.meeting.y + 44} />
</g>
);
}
function EntranceDoor({ colors }: { colors: OfficeColors }) {
const doorCX = ENTRANCE.x;
const doorY = OFFICE.y + OFFICE.height;
const half = 36;
return (
<g>
<rect x={doorCX - half - 2} y={doorY - OFFICE.wallThickness - 1} width={half * 2 + 4} height={OFFICE.wallThickness + 4} fill={colors.carpet} />
<rect x={doorCX - half - 3} y={doorY - 10} width={3} height={12} rx={1} fill={colors.doorArc} />
<rect x={doorCX + half} y={doorY - 10} width={3} height={12} rx={1} fill={colors.doorArc} />
<path
d={`M ${doorCX - half} ${doorY} A ${half} ${half} 0 0 0 ${doorCX} ${doorY - half}`}
fill="none"
stroke={colors.doorArc}
strokeWidth={0.8}
strokeDasharray="4 3"
opacity={0.5}
/>
<path
d={`M ${doorCX + half} ${doorY} A ${half} ${half} 0 0 1 ${doorCX} ${doorY - half}`}
fill="none"
stroke={colors.doorArc}
strokeWidth={0.8}
strokeDasharray="4 3"
opacity={0.5}
/>
<rect x={doorCX - 30} y={doorY - 18} width={60} height={12} rx={3} fill="#c0714c" opacity={0.45} />
<text
x={doorCX}
y={doorY + 15}
textAnchor="middle"
fill={colors.doorArc}
fontSize={9}
fontWeight={600}
letterSpacing="0.15em"
fontFamily="'Manrope', system-ui, sans-serif"
>
ENTRANCE
</text>
</g>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants";
import { useOfficeStore } from "@/store/office-store";
import { getDirector, setMode } from "@/sim/runtime";
import type { AgentStatus } from "@/lib/types";
const LEGEND: AgentStatus[] = ["idle", "thinking", "tool_calling", "speaking", "spawning", "error"];
export function HeaderBar() {
const paused = useOfficeStore((s) => s.paused);
const setPaused = useOfficeStore((s) => s.setPaused);
const speed = useOfficeStore((s) => s.speed);
const setSpeed = useOfficeStore((s) => s.setSpeed);
const theme = useOfficeStore((s) => s.theme);
const setTheme = useOfficeStore((s) => s.setTheme);
const mode = useOfficeStore((s) => s.mode);
const agentCount = useOfficeStore((s) => s.agents.size);
const panelOpen = useOfficeStore((s) => s.panelOpen);
const isLive = mode === "live";
return (
<header className="header">
<div className="header-brand">
<span className="header-mark">
&gt;<span className="header-cursor">_</span>
</span>
<div>
<h1>Codex Office</h1>
<p>multi-agent · {isLive ? "實況模式" : "模擬模式"}</p>
</div>
</div>
<div className="header-legend">
{LEGEND.map((s) => (
<span key={s} className="legend-chip">
<span className="legend-dot" style={{ backgroundColor: STATUS_COLORS[s] }} />
{STATUS_LABELS[s]}
</span>
))}
</div>
<div className="header-controls">
<span className="agent-count">{agentCount} agents</span>
<button
className={`btn mode-btn ${isLive ? "live-on" : ""}`}
onClick={() => setMode(isLive ? "sim" : "live")}
title={isLive ? "切回劇本模擬" : "連上真實 Claude Code 活動 (需 npm run watch)"}
>
{isLive ? "● LIVE" : "▶ 模擬"}
</button>
{!isLive && (
<>
<button className="btn" onClick={() => getDirector().hireAgent()} title="招募一位 agent">
+ Agent
</button>
<button
className="btn"
onClick={() => setSpeed(speed >= 4 ? 1 : speed * 2)}
title="模擬速度"
>
{speed}×
</button>
</>
)}
<button className="btn" onClick={() => setPaused(!paused)} title={paused ? "繼續" : "暫停"}>
{paused ? "▶" : "⏸"}
</button>
<button className="btn" onClick={() => setTheme(theme === "light" ? "dark" : "light")} title="切換主題">
{theme === "light" ? "🌙" : "☀️"}
</button>
<button
className="btn"
onClick={() => useOfficeStore.getState().togglePanel()}
title={panelOpen ? "收合面板,放大辦公室" : "展開面板"}
>
{panelOpen ? "⊞" : "⊟"}
</button>
</div>
</header>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { memo } from "react";
import { shade, type PawnAppearance } from "@/lib/appearance";
export type PawnPose = "stand" | "sit" | "walk";
export type PawnMotion = "none" | "typing" | "talking" | "shaking";
const OUTLINE = "rgba(28, 18, 8, 0.32)";
interface PawnProps {
appearance: PawnAppearance;
pose: PawnPose;
motion?: PawnMotion;
ghost?: boolean;
}
/**
* Chibi office worker. Anchor (0,0) at the hips; feet ≈ y+16, hair top ≈ y-30.
*/
export const Pawn = memo(function Pawn({ appearance, pose, motion = "none", ghost = false }: PawnProps) {
const isWalking = pose === "walk";
const isTyping = motion === "typing" && pose !== "walk";
const isTalking = motion === "talking";
const isShaking = motion === "shaking";
const skin = ghost ? "#9ca3af" : appearance.skinColor;
const hair = ghost ? "#6b7280" : appearance.hairColor;
const shirt = ghost ? "#9ca3af" : appearance.shirtColor;
const pants = ghost ? "#6b7280" : appearance.pantsColor;
const shoes = ghost ? "#4b5563" : appearance.shoeColor;
const sleeve = shade(shirt, 0.8);
return (
<g>
<ellipse cx={0} cy={17.5} rx={11.5} ry={3} fill="rgba(0,0,0,0.16)" />
<g
style={
isShaking
? { animation: "pawn-shake 0.45s linear infinite", transformBox: "fill-box", transformOrigin: "center" }
: undefined
}
>
{/* legs */}
<Leg side={-1} pose={pose} walking={isWalking} pants={pants} shoes={shoes} />
<Leg side={1} pose={pose} walking={isWalking} pants={pants} shoes={shoes} />
{/* torso — breathes when idle */}
<g
style={
!isWalking && !isShaking
? { animation: "pawn-breathe 3.4s ease-in-out infinite", transformBox: "fill-box", transformOrigin: "center bottom" }
: undefined
}
>
<rect x={-8} y={-11.5} width={16} height={16} rx={6.5} fill={shirt} stroke={OUTLINE} strokeWidth={1} />
<path
d="M -3 -11 L 0 -8.2 L 3 -11"
fill="none"
stroke={shade(shirt, 0.66)}
strokeWidth={1.2}
strokeLinecap="round"
/>
</g>
{/* arms */}
<Arm side={-1} walking={isWalking} typing={isTyping} sleeve={sleeve} skin={skin} />
<Arm side={1} walking={isWalking} typing={isTyping} sleeve={sleeve} skin={skin} />
{/* head */}
<g>
<circle cx={0} cy={-19.5} r={8.6} fill={skin} stroke={OUTLINE} strokeWidth={1} />
<Hair style={appearance.hairStyle} color={hair} />
<g style={{ animation: "pawn-blink 4.6s ease-in-out infinite", transformBox: "fill-box", transformOrigin: "center" }}>
<Eyes style={appearance.eyeStyle} />
</g>
{isTalking ? (
<ellipse
cx={0}
cy={-15}
rx={1.8}
ry={1.6}
fill="#6b2812"
style={{ animation: "pawn-mouth 0.35s ease-in-out infinite", transformBox: "fill-box", transformOrigin: "center" }}
/>
) : (
<path d="M -1.8 -15 Q 0 -13.6 1.8 -15" fill="none" stroke="#6b2812" strokeWidth={1} strokeLinecap="round" />
)}
</g>
</g>
</g>
);
});
function Leg({ side, pose, walking, pants, shoes }: { side: -1 | 1; pose: PawnPose; walking: boolean; pants: string; shoes: string }) {
const legH = pose === "sit" ? 7 : 11;
const shoeY = pose === "sit" ? 5 : 8.5;
return (
<g transform={`translate(${side * 3.5}, 3)`}>
<g
style={
walking
? {
animation: "pawn-swing 0.5s ease-in-out infinite alternate",
animationDelay: side === 1 ? "-0.5s" : "0s",
transformBox: "fill-box",
transformOrigin: "center top",
}
: undefined
}
>
<rect x={-2.5} y={-1} width={5} height={legH} rx={2.4} fill={pants} stroke={OUTLINE} strokeWidth={0.8} />
<rect x={-3} y={shoeY} width={6} height={4.5} rx={2} fill={shoes} />
</g>
</g>
);
}
function Arm({ side, walking, typing, sleeve, skin }: { side: -1 | 1; walking: boolean; typing: boolean; sleeve: string; skin: string }) {
const baseRotate = typing ? side * -30 : 0;
let animStyle: React.CSSProperties | undefined;
if (walking) {
animStyle = {
animation: "pawn-swing 0.5s ease-in-out infinite alternate",
animationDelay: side === 1 ? "0s" : "-0.5s",
transformBox: "fill-box",
transformOrigin: "center top",
};
} else if (typing) {
animStyle = {
animation: "pawn-type 0.26s ease-in-out infinite",
animationDelay: side === 1 ? "-0.13s" : "0s",
transformBox: "fill-box",
transformOrigin: "center top",
};
}
return (
<g transform={`translate(${side * 8.5}, -8.5) rotate(${baseRotate})`}>
<g style={animStyle}>
<rect x={-2} y={-1} width={4} height={8.8} rx={2} fill={sleeve} stroke={OUTLINE} strokeWidth={0.8} />
<circle cx={0} cy={8.8} r={2.4} fill={skin} stroke={OUTLINE} strokeWidth={0.7} />
</g>
</g>
);
}
function Hair({ style, color }: { style: PawnAppearance["hairStyle"]; color: string }) {
switch (style) {
case "short":
return (
<path
d="M -8.4 -20.5 Q -8.4 -29 0 -29 Q 8.4 -29 8.4 -20.5 Q 4 -24 0 -24 Q -4 -24 -8.4 -20.5 Z"
fill={color}
/>
);
case "spiky":
return (
<g fill={color}>
<path d="M -8.4 -20.5 Q -8.4 -28 0 -28 Q 8.4 -28 8.4 -20.5 Q 4 -23.5 0 -23.5 Q -4 -23.5 -8.4 -20.5 Z" />
<polygon points="-6,-26 -5,-31 -3,-27" />
<polygon points="-1.5,-27 0,-32 1.5,-27" />
<polygon points="3,-27 5,-31 6,-26" />
</g>
);
case "side-part":
return (
<g fill={color}>
<path d="M -8.4 -20 Q -8.6 -29 -1 -29 Q 8.4 -29 8.4 -20 Q 6.5 -25 2 -25.5 Q -3 -26 -8.4 -20 Z" />
<path d="M 4 -28 Q 8.9 -26.5 8.6 -21.5 L 6.3 -24 Z" />
</g>
);
case "curly":
return (
<g fill={color}>
<circle cx={-5.5} cy={-24.5} r={3.4} />
<circle cx={0} cy={-27} r={3.6} />
<circle cx={5.5} cy={-24.5} r={3.4} />
<circle cx={-7.8} cy={-20.5} r={2.6} />
<circle cx={7.8} cy={-20.5} r={2.6} />
</g>
);
case "buzz":
return (
<path
d="M -8 -21.5 Q -8 -28 0 -28 Q 8 -28 8 -21.5 Q 4 -24.5 0 -24.5 Q -4 -24.5 -8 -21.5 Z"
fill={color}
opacity={0.72}
/>
);
case "bob":
return (
<path
d="M -8.8 -14 Q -9.4 -29.5 0 -29.5 Q 9.4 -29.5 8.8 -14 L 6 -14 Q 7 -24 0 -24.5 Q -7 -24 -6 -14 Z"
fill={color}
/>
);
default:
return null;
}
}
function Eyes({ style }: { style: PawnAppearance["eyeStyle"] }) {
const ey = -20;
const gap = 3.2;
switch (style) {
case "dot":
return (
<g>
<circle cx={-gap} cy={ey} r={1.3} fill="#241a12" />
<circle cx={gap} cy={ey} r={1.3} fill="#241a12" />
</g>
);
case "line":
return (
<g stroke="#241a12" strokeWidth={1.1} strokeLinecap="round">
<line x1={-gap - 1.6} y1={ey} x2={-gap + 1.6} y2={ey} />
<line x1={gap - 1.6} y1={ey} x2={gap + 1.6} y2={ey} />
</g>
);
case "wide":
return (
<g>
<ellipse cx={-gap} cy={ey} rx={1.9} ry={2.2} fill="#fff" stroke="#241a12" strokeWidth={0.6} />
<circle cx={-gap} cy={ey + 0.3} r={1} fill="#241a12" />
<ellipse cx={gap} cy={ey} rx={1.9} ry={2.2} fill="#fff" stroke="#241a12" strokeWidth={0.6} />
<circle cx={gap} cy={ey + 0.3} r={1} fill="#241a12" />
</g>
);
default:
return null;
}
}
+121
View File
@@ -0,0 +1,121 @@
import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants";
import { generateAppearance } from "@/lib/appearance";
import { useOfficeStore } from "@/store/office-store";
import { getDirector } from "@/sim/runtime";
import { Pawn } from "./Pawn";
const ROLE_LABELS = { lead: "Lead Agent", agent: "Agent", subagent: "Subagent" } as const;
export function SidePanel() {
const selectedAgentId = useOfficeStore((s) => s.selectedAgentId);
const agent = useOfficeStore((s) => (s.selectedAgentId ? s.agents.get(s.selectedAgentId) : undefined));
const agents = useOfficeStore((s) => s.agents);
const events = useOfficeStore((s) => s.events);
const selectAgent = useOfficeStore((s) => s.selectAgent);
const mode = useOfficeStore((s) => s.mode);
return (
<aside className="side-panel">
{selectedAgentId && agent ? (
<div className="agent-card">
<div className="agent-card-head">
<svg viewBox="-30 -42 60 68" width="64" height="72">
<Pawn appearance={generateAppearance(agent.id)} pose="stand" motion="none" />
</svg>
<div>
<h2>
{agent.role === "lead" && <span className="mark"> </span>}
{agent.name}
</h2>
<span className="role-badge">{ROLE_LABELS[agent.role]}</span>
<code className="model-tag">{agent.model}</code>
</div>
<button className="close-btn" onClick={() => selectAgent(null)}>
</button>
</div>
<div className="agent-stat-row">
<span className="status-chip" style={{ backgroundColor: `${STATUS_COLORS[agent.status]}22`, color: STATUS_COLORS[agent.status] }}>
{STATUS_LABELS[agent.status]}
</span>
{agent.currentTool && <code className="tool-now">{agent.currentTool.name}()</code>}
</div>
<dl className="agent-facts">
<div>
<dt>調</dt>
<dd>{agent.toolCallCount} </dd>
</div>
<div>
<dt></dt>
<dd>{agent.zone}</dd>
</div>
{agent.parentId && (
<div>
<dt></dt>
<dd>
<button className="link-btn" onClick={() => selectAgent(agent.parentId)}>
{agents.get(agent.parentId)?.name ?? agent.parentId}
</button>
</dd>
</div>
)}
{agent.childIds.length > 0 && (
<div>
<dt>Subagents</dt>
<dd>
{agent.childIds.map((cid) => (
<button key={cid} className="link-btn" onClick={() => selectAgent(cid)}>
{agents.get(cid)?.name ?? cid}
</button>
))}
</dd>
</div>
)}
</dl>
{agent.toolHistory.length > 0 && (
<div className="tool-history">
<h3></h3>
<div className="tool-chips">
{agent.toolHistory.map((t, i) => (
<code key={i}>{t}</code>
))}
</div>
</div>
)}
{agent.role !== "subagent" && mode === "sim" && (
<button
className="btn assign-btn"
onClick={() => {
if (!getDirector().assignTask(agent.id)) {
useOfficeStore.getState().addEvent("💬", `${agent.name} 正忙,稍後再指派`);
}
}}
>
📋
</button>
)}
</div>
) : (
<div className="panel-hint">
<p> agent </p>
</div>
)}
<div className="event-feed">
<h3></h3>
<ul>
{events.map((e) => (
<li key={e.at}>
<span className="event-icon">{e.icon}</span>
{e.text}
</li>
))}
</ul>
</div>
</aside>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { memo } from "react";
/** Flat top-down furniture pieces. All anchored at their visual centre. */
export const Desk = memo(function Desk({
x,
y,
isDark,
active,
}: {
x: number;
y: number;
isDark: boolean;
active: boolean;
}) {
const top = isDark ? "#54432e" : "#d9b98a";
const edge = isDark ? "#3d3021" : "#bd9c6c";
const laptopBody = isDark ? "#2a2a2e" : "#4a4a52";
const screen = active ? "#10a37f" : isDark ? "#3d3d44" : "#6b6b74";
return (
<g transform={`translate(${x}, ${y})`}>
<rect x={-34} y={-16} width={68} height={32} rx={5} fill={top} stroke={edge} strokeWidth={1.2} />
<rect x={-30} y={-12.5} width={60} height={25} rx={3.5} fill="none" stroke={edge} strokeWidth={0.6} opacity={0.5} />
{/* laptop facing the seat (north side) */}
<rect x={-10} y={-11} width={20} height={13} rx={2} fill={laptopBody} />
<rect x={-8} y={-9} width={16} height={9} rx={1} fill={screen}>
{active && <animate attributeName="opacity" values="1;0.75;1" dur="1.6s" repeatCount="indefinite" />}
</rect>
{/* mug */}
<circle cx={22} cy={4} r={3.4} fill={isDark ? "#8a4a2e" : "#c0714c"} />
<circle cx={22} cy={4} r={2} fill={isDark ? "#5c3220" : "#8a4a2e"} />
{/* notepad */}
<rect x={-27} y={2} width={12} height={9} rx={1} fill={isDark ? "#8f8672" : "#f6efdd"} transform="rotate(-8 -21 6)" />
</g>
);
});
export const Chair = memo(function Chair({ x, y, isDark }: { x: number; y: number; isDark: boolean }) {
const seat = isDark ? "#4a3a28" : "#a9835b";
const edge = isDark ? "#32271a" : "#8c6a4a";
return (
<g transform={`translate(${x}, ${y})`}>
<rect x={-9} y={-8} width={18} height={16} rx={5} fill={seat} stroke={edge} strokeWidth={1} />
<rect x={-10} y={-11} width={20} height={5} rx={2.5} fill={edge} />
</g>
);
});
export const MeetingTable = memo(function MeetingTable({
x,
y,
radius,
isDark,
}: {
x: number;
y: number;
radius: number;
isDark: boolean;
}) {
const top = isDark ? "#54432e" : "#d9b98a";
const edge = isDark ? "#3d3021" : "#bd9c6c";
return (
<g transform={`translate(${x}, ${y})`}>
<circle r={radius} fill={top} stroke={edge} strokeWidth={2} />
<circle r={radius - 7} fill="none" stroke={edge} strokeWidth={0.8} opacity={0.5} />
{/* Claude mark inlaid in the table */}
<text
textAnchor="middle"
dy={radius * 0.16}
fontSize={radius * 0.4}
fill={isDark ? "#8a6a45" : "#c9a06c"}
opacity={0.55}
fontFamily="'JetBrains Mono', monospace"
fontWeight={700}
>
&gt;_
</text>
</g>
);
});
export const Sofa = memo(function Sofa({
x,
y,
rotation = 0,
isDark,
}: {
x: number;
y: number;
rotation?: number;
isDark: boolean;
}) {
const body = isDark ? "#5c4434" : "#cd8d66";
const cushion = isDark ? "#6e5442" : "#dda67f";
const edge = isDark ? "#3d2c20" : "#b0714a";
return (
<g transform={`translate(${x}, ${y}) rotate(${rotation})`}>
<rect x={-46} y={-18} width={92} height={36} rx={10} fill={body} stroke={edge} strokeWidth={1.2} />
<rect x={-40} y={-12} width={37} height={24} rx={6} fill={cushion} />
<rect x={3} y={-12} width={37} height={24} rx={6} fill={cushion} />
<rect x={-46} y={-18} width={92} height={7} rx={3.5} fill={edge} opacity={0.85} />
</g>
);
});
export const Plant = memo(function Plant({ x, y }: { x: number; y: number }) {
return (
<g transform={`translate(${x}, ${y})`}>
<rect x={-7} y={4} width={14} height={10} rx={3} fill="#a0623d" />
<ellipse cx={-5} cy={-2} rx={6} ry={9} fill="#5f8f52" transform="rotate(-24 -5 -2)" />
<ellipse cx={5} cy={-2} rx={6} ry={9} fill="#6f9f5e" transform="rotate(24 5 -2)" />
<ellipse cx={0} cy={-6} rx={5.5} ry={10} fill="#7fae6b" />
</g>
);
});
export const CoffeeTable = memo(function CoffeeTable({ x, y, isDark }: { x: number; y: number; isDark: boolean }) {
const top = isDark ? "#4a3a28" : "#c9a97e";
const edge = isDark ? "#32271a" : "#a9835b";
return (
<g transform={`translate(${x}, ${y})`}>
<ellipse rx={26} ry={16} fill={top} stroke={edge} strokeWidth={1.2} />
<circle cx={-7} cy={-2} r={3.2} fill={isDark ? "#8a4a2e" : "#c0714c"} />
<circle cx={-7} cy={-2} r={1.8} fill={isDark ? "#5c3220" : "#8a4a2e"} />
<rect x={2} y={-5} width={12} height={9} rx={1.5} fill={isDark ? "#8f8672" : "#f6efdd"} transform="rotate(9 8 0)" />
</g>
);
});
/** Espresso machine on a small stand — the lounge's centrepiece. */
export const CoffeeMachine = memo(function CoffeeMachine({ x, y, isDark }: { x: number; y: number; isDark: boolean }) {
const stand = isDark ? "#4a3a28" : "#b08a5e";
const body = isDark ? "#26262b" : "#3a3a42";
return (
<g transform={`translate(${x}, ${y})`}>
<rect x={-18} y={-10} width={36} height={22} rx={4} fill={stand} stroke={isDark ? "#32271a" : "#93714c"} strokeWidth={1} />
<rect x={-11} y={-7} width={22} height={13} rx={2.5} fill={body} />
<circle cx={0} cy={-0.5} r={2.6} fill="#10a37f" />
<rect x={-8} y={8} width={5} height={3} rx={1} fill="#e8e0cd" />
<rect x={3} y={8} width={5} height={3} rx={1} fill="#e8e0cd" />
{/* steam */}
{[0, 1].map((i) => (
<path
key={i}
d={`M ${i * 6 - 3} -10 q 2 -4 0 -8`}
fill="none"
stroke={isDark ? "#9a9284" : "#b8b0a0"}
strokeWidth={1}
strokeLinecap="round"
opacity={0.6}
style={{ animation: `zzz-float 2.6s ease-out ${i * 1.1}s infinite` }}
/>
))}
</g>
);
});
+183
View File
@@ -0,0 +1,183 @@
import { useOfficeStore } from "@/store/office-store";
import { DESK_SLOTS, HOT_DESK_SLOTS } from "@/lib/positions";
import { ENTRANCE } from "@/lib/constants";
const SSE_URL = "http://localhost:5181/events";
/** Status decay: how long a live status persists without fresh events. */
const SPEAK_TTL = 8_000;
const TOOL_TTL = 12_000;
const THINK_TTL = 45_000;
const OFFLINE_AFTER = 5 * 60_000;
const SUB_MAX_AGE = 20 * 60_000;
interface SubMeta {
agentId: string;
parentId: string;
startedAt: number;
}
let es: EventSource | null = null;
let decayTimer: ReturnType<typeof setInterval> | null = null;
const lastEventAt = new Map<string, number>();
const subsByToolId = new Map<string, SubMeta>();
const S = () => useOfficeStore.getState();
const mainId = (slug: string) => `proj-${slug}`;
function freeSlot(zone: "desk" | "hotDesk"): number {
const slots = zone === "desk" ? DESK_SLOTS : HOT_DESK_SLOTS;
const occupied = new Set(
[...S().agents.values()].filter((a) => a.homeZone === zone).map((a) => a.homeSlot),
);
return slots.findIndex((_, i) => !occupied.has(i));
}
function seatProject(slug: string, name: string, lastActivity: number) {
if (S().agents.has(mainId(slug))) return;
const slot = freeSlot("desk");
if (slot === -1) {
S().addEvent("🈵", `工位已滿,略過專案 ${name}`);
return;
}
const id = mainId(slug);
S().spawnAgent({ id, name, role: "agent", model: "codex cli session", homeSlot: slot });
S().startWalk(id, DESK_SLOTS[slot], "desk", { arriveStatus: "idle" });
S().addEvent("🪑", `專案 ${name} 入座`);
lastEventAt.set(id, lastActivity || Date.now());
}
function spawnSub(slug: string, subId: string, desc: string, kind: string) {
const parentId = mainId(slug);
if (!S().agents.has(parentId)) return;
if (subsByToolId.has(subId)) return;
const slot = freeSlot("hotDesk");
if (slot === -1) return;
const agentId = `live-sub-${subId}`;
const name = desc || kind;
S().spawnAgent({ id: agentId, name, role: "subagent", model: kind, homeSlot: slot, parentId });
S().startWalk(agentId, HOT_DESK_SLOTS[slot], "hotDesk", { arriveStatus: "tool_calling" });
S().addLink({ sourceId: parentId, targetId: agentId, strength: 0.6, kind: "spawn" });
S().addEvent("✨", `${S().agents.get(parentId)?.name} 派出 subagent「${name}`);
subsByToolId.set(subId, { agentId, parentId, startedAt: Date.now() });
lastEventAt.set(agentId, Date.now());
}
function endSub(subId: string) {
const meta = subsByToolId.get(subId);
if (!meta) return;
subsByToolId.delete(subId);
const agent = S().agents.get(meta.agentId);
if (!agent) return;
S().removeLink(meta.parentId, meta.agentId);
S().setSpeech(meta.agentId, null);
S().addEvent("↩️", `subagent「${agent.name}」完成,離開辦公室`);
S().startWalk(meta.agentId, { ...ENTRANCE }, "corridor", { despawnOnArrive: true });
}
function bump(id: string) {
lastEventAt.set(id, Date.now());
}
function onEvent(e: MessageEvent) {
let ev: Record<string, unknown>;
try {
ev = JSON.parse(e.data as string);
} catch {
return;
}
const slug = ev.slug as string;
const id = slug ? mainId(slug) : "";
switch (ev.type) {
case "snapshot": {
const projects = ev.projects as Array<{ slug: string; name: string; lastActivity: number }>;
for (const p of projects.slice(0, DESK_SLOTS.length)) {
seatProject(p.slug, p.name, p.lastActivity);
}
const subs =
(ev.subs as Array<{ subId: string; slug: string; desc: string; kind: string }> | undefined) ?? [];
for (const sub of subs) spawnSub(sub.slug, sub.subId, sub.desc, sub.kind);
break;
}
case "project":
seatProject(slug, ev.name as string, ev.lastActivity as number);
break;
case "prompt": {
if (!S().agents.has(id)) break;
S().setStatus(id, "thinking");
S().addEvent("📋", `${S().agents.get(id)?.name} 收到新任務`);
bump(id);
break;
}
case "thinking": {
if (!S().agents.has(id)) break;
S().setStatus(id, "thinking");
bump(id);
break;
}
case "tool": {
if (!S().agents.has(id)) break;
S().setStatus(id, "tool_calling");
S().setTool(id, { name: ev.tool as string, startedAt: Date.now() });
bump(id);
break;
}
case "speech": {
if (!S().agents.has(id)) break;
S().setStatus(id, "speaking");
S().setSpeech(id, ev.text as string);
bump(id);
break;
}
case "subagent_spawn":
spawnSub(slug, ev.subId as string, ev.desc as string, ev.kind as string);
bump(id);
break;
case "subagent_end":
endSub(ev.subId as string);
bump(id);
break;
}
}
/** Fade statuses back to idle / offline when a project goes quiet. */
function decayPass() {
const now = Date.now();
for (const agent of S().agents.values()) {
if (!agent.id.startsWith("proj-")) continue;
const last = lastEventAt.get(agent.id) ?? now;
const age = now - last;
if (agent.status === "speaking" && age > SPEAK_TTL) S().setStatus(agent.id, "idle");
else if (agent.status === "tool_calling" && age > TOOL_TTL) S().setStatus(agent.id, "idle");
else if (agent.status === "thinking" && age > THINK_TTL) S().setStatus(agent.id, "idle");
else if (agent.status === "idle" && age > OFFLINE_AFTER) S().setStatus(agent.id, "offline");
else if (agent.status === "offline" && age < OFFLINE_AFTER) S().setStatus(agent.id, "idle");
}
for (const [subId, meta] of subsByToolId) {
if (now - meta.startedAt > SUB_MAX_AGE) endSub(subId);
}
}
export function liveConnect() {
if (es) return;
es = new EventSource(SSE_URL);
es.onmessage = onEvent;
es.onopen = () => S().addEvent("🔌", "已連上實況伺服器");
es.onerror = () => {
if (es?.readyState === EventSource.CLOSED) {
S().addEvent("⚠️", "實況伺服器連線中斷 (npm run watch)");
}
};
decayTimer = setInterval(decayPass, 1000);
}
export function liveDisconnect() {
es?.close();
es = null;
if (decayTimer) clearInterval(decayTimer);
decayTimer = null;
lastEventAt.clear();
subsByToolId.clear();
}
+64
View File
@@ -0,0 +1,64 @@
export type HairStyle = "short" | "spiky" | "side-part" | "curly" | "buzz" | "bob";
export type EyeStyle = "dot" | "line" | "wide";
export interface PawnAppearance {
hairStyle: HairStyle;
eyeStyle: EyeStyle;
skinColor: string;
hairColor: string;
shirtColor: string;
pantsColor: string;
shoeColor: string;
}
export function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
const HAIR_STYLES: HairStyle[] = ["short", "spiky", "side-part", "curly", "buzz", "bob"];
const EYE_STYLES: EyeStyle[] = ["dot", "line", "wide"];
const SKIN_COLORS = ["#fde2c8", "#f5c5a0", "#d4956b", "#a0714f", "#6b4226", "#ffe0bd"];
const HAIR_COLORS = ["#2c1b0e", "#5a3214", "#c2884a", "#e8c068", "#7b6d66"];
/** Cool, Codex-adjacent wardrobe — greens, teals, blues, one orange pop. */
const SHIRT_COLORS = [
"#10a37f",
"#2fb3c9",
"#4f86d9",
"#7b68c9",
"#3d7a68",
"#88a15f",
"#4a7dbb",
"#b05fa3",
"#5f9e64",
"#c9793a",
];
const PANTS_COLORS = ["#4a4238", "#52525b", "#5b4636", "#37475c", "#4a3f63", "#6b3f4a"];
const SHOE_COLORS = ["#3f3f46", "#7c2d12", "#1e293b", "#525252"];
export function generateAppearance(agentId: string): PawnAppearance {
const h = hashString(agentId);
const bits = (offset: number, count: number) => (h >>> offset) % count;
return {
hairStyle: HAIR_STYLES[bits(3, HAIR_STYLES.length)],
eyeStyle: EYE_STYLES[bits(6, EYE_STYLES.length)],
skinColor: SKIN_COLORS[bits(8, SKIN_COLORS.length)],
hairColor: HAIR_COLORS[bits(11, HAIR_COLORS.length)],
shirtColor: SHIRT_COLORS[h % SHIRT_COLORS.length],
pantsColor: PANTS_COLORS[bits(14, PANTS_COLORS.length)],
shoeColor: SHOE_COLORS[bits(17, SHOE_COLORS.length)],
};
}
/** Darken a hex color for sleeves / shading. */
export function shade(hex: string, factor: number): string {
const r = Math.round(parseInt(hex.slice(1, 3), 16) * factor);
const g = Math.round(parseInt(hex.slice(3, 5), 16) * factor);
const b = Math.round(parseInt(hex.slice(5, 7), 16) * factor);
return `rgb(${r},${g},${b})`;
}
+140
View File
@@ -0,0 +1,140 @@
import type { AgentStatus, AgentZone } from "./types";
export const SVG_WIDTH = 1200;
export const SVG_HEIGHT = 700;
/** One building shell, four quadrant rooms, a cross-shaped corridor. */
export const OFFICE = {
x: 30,
y: 20,
width: SVG_WIDTH - 60,
height: SVG_HEIGHT - 40,
wallThickness: 6,
cornerRadius: 18,
corridorWidth: 34,
} as const;
const halfW = (OFFICE.width - OFFICE.corridorWidth) / 2;
const halfH = (OFFICE.height - OFFICE.corridorWidth) / 2;
const rightX = OFFICE.x + halfW + OFFICE.corridorWidth;
const bottomY = OFFICE.y + halfH + OFFICE.corridorWidth;
export interface ZoneRect {
x: number;
y: number;
width: number;
height: number;
}
export const ZONES: Record<Exclude<AgentZone, "corridor">, ZoneRect> = {
desk: { x: OFFICE.x, y: OFFICE.y, width: halfW, height: halfH },
meeting: { x: rightX, y: OFFICE.y, width: halfW, height: halfH },
hotDesk: { x: OFFICE.x, y: bottomY, width: halfW, height: halfH },
lounge: { x: rightX, y: bottomY, width: halfW, height: halfH },
};
export const ZONE_LABELS: Record<Exclude<AgentZone, "corridor">, { zh: string; en: string }> = {
desk: { zh: "主力工位區", en: "AGENTS" },
meeting: { zh: "會議區", en: "WAR ROOM" },
hotDesk: { zh: "Worker 熱桌區", en: "HOT DESKS" },
lounge: { zh: "休息區", en: "LOUNGE" },
};
/** Main entrance: bottom outer wall, centred under the lounge. */
export const ENTRANCE = {
x: ZONES.lounge.x + ZONES.lounge.width / 2,
y: OFFICE.y + OFFICE.height - 26,
} as const;
export const CORRIDOR_CENTER = {
x: OFFICE.x + OFFICE.width / 2,
y: OFFICE.y + OFFICE.height / 2,
} as const;
export const MEETING_CENTER = {
x: ZONES.meeting.x + ZONES.meeting.width / 2,
y: ZONES.meeting.y + ZONES.meeting.height / 2 + 10,
} as const;
/* ── Codex palette: cool graphite + terminal green ── */
export interface OfficeColors {
canvas: string;
corridor: string;
corridorLine: string;
tile: string;
wall: string;
wallFill: string;
woodA: string;
woodLine: string;
carpet: string;
carpetDot: string;
rug: string;
rugBorder: string;
labelText: string;
doorArc: string;
logoBg: string;
logoText: string;
}
export const LIGHT: OfficeColors = {
canvas: "#e2e7e4",
corridor: "#dfe4df",
corridorLine: "#b9c4bc",
tile: "#ccd4cf",
wall: "#5f6e6a",
wallFill: "#8fa19b",
woodA: "#eef0ea",
woodLine: "#dde2d6",
carpet: "#e2ece6",
carpetDot: "#c9dcd2",
rug: "#c4d8ce",
rugBorder: "#a3c2b4",
labelText: "#7d938c",
doorArc: "#8aa198",
logoBg: "#0f1512",
logoText: "#d7efe5",
};
export const DARK: OfficeColors = {
canvas: "#0d1110",
corridor: "#131917",
corridorLine: "#2b3733",
tile: "#1c2421",
wall: "#3d4b46",
wallFill: "#2c3833",
woodA: "#161c19",
woodLine: "#1e2622",
carpet: "#17201c",
carpetDot: "#24312b",
rug: "#223129",
rugBorder: "#354940",
labelText: "#5c6f68",
doorArc: "#567a6d",
logoBg: "#d7efe5",
logoText: "#0f1512",
};
/** Terminal green is reserved for tool-calling — the "hands on keyboard" state. */
export const STATUS_COLORS: Record<AgentStatus, string> = {
idle: "#7b8fa1",
thinking: "#4f86d9",
tool_calling: "#10a37f",
speaking: "#a06cd5",
spawning: "#2fb3c9",
error: "#d64545",
offline: "#7d7d75",
};
export const STATUS_LABELS: Record<AgentStatus, string> = {
idle: "待命",
thinking: "思考中",
tool_calling: "工具調用",
speaking: "回覆中",
spawning: "生成中",
error: "錯誤",
offline: "離線",
};
export const MAX_MAIN_AGENTS = 6;
export const MAX_SUB_AGENTS = 8;
+101
View File
@@ -0,0 +1,101 @@
import { CORRIDOR_CENTER, ENTRANCE, OFFICE, ZONES } from "./constants";
import type { AgentZone, Point } from "./types";
export const WALK_SPEED = 130; // svg px / second
export const MIN_WALK_DURATION = 1.1;
const corridorW = OFFICE.corridorWidth;
/** Where each zone's door meets the corridor (all rooms open onto the central cross). */
function zoneDoorPoint(zone: AgentZone): Point {
if (zone === "corridor") return { ...ENTRANCE };
const z = ZONES[zone];
switch (zone) {
case "desk":
case "meeting":
return { x: z.x + z.width / 2, y: z.y + z.height + corridorW / 2 };
case "hotDesk":
case "lounge":
return { x: z.x + z.width / 2, y: z.y - corridorW / 2 };
}
}
function sameCorridorArm(a: AgentZone, b: AgentZone): boolean {
if (a === "corridor" || b === "corridor") return false;
const pairs: AgentZone[][] = [
["desk", "hotDesk"],
["meeting", "lounge"],
["desk", "meeting"],
["hotDesk", "lounge"],
];
return pairs.some((p) => p.includes(a) && p.includes(b));
}
/** Waypoint path from → fromDoor → (corridor centre) → toDoor → to. */
export function planWalkPath(from: Point, to: Point, fromZone: AgentZone, toZone: AgentZone): Point[] {
if (fromZone === toZone) return [{ ...from }, { ...to }];
const fromDoor = zoneDoorPoint(fromZone);
const toDoor = zoneDoorPoint(toZone);
// The entrance sits on the lounge's south wall: walks to/from the entrance
// pass through the lounge door only when leaving the building side.
if (fromZone === "corridor" && toZone === "lounge") return [{ ...from }, { ...to }];
if (fromZone === "lounge" && toZone === "corridor") return [{ ...from }, { ...to }];
if (fromZone === "corridor") {
// Enter the building: entrance → lounge door → corridor → destination door.
const loungeDoor = zoneDoorPoint("lounge");
if (toZone === "meeting") return [{ ...from }, loungeDoor, toDoor, { ...to }];
return [{ ...from }, loungeDoor, { ...CORRIDOR_CENTER }, toDoor, { ...to }];
}
if (toZone === "corridor") {
const loungeDoor = zoneDoorPoint("lounge");
if (fromZone === "meeting") return [{ ...from }, fromDoor, loungeDoor, { ...to }];
return [{ ...from }, fromDoor, { ...CORRIDOR_CENTER }, loungeDoor, { ...to }];
}
if (sameCorridorArm(fromZone, toZone)) {
return [{ ...from }, fromDoor, toDoor, { ...to }];
}
return [{ ...from }, fromDoor, { ...CORRIDOR_CENTER }, toDoor, { ...to }];
}
function distance(a: Point, b: Point): number {
return Math.hypot(b.x - a.x, b.y - a.y);
}
export function pathLength(path: Point[]): number {
let len = 0;
for (let i = 1; i < path.length; i++) len += distance(path[i - 1], path[i]);
return len;
}
export function walkDuration(path: Point[]): number {
return Math.max(pathLength(path) / WALK_SPEED, MIN_WALK_DURATION);
}
/** Distance-proportional interpolation along a polyline. */
export function interpolatePath(path: Point[], progress: number): Point {
if (path.length === 0) return { x: 0, y: 0 };
if (progress <= 0) return { ...path[0] };
if (progress >= 1) return { ...path[path.length - 1] };
const total = pathLength(path);
if (total === 0) return { ...path[0] };
const target = progress * total;
let acc = 0;
for (let i = 1; i < path.length; i++) {
const seg = distance(path[i - 1], path[i]);
if (acc + seg >= target) {
const t = seg > 0 ? (target - acc) / seg : 0;
return {
x: path[i - 1].x + (path[i].x - path[i - 1].x) * t,
y: path[i - 1].y + (path[i].y - path[i - 1].y) * t,
};
}
acc += seg;
}
return { ...path[path.length - 1] };
}
+73
View File
@@ -0,0 +1,73 @@
/** Canned dialogue for the simulation — Codex CLI flavoured. */
export const TOOLS = [
"exec",
"shell",
"apply_patch",
"read_file",
"web_search",
"browser",
"js",
"update_plan",
] as const;
export const SUB_TOOLS = ["exec", "read_file", "shell", "web_search"] as const;
export const TASK_NAMES = [
"重構登入流程",
"修 CI 紅燈",
"寫整合測試",
"調查記憶體洩漏",
"升級依賴套件",
"優化查詢效能",
"補齊 API 文件",
"審查 PR #42",
];
export const DONE_PHRASES = [
"任務完成 ✓",
"測試全部通過了",
"找到問題根源了",
"PR 已送出審查",
"patch 已套用,行為不變",
"報告整理好了",
"已修復並驗證",
"文件更新完畢",
];
export const SUB_DONE_PHRASES = [
"搜尋結果回報完畢",
"找到 3 個相關檔案",
"分析完成,回報中",
"子任務完成 ✓",
];
export const MEETING_PHRASES = [
"這個介面要先定好",
"我來負責前半段",
"分工沒問題",
"先對齊資料格式",
"衝突我來解",
"就這樣執行吧",
];
export const AGENT_NAMES = ["Codex", "Sol", "Nova", "Mini", "Atlas", "Orion"];
export const AGENT_MODELS: Record<string, string> = {
Codex: "gpt-5-codex",
Sol: "gpt-5.6-sol",
Nova: "gpt-5-nova",
Mini: "gpt-5-mini",
Atlas: "o4",
Orion: "gpt-5.2",
};
export const SUB_AGENT_KINDS = ["Explore", "Plan", "Review", "Research", "Test"];
export function pick<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
export function rand(min: number, max: number): number {
return min + Math.random() * (max - min);
}
+62
View File
@@ -0,0 +1,62 @@
import { MEETING_CENTER, ZONES } from "./constants";
import type { Point } from "./types";
export interface DeskSlot {
x: number;
y: number;
}
function gridSlots(
zone: { x: number; y: number; width: number; height: number },
cols: number,
rows: number,
padX: number,
padTop: number,
padBottom: number,
): DeskSlot[] {
const availW = zone.width - padX * 2;
const availH = zone.height - padTop - padBottom;
const cellW = availW / cols;
const cellH = availH / rows;
const slots: DeskSlot[] = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
slots.push({
x: Math.round(zone.x + padX + cellW * (col + 0.5)),
y: Math.round(zone.y + padTop + cellH * (row + 0.5)),
});
}
}
return slots;
}
/** 6 fixed desks for main agents (3 × 2). */
export const DESK_SLOTS = gridSlots(ZONES.desk, 3, 2, 60, 66, 40);
/** 8 hot desks for subagents (4 × 2). */
export const HOT_DESK_SLOTS = gridSlots(ZONES.hotDesk, 4, 2, 50, 70, 36);
/** Standing spots in the lounge, between the sofas and the reception desk. */
export const LOUNGE_ANCHORS: Point[] = (() => {
const lz = ZONES.lounge;
return [
{ x: lz.x + 200, y: lz.y + 88 },
{ x: lz.x + 265, y: lz.y + 140 },
{ x: lz.x + 360, y: lz.y + 88 },
{ x: lz.x + 60, y: lz.y + 150 },
{ x: lz.x + 145, y: lz.y + 140 },
{ x: lz.x + 430, y: lz.y + 150 },
];
})();
/** Circular seats around the meeting table. */
export function meetingSeats(count: number, center: Point = MEETING_CENTER): Point[] {
const radius = Math.min(74 + count * 6, 108);
return Array.from({ length: count }, (_, i) => {
const angle = (2 * Math.PI * i) / count - Math.PI / 2;
return {
x: Math.round(center.x + Math.cos(angle) * radius),
y: Math.round(center.y + Math.sin(angle) * radius),
};
});
}
+77
View File
@@ -0,0 +1,77 @@
export type AgentStatus =
| "idle"
| "thinking"
| "tool_calling"
| "speaking"
| "spawning"
| "error"
| "offline";
export type AgentZone = "desk" | "meeting" | "hotDesk" | "lounge" | "corridor";
export type AgentRole = "lead" | "agent" | "subagent";
export interface Point {
x: number;
y: number;
}
export interface MovementState {
path: Point[];
/** total walk duration in seconds */
duration: number;
/** elapsed seconds */
elapsed: number;
toZone: AgentZone;
/** status to apply when the walk finishes */
arriveStatus?: AgentStatus;
/** remove the agent from the office when the walk finishes */
despawnOnArrive?: boolean;
}
export interface ToolCall {
name: string;
startedAt: number;
}
export interface VisualAgent {
id: string;
name: string;
role: AgentRole;
/** model tag shown in the detail panel, e.g. "fable-5" */
model: string;
status: AgentStatus;
position: Point;
zone: AgentZone;
/** permanently assigned seat (desk / hot-desk slot centre) */
homePosition: Point;
homeZone: AgentZone;
homeSlot: number;
currentTool: ToolCall | null;
speech: string | null;
movement: MovementState | null;
parentId: string | null;
childIds: string[];
toolCallCount: number;
toolHistory: string[];
spawnedAt: number;
}
export interface CollaborationLink {
sourceId: string;
targetId: string;
strength: number;
kind: "spawn" | "meeting";
}
export interface Meeting {
id: string;
agentIds: string[];
center: Point;
}
export interface OfficeEvent {
at: number;
text: string;
icon: string;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+445
View File
@@ -0,0 +1,445 @@
import { useOfficeStore } from "@/store/office-store";
import { ENTRANCE, MAX_MAIN_AGENTS, MAX_SUB_AGENTS } from "@/lib/constants";
import { DESK_SLOTS, HOT_DESK_SLOTS, LOUNGE_ANCHORS } from "@/lib/positions";
import {
AGENT_MODELS,
AGENT_NAMES,
DONE_PHRASES,
MEETING_PHRASES,
SUB_AGENT_KINDS,
SUB_DONE_PHRASES,
SUB_TOOLS,
TASK_NAMES,
TOOLS,
pick,
rand,
} from "@/lib/phrases";
type Phase =
| "arriving"
| "idle"
| "thinking"
| "tooling"
| "waiting_subs"
| "speaking"
| "error"
| "lounge_go"
| "lounge_stay"
| "lounge_back"
| "meeting"
| "sub_arriving"
| "sub_working"
| "sub_reporting"
| "sub_leaving";
interface Brain {
phase: Phase;
timer: number;
toolsLeft: number;
loungeAnchor: number;
taskName: string | null;
}
interface MeetingSession {
id: string;
agentIds: string[];
timer: number;
speakTimer: number;
speakerIdx: number;
gathered: boolean;
}
let subSeq = 0;
let meetingSeq = 0;
/**
* The simulation director: a per-agent finite-state machine plus a global
* meeting scheduler. Everything mutates the world through store actions only,
* so a real Claude Code event feed could replace this file wholesale.
*/
export class Director {
private brains = new Map<string, Brain>();
private meeting: MeetingSession | null = null;
private meetingCooldown = 20;
private arrivalQueue: string[] = [];
private arrivalTimer = 0.5;
constructor() {
this.arrivalQueue = AGENT_NAMES.slice(0, 4);
}
private get store() {
return useOfficeStore.getState();
}
update(dt: number) {
this.handleArrivals(dt);
this.updateMeetingScheduler(dt);
const S = this.store;
for (const [id, brain] of this.brains) {
const agent = S.agents.get(id);
if (!agent) {
this.brains.delete(id);
continue;
}
brain.timer -= dt;
this.stepAgent(id, brain, dt);
}
}
/* ── public controls ── */
/** Manually assign a task from the UI panel. */
assignTask(id: string): boolean {
const brain = this.brains.get(id);
const agent = this.store.agents.get(id);
if (!brain || !agent || brain.phase !== "idle" || agent.movement) return false;
this.beginTask(id, brain);
return true;
}
/** Spawn one more main agent (header button). */
hireAgent(): boolean {
const S = this.store;
const mains = [...S.agents.values()].filter((a) => a.role !== "subagent");
if (mains.length + this.arrivalQueue.length >= MAX_MAIN_AGENTS) return false;
const used = new Set(mains.map((a) => a.name));
const name = AGENT_NAMES.find((n) => !used.has(n) && !this.arrivalQueue.includes(n));
if (!name) return false;
this.arrivalQueue.push(name);
return true;
}
/* ── arrivals ── */
private handleArrivals(dt: number) {
if (this.arrivalQueue.length === 0) return;
this.arrivalTimer -= dt;
if (this.arrivalTimer > 0) return;
this.arrivalTimer = rand(0.9, 1.6);
const S = this.store;
const name = this.arrivalQueue.shift()!;
const occupied = new Set(
[...S.agents.values()].filter((a) => a.homeZone === "desk").map((a) => a.homeSlot),
);
const slot = DESK_SLOTS.findIndex((_, i) => !occupied.has(i));
if (slot === -1) return;
const id = `main-${name.toLowerCase()}`;
const role = S.agents.size === 0 ? "lead" : "agent";
S.spawnAgent({ id, name, role, model: AGENT_MODELS[name] ?? "claude-sonnet-5", homeSlot: slot });
S.addEvent("🚪", `${name} 進入辦公室`);
S.startWalk(id, DESK_SLOTS[slot], "desk", { arriveStatus: "idle" });
this.brains.set(id, {
phase: "arriving",
timer: 0,
toolsLeft: 0,
loungeAnchor: -1,
taskName: null,
});
}
/* ── main agent FSM ── */
private beginTask(id: string, brain: Brain) {
const S = this.store;
const task = pick(TASK_NAMES);
brain.taskName = task;
brain.phase = "thinking";
brain.timer = rand(2, 4);
S.setStatus(id, "thinking");
const agent = S.agents.get(id);
if (agent) S.addEvent("📋", `${agent.name} 接下任務「${task}`);
}
private stepAgent(id: string, brain: Brain, _dt: number) {
const S = this.store;
const agent = S.agents.get(id)!;
switch (brain.phase) {
case "arriving": {
if (!agent.movement && agent.zone === agent.homeZone) {
brain.phase = "idle";
brain.timer = rand(1.5, 5);
}
break;
}
case "idle": {
if (this.meeting?.agentIds.includes(id)) break;
if (brain.timer > 0 || agent.movement) break;
const roll = Math.random();
if (roll < 0.62) {
this.beginTask(id, brain);
} else if (roll < 0.78 && agent.zone === "desk") {
// coffee break
const usedAnchors = new Set(
[...this.brains.values()].map((b) => b.loungeAnchor).filter((i) => i >= 0),
);
const anchor = LOUNGE_ANCHORS.findIndex((_, i) => !usedAnchors.has(i));
if (anchor >= 0) {
brain.loungeAnchor = anchor;
brain.phase = "lounge_go";
S.startWalk(id, LOUNGE_ANCHORS[anchor], "lounge", { arriveStatus: "idle" });
S.addEvent("☕", `${agent.name} 去休息區倒咖啡`);
} else {
brain.timer = rand(2, 5);
}
} else if (roll < 0.83) {
brain.phase = "error";
brain.timer = rand(2.5, 3.5);
S.setStatus(id, "error");
S.addEvent("⚠️", `${agent.name} 遇到錯誤,重試中`);
} else {
brain.timer = rand(2, 6);
}
break;
}
case "thinking": {
if (brain.timer > 0) break;
brain.phase = "tooling";
brain.toolsLeft = 2 + Math.floor(Math.random() * 3);
brain.timer = 0;
S.setStatus(id, "tool_calling");
break;
}
case "tooling": {
if (brain.timer > 0) break;
if (brain.toolsLeft > 0) {
brain.toolsLeft -= 1;
brain.timer = rand(1.4, 2.6);
S.setTool(id, { name: pick(TOOLS), startedAt: S.clock });
break;
}
S.setTool(id, null);
// decide whether to delegate to subagents
const subCount = [...S.agents.values()].filter((a) => a.role === "subagent").length;
const wantSubs = Math.random() < 0.55 ? 1 + Math.floor(Math.random() * 2) : 0;
const canSpawn = Math.min(wantSubs, MAX_SUB_AGENTS - subCount);
if (canSpawn > 0) {
for (let i = 0; i < canSpawn; i++) this.spawnSubagent(id);
brain.phase = "waiting_subs";
S.setStatus(id, "thinking");
} else {
this.finishTask(id, brain);
}
break;
}
case "waiting_subs": {
if (agent.childIds.length === 0) {
this.finishTask(id, brain);
}
break;
}
case "speaking": {
if (brain.timer > 0) break;
S.setSpeech(id, null);
S.setStatus(id, "idle");
brain.phase = "idle";
brain.timer = rand(3, 8);
break;
}
case "error": {
if (brain.timer > 0) break;
S.setStatus(id, "idle");
brain.phase = "idle";
brain.timer = rand(2, 5);
break;
}
case "lounge_go": {
if (!agent.movement && agent.zone === "lounge") {
brain.phase = "lounge_stay";
brain.timer = rand(5, 10);
}
break;
}
case "lounge_stay": {
if (brain.timer > 0) break;
brain.phase = "lounge_back";
brain.loungeAnchor = -1;
S.walkHome(id);
break;
}
case "lounge_back": {
if (!agent.movement && agent.zone === agent.homeZone) {
brain.phase = "idle";
brain.timer = rand(2, 6);
}
break;
}
case "meeting":
// handled by the meeting scheduler
break;
/* ── subagent FSM ── */
case "sub_arriving": {
if (!agent.movement && agent.zone === "hotDesk") {
brain.phase = "sub_working";
brain.toolsLeft = 3 + Math.floor(Math.random() * 4);
brain.timer = 0;
S.setStatus(id, "tool_calling");
}
break;
}
case "sub_working": {
if (brain.timer > 0) break;
if (brain.toolsLeft > 0) {
brain.toolsLeft -= 1;
brain.timer = rand(1.2, 2.2);
S.setTool(id, { name: pick(SUB_TOOLS), startedAt: S.clock });
break;
}
S.setTool(id, null);
brain.phase = "sub_reporting";
brain.timer = rand(1.8, 2.6);
S.setStatus(id, "speaking");
S.setSpeech(id, pick(SUB_DONE_PHRASES));
break;
}
case "sub_reporting": {
if (brain.timer > 0) break;
S.setSpeech(id, null);
S.addEvent("↩️", `${agent.name} 回報完畢,離開辦公室`);
if (agent.parentId) S.removeLink(agent.parentId, id);
brain.phase = "sub_leaving";
S.startWalk(id, { ...ENTRANCE }, "corridor", { despawnOnArrive: true });
break;
}
case "sub_leaving":
break;
}
}
private finishTask(id: string, brain: Brain) {
const S = this.store;
const agent = S.agents.get(id);
if (!agent) return;
brain.phase = "speaking";
brain.timer = rand(2.5, 4);
S.setStatus(id, "speaking");
S.setSpeech(id, pick(DONE_PHRASES));
S.addEvent("✅", `${agent.name} 完成「${brain.taskName ?? "任務"}`);
brain.taskName = null;
}
private spawnSubagent(parentId: string) {
const S = this.store;
const parent = S.agents.get(parentId);
if (!parent) return;
const occupied = new Set(
[...S.agents.values()].filter((a) => a.homeZone === "hotDesk").map((a) => a.homeSlot),
);
const slot = HOT_DESK_SLOTS.findIndex((_, i) => !occupied.has(i));
if (slot === -1) return;
const kind = pick(SUB_AGENT_KINDS);
const id = `sub-${kind.toLowerCase()}-${subSeq++}`;
const name = `${kind}-${subSeq}`;
S.spawnAgent({ id, name, role: "subagent", model: "claude-haiku-4-5", homeSlot: slot, parentId });
S.addEvent("✨", `${parent.name} 派出 subagent ${name}`);
S.startWalk(id, HOT_DESK_SLOTS[slot], "hotDesk", { arriveStatus: "tool_calling" });
S.addLink({ sourceId: parentId, targetId: id, strength: 0.6, kind: "spawn" });
this.brains.set(id, {
phase: "sub_arriving",
timer: 0,
toolsLeft: 0,
loungeAnchor: -1,
taskName: null,
});
}
/* ── meetings ── */
private updateMeetingScheduler(dt: number) {
const S = this.store;
if (this.meeting) {
const m = this.meeting;
m.timer -= dt;
if (!m.gathered) {
const allSeated = m.agentIds.every((id) => {
const a = S.agents.get(id);
return a && !a.movement && a.zone === "meeting";
});
if (allSeated) {
m.gathered = true;
m.speakTimer = 0;
}
} else {
m.speakTimer -= dt;
if (m.speakTimer <= 0) {
// rotate speaker
const prev = m.agentIds[m.speakerIdx % m.agentIds.length];
S.setSpeech(prev, null);
S.setStatus(prev, "idle");
m.speakerIdx += 1;
const next = m.agentIds[m.speakerIdx % m.agentIds.length];
S.setStatus(next, "speaking");
S.setSpeech(next, pick(MEETING_PHRASES));
m.speakTimer = rand(2, 3.4);
}
}
if (m.timer <= 0 && m.gathered) {
for (const id of m.agentIds) {
S.setSpeech(id, null);
const brain = this.brains.get(id);
if (brain) {
brain.phase = "arriving"; // reuse: wait until seated home, then idle
brain.timer = 0;
}
}
S.addEvent("🏁", "會議結束,各自回工位");
S.endMeeting(m.id);
this.meeting = null;
this.meetingCooldown = rand(30, 55);
}
return;
}
this.meetingCooldown -= dt;
if (this.meetingCooldown > 0) return;
// candidates: settled main agents that are idle at their desk
const candidates = [...S.agents.values()].filter((a) => {
const brain = this.brains.get(a.id);
return (
a.role !== "subagent" &&
brain?.phase === "idle" &&
!a.movement &&
a.zone === "desk"
);
});
if (candidates.length < 2) {
this.meetingCooldown = 6;
return;
}
const count = Math.min(candidates.length, 2 + Math.floor(Math.random() * 2));
const chosen = candidates.slice(0, count).map((a) => a.id);
const id = `meeting-${meetingSeq++}`;
for (const agentId of chosen) {
const brain = this.brains.get(agentId)!;
brain.phase = "meeting";
}
S.addEvent("🤝", `${chosen.length} 位 agent 前往會議區討論`);
S.startMeeting(id, chosen);
this.meeting = { id, agentIds: chosen, timer: rand(12, 18), speakTimer: 0, speakerIdx: -1, gathered: false };
}
}
+44
View File
@@ -0,0 +1,44 @@
import { Director } from "./director";
import { useOfficeStore } from "@/store/office-store";
import { liveConnect, liveDisconnect } from "@/gateway/live";
let director = new Director();
let running = false;
export function getDirector() {
return director;
}
/** Switch between the scripted simulation and the live transcript feed. */
export function setMode(mode: "sim" | "live") {
const S = useOfficeStore.getState();
if (S.mode === mode) return;
S.clearWorld();
S.setMode(mode);
if (mode === "sim") {
liveDisconnect();
director = new Director();
} else {
liveConnect();
}
}
/** Single rAF loop: advances the store clock/movements, then the director's FSMs. */
export function startRuntime() {
if (running) return;
running = true;
let last = performance.now();
const frame = (now: number) => {
const raw = Math.min((now - last) / 1000, 0.1);
last = now;
const S = useOfficeStore.getState();
if (!S.paused) {
const dt = raw * S.speed;
S.tick(dt);
if (S.mode === "sim") director.update(dt);
}
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
+320
View File
@@ -0,0 +1,320 @@
import { create } from "zustand";
import { DESK_SLOTS, HOT_DESK_SLOTS, meetingSeats } from "@/lib/positions";
import { interpolatePath, planWalkPath, walkDuration } from "@/lib/movement";
import { ENTRANCE, MEETING_CENTER } from "@/lib/constants";
import type {
AgentStatus,
AgentZone,
CollaborationLink,
Meeting,
OfficeEvent,
Point,
ToolCall,
VisualAgent,
} from "@/lib/types";
interface OfficeState {
agents: Map<string, VisualAgent>;
links: CollaborationLink[];
meetings: Meeting[];
events: OfficeEvent[];
selectedAgentId: string | null;
theme: "light" | "dark";
paused: boolean;
speed: number;
/** data source: scripted simulation or live Claude Code transcripts */
mode: "sim" | "live";
/** side panel visibility (collapse to maximise the office view) */
panelOpen: boolean;
/** simulation clock, seconds */
clock: number;
selectAgent: (id: string | null) => void;
setTheme: (t: "light" | "dark") => void;
setPaused: (p: boolean) => void;
setSpeed: (s: number) => void;
setMode: (m: "sim" | "live") => void;
togglePanel: () => void;
clearWorld: () => void;
addEvent: (icon: string, text: string) => void;
spawnAgent: (a: {
id: string;
name: string;
role: VisualAgent["role"];
model: string;
homeSlot: number;
parentId?: string;
}) => void;
removeAgent: (id: string) => void;
setStatus: (id: string, status: AgentStatus) => void;
setTool: (id: string, tool: ToolCall | null) => void;
setSpeech: (id: string, text: string | null) => void;
startWalk: (
id: string,
to: Point,
toZone: AgentZone,
opts?: { arriveStatus?: AgentStatus; despawnOnArrive?: boolean },
) => void;
walkHome: (id: string, arriveStatus?: AgentStatus) => void;
addLink: (link: CollaborationLink) => void;
removeLink: (sourceId: string, targetId: string) => void;
startMeeting: (id: string, agentIds: string[]) => void;
endMeeting: (id: string) => void;
/** advance movements + clock; called once per frame with scaled dt */
tick: (dt: number) => void;
}
let eventSeq = 0;
export const useOfficeStore = create<OfficeState>()((set, get) => ({
agents: new Map(),
links: [],
meetings: [],
events: [],
selectedAgentId: null,
theme: "light",
paused: false,
speed: 1,
mode: "sim",
panelOpen: true,
clock: 0,
selectAgent: (id) => set({ selectedAgentId: id }),
setTheme: (t) => set({ theme: t }),
setPaused: (p) => set({ paused: p }),
setSpeed: (s) => set({ speed: s }),
setMode: (m) => set({ mode: m }),
togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })),
clearWorld: () =>
set({ agents: new Map(), links: [], meetings: [], events: [], selectedAgentId: null }),
addEvent: (icon, text) =>
set((state) => ({
events: [{ at: eventSeq++, text, icon }, ...state.events].slice(0, 60),
})),
spawnAgent: ({ id, name, role, model, homeSlot, parentId }) =>
set((state) => {
const isSub = role === "subagent";
const slot = isSub ? HOT_DESK_SLOTS[homeSlot] : DESK_SLOTS[homeSlot];
const agent: VisualAgent = {
id,
name,
role,
model,
status: "spawning",
position: { ...ENTRANCE },
zone: "corridor",
homePosition: { x: slot.x, y: slot.y },
homeZone: isSub ? "hotDesk" : "desk",
homeSlot,
currentTool: null,
speech: null,
movement: null,
parentId: parentId ?? null,
childIds: [],
toolCallCount: 0,
toolHistory: [],
spawnedAt: state.clock,
};
const agents = new Map(state.agents);
agents.set(id, agent);
if (parentId) {
const parent = agents.get(parentId);
if (parent) agents.set(parentId, { ...parent, childIds: [...parent.childIds, id] });
}
return { agents };
}),
removeAgent: (id) =>
set((state) => {
const agents = new Map(state.agents);
const agent = agents.get(id);
agents.delete(id);
if (agent?.parentId) {
const parent = agents.get(agent.parentId);
if (parent) {
agents.set(agent.parentId, {
...parent,
childIds: parent.childIds.filter((c) => c !== id),
});
}
}
return {
agents,
links: state.links.filter((l) => l.sourceId !== id && l.targetId !== id),
selectedAgentId: state.selectedAgentId === id ? null : state.selectedAgentId,
};
}),
setStatus: (id, status) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent || agent.status === status) return {};
const agents = new Map(state.agents);
agents.set(id, {
...agent,
status,
speech: status === "speaking" ? agent.speech : null,
currentTool: status === "tool_calling" ? agent.currentTool : null,
});
return { agents };
}),
setTool: (id, tool) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const agents = new Map(state.agents);
agents.set(id, {
...agent,
currentTool: tool,
toolCallCount: tool ? agent.toolCallCount + 1 : agent.toolCallCount,
toolHistory: tool ? [tool.name, ...agent.toolHistory].slice(0, 12) : agent.toolHistory,
});
return { agents };
}),
setSpeech: (id, text) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const agents = new Map(state.agents);
agents.set(id, { ...agent, speech: text });
return { agents };
}),
startWalk: (id, to, toZone, opts) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const from = agent.movement
? interpolatePath(agent.movement.path, agent.movement.elapsed / agent.movement.duration)
: agent.position;
const path = planWalkPath(from, to, agent.zone, toZone);
const agents = new Map(state.agents);
agents.set(id, {
...agent,
position: { ...from },
movement: {
path,
duration: walkDuration(path),
elapsed: 0,
toZone,
arriveStatus: opts?.arriveStatus,
despawnOnArrive: opts?.despawnOnArrive,
},
});
return { agents };
}),
walkHome: (id, arriveStatus = "idle") => {
const agent = get().agents.get(id);
if (!agent) return;
get().startWalk(id, agent.homePosition, agent.homeZone, { arriveStatus });
},
addLink: (link) =>
set((state) => {
const others = state.links.filter(
(l) => !(l.sourceId === link.sourceId && l.targetId === link.targetId),
);
return { links: [...others, link] };
}),
removeLink: (sourceId, targetId) =>
set((state) => ({
links: state.links.filter((l) => !(l.sourceId === sourceId && l.targetId === targetId)),
})),
startMeeting: (id, agentIds) => {
set((state) => ({
meetings: [...state.meetings, { id, agentIds, center: { ...MEETING_CENTER } }],
}));
const seats = meetingSeats(agentIds.length);
agentIds.forEach((agentId, i) => {
get().startWalk(agentId, seats[i], "meeting", { arriveStatus: "idle" });
});
// pairwise meeting links
for (let i = 0; i < agentIds.length; i++) {
for (let j = i + 1; j < agentIds.length; j++) {
get().addLink({ sourceId: agentIds[i], targetId: agentIds[j], strength: 0.85, kind: "meeting" });
}
}
},
endMeeting: (id) => {
const meeting = get().meetings.find((m) => m.id === id);
if (!meeting) return;
set((state) => ({ meetings: state.meetings.filter((m) => m.id !== id) }));
const ids = meeting.agentIds;
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
get().removeLink(ids[i], ids[j]);
}
}
ids.forEach((agentId) => {
const agent = get().agents.get(agentId);
if (agent) get().walkHome(agentId);
});
},
tick: (dt) =>
set((state) => {
let agents: Map<string, VisualAgent> | null = null;
const toRemove: string[] = [];
for (const [id, agent] of state.agents) {
if (!agent.movement) continue;
if (!agents) agents = new Map(state.agents);
const elapsed = agent.movement.elapsed + dt;
if (elapsed >= agent.movement.duration) {
const end = agent.movement.path[agent.movement.path.length - 1];
if (agent.movement.despawnOnArrive) {
toRemove.push(id);
continue;
}
agents.set(id, {
...agent,
position: { ...end },
zone: agent.movement.toZone,
status: agent.movement.arriveStatus ?? agent.status,
movement: null,
});
} else {
const pos = interpolatePath(agent.movement.path, elapsed / agent.movement.duration);
agents.set(id, {
...agent,
position: pos,
movement: { ...agent.movement, elapsed },
});
}
}
if (toRemove.length > 0 && agents) {
for (const id of toRemove) agents.delete(id);
}
return {
clock: state.clock + dt,
...(agents ? { agents } : {}),
...(toRemove.length > 0
? {
links: state.links.filter(
(l) => !toRemove.includes(l.sourceId) && !toRemove.includes(l.targetId),
),
selectedAgentId:
state.selectedAgentId && toRemove.includes(state.selectedAgentId)
? null
: state.selectedAgentId,
}
: {}),
};
}),
}));
+653
View File
@@ -0,0 +1,653 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--terracotta: #10a37f;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
font-family: "Manrope", system-ui, sans-serif;
transition: background-color 0.3s, color 0.3s;
}
.app.light {
--bg: #ebe7db;
--bg-panel: #f7f4ec;
--bg-card: #fffdf8;
--text: #26221c;
--text-dim: #8a8272;
--border: rgba(120, 90, 60, 0.16);
background: var(--bg);
color: var(--text);
}
.app.dark {
--bg: #131009;
--bg-panel: #1c1812;
--bg-card: #242019;
--text: #ece7dc;
--text-dim: #8f8672;
--border: rgba(255, 255, 255, 0.09);
background: var(--bg);
color: var(--text);
}
/* ── header ── */
.header {
display: flex;
align-items: center;
gap: 24px;
padding: 10px 20px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
}
.header-brand {
display: flex;
align-items: center;
gap: 12px;
}
.header-mark {
font-family: "JetBrains Mono", monospace;
font-size: 24px;
font-weight: 700;
color: var(--terracotta);
line-height: 1;
display: inline-block;
}
.header-cursor {
animation: cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes cursor-blink {
to {
visibility: hidden;
}
}
.header-brand h1 {
font-family: "Sora", system-ui, sans-serif;
font-size: 21px;
font-weight: 600;
letter-spacing: 0.01em;
}
.header-brand p {
font-size: 11px;
color: var(--text-dim);
margin-top: 1px;
}
.header-legend {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-left: auto;
}
.legend-chip {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-dim);
}
.legend-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.header-controls {
display: flex;
align-items: center;
gap: 8px;
}
.agent-count {
font-size: 12px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.btn {
font-family: inherit;
font-size: 13px;
font-weight: 600;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
}
.btn:hover {
border-color: var(--terracotta);
}
.btn:active {
transform: scale(0.96);
}
.mode-btn.live-on {
background: #d14343;
color: #fff;
border-color: transparent;
animation: live-pulse 1.6s ease-in-out infinite;
}
@keyframes live-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(209, 67, 67, 0.4);
}
50% {
box-shadow: 0 0 0 5px rgba(209, 67, 67, 0);
}
}
/* ── layout ── */
.main-row {
display: flex;
flex: 1;
min-height: 0;
}
.floorplan-wrap {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
}
.floorplan-svg {
width: 100%;
height: 100%;
}
/* ── side panel ── */
.side-panel {
width: 300px;
flex-shrink: 0;
border-left: 1px solid var(--border);
background: var(--bg-panel);
display: flex;
flex-direction: column;
overflow: hidden;
}
.agent-card {
padding: 16px;
border-bottom: 1px solid var(--border);
animation: card-in 0.25s ease-out;
}
.agent-card-head {
display: flex;
gap: 10px;
align-items: center;
position: relative;
}
.agent-card-head svg {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
flex-shrink: 0;
}
.agent-card-head h2 {
font-family: "Sora", system-ui, sans-serif;
font-size: 19px;
font-weight: 600;
}
.agent-card-head .mark {
color: var(--terracotta);
}
.role-badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--terracotta);
background: color-mix(in srgb, var(--terracotta) 14%, transparent);
border-radius: 5px;
padding: 1px 6px;
margin: 3px 6px 0 0;
}
.model-tag {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: var(--text-dim);
}
.close-btn {
position: absolute;
top: 0;
right: 0;
border: none;
background: none;
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
}
.agent-stat-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.status-chip {
font-size: 12px;
font-weight: 700;
border-radius: 7px;
padding: 3px 9px;
}
.tool-now {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: var(--terracotta);
}
.agent-facts {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 12px;
}
.agent-facts dt {
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.04em;
}
.agent-facts dd {
font-size: 13px;
font-weight: 600;
margin-top: 1px;
}
.link-btn {
border: none;
background: none;
color: var(--terracotta);
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-right: 8px;
text-decoration: underline;
text-underline-offset: 2px;
}
.tool-history {
margin-top: 12px;
}
.tool-history h3,
.event-feed h3 {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 6px;
}
.tool-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tool-chips code {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 5px;
padding: 1px 6px;
}
.assign-btn {
width: 100%;
margin-top: 14px;
background: var(--terracotta);
color: #fff;
border-color: transparent;
}
.assign-btn:hover {
filter: brightness(1.06);
border-color: transparent;
}
.panel-hint {
padding: 20px 16px;
border-bottom: 1px solid var(--border);
color: var(--text-dim);
font-size: 12px;
}
.event-feed {
flex: 1;
overflow-y: auto;
padding: 14px 16px;
}
.event-feed ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 7px;
}
.event-feed li {
font-size: 12px;
line-height: 1.45;
display: flex;
gap: 7px;
animation: card-in 0.25s ease-out;
}
.event-icon {
flex-shrink: 0;
}
/* ── keyframes: pawn ── */
@keyframes pawn-breathe {
0%,
100% {
transform: scaleY(1);
}
50% {
transform: scaleY(1.035);
}
}
@keyframes pawn-swing {
from {
transform: rotate(-24deg);
}
to {
transform: rotate(24deg);
}
}
@keyframes pawn-type {
0%,
100% {
transform: rotate(0deg) translateY(0);
}
50% {
transform: rotate(4deg) translateY(1.4px);
}
}
@keyframes pawn-blink {
0%,
93%,
100% {
transform: scaleY(1);
}
95%,
97% {
transform: scaleY(0.08);
}
}
@keyframes pawn-shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-1.6px) rotate(-2deg);
}
75% {
transform: translateX(1.6px) rotate(2deg);
}
}
@keyframes pawn-mouth {
0%,
100% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.4);
}
}
/* ── keyframes: emotes ── */
@keyframes emote-pop {
from {
transform: scale(0);
}
70% {
transform: scale(1.15);
}
to {
transform: scale(1);
}
}
@keyframes emote-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-2.5px);
}
}
@keyframes emote-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes thinking-dots {
0%,
100% {
opacity: 0.25;
}
50% {
opacity: 1;
}
}
@keyframes gear-spin {
to {
transform: rotate(360deg);
}
}
@keyframes zzz-float {
0% {
opacity: 0;
transform: translate(0, 0);
}
30% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(5px, -9px);
}
}
@keyframes sparkle-twinkle {
0%,
100% {
opacity: 0.2;
transform: scale(0.6) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1.1) rotate(45deg);
}
}
@keyframes selection-ring {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.12);
opacity: 0.7;
}
}
@keyframes agent-spawn {
from {
transform: scale(0.2);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes dash-flow {
to {
stroke-dashoffset: -32;
}
}
@keyframes mark-spin {
to {
transform: rotate(360deg);
}
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── RWD ──
Portrait / narrow screens: stack vertically. The office locks to its
1200:700 aspect ratio at full width (maximum size, no letterboxing),
and the panel moves below it. */
@media (orientation: portrait) {
.app {
height: 100dvh;
}
.main-row {
flex-direction: column;
overflow-y: auto;
}
.floorplan-wrap {
flex: none;
width: 100%;
aspect-ratio: 1200 / 700;
padding: 2px;
}
.side-panel {
width: 100%;
flex: 1 0 auto;
border-left: none;
border-top: 1px solid var(--border);
}
.event-feed {
max-height: 40vh;
}
}
/* Narrow landscape: keep the row, slim the panel */
@media (max-width: 900px) and (orientation: landscape) {
.side-panel {
width: 232px;
}
}
/* Compact chrome on any narrow screen */
@media (max-width: 900px) {
.header {
gap: 10px;
padding: 6px 10px;
flex-wrap: wrap;
}
.header-legend,
.header-brand p,
.agent-count {
display: none;
}
.header-mark {
font-size: 22px;
}
.header-brand h1 {
font-size: 17px;
}
.header-controls {
margin-left: auto;
gap: 5px;
}
.btn {
padding: 5px 9px;
font-size: 12px;
}
}
/* Very tight screens: shave header further */
@media (max-width: 480px) {
.header-brand h1 {
font-size: 15px;
}
.btn {
padding: 4px 7px;
font-size: 11px;
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});