建立 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:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
@@ -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>
|
||||
Generated
+1859
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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/)`);
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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">>_</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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
><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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
>_
|
||||
</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>
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user