commit 7d9d4771669f70f95df4c63ac385cb70c73e1929 Author: JianMiau Date: Mon Jul 20 14:02:32 2026 +0800 建立 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b518b92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.local +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..844f24c --- /dev/null +++ b/README.md @@ -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) +``` diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..604e8cf --- /dev/null +++ b/ecosystem.config.cjs @@ -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, + }, + ], +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..d66da66 --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + Codex Office — 虛擬辦公室 + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d61cbce --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1859 @@ +{ + "name": "claude-office", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claude-office", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a0dc26d --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/server/serve.mjs b/server/serve.mjs new file mode 100644 index 0000000..4642960 --- /dev/null +++ b/server/serve.mjs @@ -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/)`); +}); diff --git a/server/watch.mjs b/server/watch.mjs new file mode 100644 index 0000000..f604b4a --- /dev/null +++ b/server/watch.mjs @@ -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); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..b9083ca --- /dev/null +++ b/src/App.tsx @@ -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 ( +
+ +
+ + {panelOpen && } +
+
+ ); +} diff --git a/src/components/AgentAvatar.tsx b/src/components/AgentAvatar.tsx new file mode 100644 index 0000000..6b9144d --- /dev/null +++ b/src/components/AgentAvatar.tsx @@ -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 ( + { + e.stopPropagation(); + selectAgent(agent.id); + }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {isSelected && ( + + + + + )} + {hovered && !isSelected && ( + + )} + + + + + + + + {!isWalking && ( + + {agent.status === "thinking" && } + {agent.status === "speaking" && } + {agent.status === "tool_calling" && agent.currentTool && ( + + )} + {agent.status === "error" && } + {agent.status === "offline" && } + {agent.status === "spawning" && } + + )} + + {/* name tag */} + +
+ + + {agent.role === "lead" && } + {displayName} + {agent.role === "subagent" && ( + + S + + )} + +
+
+ + {hovered && ( + +
+ + {agent.name} · {STATUS_LABELS[agent.status]} + +
+
+ )} +
+ ); +}); + +const spawnStyle: React.CSSProperties = { + animation: "agent-spawn 0.5s ease-out forwards", + transformBox: "fill-box", + transformOrigin: "center bottom", +}; diff --git a/src/components/ConnectionLine.tsx b/src/components/ConnectionLine.tsx new file mode 100644 index 0000000..b6695e1 --- /dev/null +++ b/src/components/ConnectionLine.tsx @@ -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 ( + + + + + ); +} diff --git a/src/components/Emotes.tsx b/src/components/Emotes.tsx new file mode 100644 index 0000000..3a45d41 --- /dev/null +++ b/src/components/Emotes.tsx @@ -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 ( + + + + + + + + + {[0, 1, 2].map((i) => ( + + ))} + + + ); +}); + +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 ( + + + +
+
+ {snippet || "…"} +
+
+
+
+ ); +}); + +export const ToolEmote = memo(function ToolEmote({ toolName, isDark }: { toolName: string; isDark: boolean }) { + const bg = isDark ? "#0d2b21" : "#eafaf3"; + return ( + + + + + + + + + + + {toolName.length > 11 ? `${toolName.slice(0, 10)}…` : toolName} + + + + ); +}); + +function Gear() { + const teeth = Array.from({ length: 8 }, (_, i) => ( + + )); + return ( + + {teeth} + + + + ); +} + +export const ErrorEmote = memo(function ErrorEmote() { + return ( + + + + + + + + ); +}); + +export const ZzzEmote = memo(function ZzzEmote({ isDark }: { isDark: boolean }) { + const color = isDark ? "#9a9284" : "#8a8578"; + return ( + + {[0, 1, 2].map((i) => ( + + z + + ))} + + ); +}); + +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 ( + + {sparkles.map((sp, i) => ( + + + + ))} + + ); +}); diff --git a/src/components/FloorPlan.tsx b/src/components/FloorPlan.tsx new file mode 100644 index 0000000..0ad7483 --- /dev/null +++ b/src/components/FloorPlan.tsx @@ -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 ( +
+ selectAgent(null)} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* building shell */} + + + + + {/* zone floors */} + {(Object.keys(ZONES) as Array).map((key) => ( + + ))} + + + + + {/* zone labels */} + {(Object.keys(ZONES) as Array).map((key) => ( + + {ZONE_LABELS[key].zh} · {ZONE_LABELS[key].en} + + ))} + + {/* desk zone: 6 fixed workstations */} + {DESK_SLOTS.map((slot, i) => ( + + ))} + + {/* hot desk zone: 8 subagent desks */} + {HOT_DESK_SLOTS.map((slot, i) => ( + + ))} + + {/* meeting zone */} + + + {/* lounge */} + + + + {/* collaboration links */} + {links.map((link) => { + const source = agentById(link.sourceId); + const target = agentById(link.targetId); + if (!source || !target) return null; + return ( + + ); + })} + + {/* agents, painter's order */} + {agentList.map((agent) => ( + + ))} + +
+ ); +} + +/* ── pieces ── */ + +function DeskUnit({ + x, + y, + isDark, + agents, + zone, + slot, +}: { + x: number; + y: number; + isDark: boolean; + agents: Map; + 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 ( + + + + + ); +} + +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 ( + + {/* rug */} + + + + + {chairPts.map((p, i) => ( + + ))} + + + ); +} + +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 ( + + + + + + + ); +} + +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 ( + + {walls.map((w, i) => ( + + ))} + + ); +} + +/** 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 ( + + {doors.map((d, i) => { + const half = doorWidth / 2; + return ( + + + + + ); + })} + + ); +} + +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 ( + + + + + + + {/* logo wall */} + + + + >_ Codex Office + + + {/* reception desk */} + + + + + + + + + ); +} + +function EntranceDoor({ colors }: { colors: OfficeColors }) { + const doorCX = ENTRANCE.x; + const doorY = OFFICE.y + OFFICE.height; + const half = 36; + + return ( + + + + + + + + + ENTRANCE + + + ); +} diff --git a/src/components/HeaderBar.tsx b/src/components/HeaderBar.tsx new file mode 100644 index 0000000..03a0940 --- /dev/null +++ b/src/components/HeaderBar.tsx @@ -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 ( +
+
+ + >_ + +
+

Codex Office

+

multi-agent 虛擬辦公室 · {isLive ? "實況模式" : "模擬模式"}

+
+
+ +
+ {LEGEND.map((s) => ( + + + {STATUS_LABELS[s]} + + ))} +
+ +
+ {agentCount} agents + + {!isLive && ( + <> + + + + )} + + + +
+
+ ); +} diff --git a/src/components/Pawn.tsx b/src/components/Pawn.tsx new file mode 100644 index 0000000..1cd6687 --- /dev/null +++ b/src/components/Pawn.tsx @@ -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 ( + + + + + {/* legs */} + + + + {/* torso — breathes when idle */} + + + + + + {/* arms */} + + + + {/* head */} + + + + + + + {isTalking ? ( + + ) : ( + + )} + + + + ); +}); + +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 ( + + + + + + + ); +} + +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 ( + + + + + + + ); +} + +function Hair({ style, color }: { style: PawnAppearance["hairStyle"]; color: string }) { + switch (style) { + case "short": + return ( + + ); + case "spiky": + return ( + + + + + + + ); + case "side-part": + return ( + + + + + ); + case "curly": + return ( + + + + + + + + ); + case "buzz": + return ( + + ); + case "bob": + return ( + + ); + default: + return null; + } +} + +function Eyes({ style }: { style: PawnAppearance["eyeStyle"] }) { + const ey = -20; + const gap = 3.2; + switch (style) { + case "dot": + return ( + + + + + ); + case "line": + return ( + + + + + ); + case "wide": + return ( + + + + + + + ); + default: + return null; + } +} diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx new file mode 100644 index 0000000..e7308c1 --- /dev/null +++ b/src/components/SidePanel.tsx @@ -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 ( + + ); +} diff --git a/src/components/furniture.tsx b/src/components/furniture.tsx new file mode 100644 index 0000000..c6bf47a --- /dev/null +++ b/src/components/furniture.tsx @@ -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 ( + + + + {/* laptop facing the seat (north side) */} + + + {active && } + + {/* mug */} + + + {/* notepad */} + + + ); +}); + +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 ( + + + + + ); +}); + +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 ( + + + + {/* Claude mark inlaid in the table */} + + >_ + + + ); +}); + +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 ( + + + + + + + ); +}); + +export const Plant = memo(function Plant({ x, y }: { x: number; y: number }) { + return ( + + + + + + + ); +}); + +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 ( + + + + + + + ); +}); + +/** 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 ( + + + + + + + {/* steam */} + {[0, 1].map((i) => ( + + ))} + + ); +}); diff --git a/src/gateway/live.ts b/src/gateway/live.ts new file mode 100644 index 0000000..a1ddb49 --- /dev/null +++ b/src/gateway/live.ts @@ -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 | null = null; +const lastEventAt = new Map(); +const subsByToolId = new Map(); + +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; + 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(); +} diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts new file mode 100644 index 0000000..8fd422b --- /dev/null +++ b/src/lib/appearance.ts @@ -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})`; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..936068b --- /dev/null +++ b/src/lib/constants.ts @@ -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, 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, { 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 = { + idle: "#7b8fa1", + thinking: "#4f86d9", + tool_calling: "#10a37f", + speaking: "#a06cd5", + spawning: "#2fb3c9", + error: "#d64545", + offline: "#7d7d75", +}; + +export const STATUS_LABELS: Record = { + idle: "待命", + thinking: "思考中", + tool_calling: "工具調用", + speaking: "回覆中", + spawning: "生成中", + error: "錯誤", + offline: "離線", +}; + +export const MAX_MAIN_AGENTS = 6; +export const MAX_SUB_AGENTS = 8; diff --git a/src/lib/movement.ts b/src/lib/movement.ts new file mode 100644 index 0000000..c1727a2 --- /dev/null +++ b/src/lib/movement.ts @@ -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] }; +} diff --git a/src/lib/phrases.ts b/src/lib/phrases.ts new file mode 100644 index 0000000..560e4e8 --- /dev/null +++ b/src/lib/phrases.ts @@ -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 = { + 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(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); +} diff --git a/src/lib/positions.ts b/src/lib/positions.ts new file mode 100644 index 0000000..b72179b --- /dev/null +++ b/src/lib/positions.ts @@ -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), + }; + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..5548ad4 --- /dev/null +++ b/src/lib/types.ts @@ -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; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..693ac2e --- /dev/null +++ b/src/main.tsx @@ -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( + + + , +); diff --git a/src/sim/director.ts b/src/sim/director.ts new file mode 100644 index 0000000..d68a170 --- /dev/null +++ b/src/sim/director.ts @@ -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(); + 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 }; + } +} diff --git a/src/sim/runtime.ts b/src/sim/runtime.ts new file mode 100644 index 0000000..118a645 --- /dev/null +++ b/src/sim/runtime.ts @@ -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); +} diff --git a/src/store/office-store.ts b/src/store/office-store.ts new file mode 100644 index 0000000..ee5075a --- /dev/null +++ b/src/store/office-store.ts @@ -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; + 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()((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 | 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, + } + : {}), + }; + }), +})); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..cdc2096 --- /dev/null +++ b/src/styles.css @@ -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; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d6fa0c6 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..a8b3919 --- /dev/null +++ b/vite.config.ts @@ -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)), + }, + }, +});