LIVE 模式改為雙向對話(codex exec --json + resume 接力)
摘要: 與 claude-office 同功能:側邊面板聊天室,每則訊息 spawn codex exec --json,捕捉 thread_id 後下一則以 codex exec resume 接力。 根本原因: 單向 /task 看不到回覆也無法追問。 影響: watcher 新增 POST /chat 與 chat_* SSE 事件;已實測一輪對話 (thread.started → agent_message → turn.completed)正確回傳。 修法: - watch.mjs:launchChat 解析 --json 事件流(thread_id / agent_message / turn.completed),chatThreads 記憶 thread - 前端與 claude-office 同步(ChatPanel / chats store / chat_* 對映) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,17 @@ npm run watch # http://localhost:5181/events (SSE)
|
|||||||
|
|
||||||
注意:監看伺服器會讀取本機的 Codex 對話記錄,**只綁 127.0.0.1、不要對外開放**。
|
注意:監看伺服器會讀取本機的 Codex 對話記錄,**只綁 127.0.0.1、不要對外開放**。
|
||||||
|
|
||||||
### 在辦公室下任務(LIVE 模式)
|
### 在辦公室對話(LIVE 模式)
|
||||||
|
|
||||||
|
點選專案 agent 後,側邊面板是一個**雙向聊天室**:每則訊息 spawn 一次
|
||||||
|
`codex exec --json`,從輸出捕捉 `thread_id`,下一則自動 `codex exec resume`
|
||||||
|
接力。回覆同時顯示在聊天串和小人頭上的對話氣泡。
|
||||||
|
|
||||||
|
- 「接續專案最近 session」:第一句改從該專案最近的 rollout 接續
|
||||||
|
- 「🔄 新對話」:清空 office 對話串(會終止進行中的執行)
|
||||||
|
- 事件:`POST /chat` → SSE 廣播 `chat_start` / `chat_text` / `chat_done`
|
||||||
|
|
||||||
|
### 在辦公室下任務(API)
|
||||||
|
|
||||||
點選任一專案 agent,側邊面板會出現任務輸入框 —「🚀 派發任務」會透過
|
點選任一專案 agent,側邊面板會出現任務輸入框 —「🚀 派發任務」會透過
|
||||||
`POST /task` 讓監看伺服器在該專案目錄 spawn 一個 headless session:
|
`POST /task` 讓監看伺服器在該專案目錄 spawn 一個 headless session:
|
||||||
|
|||||||
@@ -294,6 +294,149 @@ function launchTask(slug, prompt, resume) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── two-way chat: one office thread per project, stitched with `codex exec resume` ── */
|
||||||
|
|
||||||
|
/** slug → { threadId, child } */
|
||||||
|
const chatThreads = new Map();
|
||||||
|
|
||||||
|
function launchChat(slug, message, fromProject) {
|
||||||
|
const proj = projects.get(slug);
|
||||||
|
if (!proj?.cwd) return { ok: false, error: "unknown project" };
|
||||||
|
|
||||||
|
let thread = chatThreads.get(slug);
|
||||||
|
if (!thread) {
|
||||||
|
thread = { threadId: null, child: null };
|
||||||
|
chatThreads.set(slug, thread);
|
||||||
|
}
|
||||||
|
if (thread.child) return { ok: false, error: "busy" };
|
||||||
|
|
||||||
|
let resumeId = thread.threadId;
|
||||||
|
if (!resumeId && fromProject) {
|
||||||
|
const sid = newestSessionId(slug);
|
||||||
|
if (sid) resumeId = sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ["exec"];
|
||||||
|
if (resumeId) args.push("resume", resumeId);
|
||||||
|
args.push("--json", "--full-auto", "--skip-git-repo-check", "-");
|
||||||
|
|
||||||
|
// Message goes through stdin ("-") — never through shell arguments.
|
||||||
|
const child = spawn("codex", args, {
|
||||||
|
cwd: proj.cwd,
|
||||||
|
shell: true,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
thread.child = child;
|
||||||
|
|
||||||
|
let stderrTail = "";
|
||||||
|
let stdoutRemainder = "";
|
||||||
|
let sawDone = false;
|
||||||
|
let lastText = "";
|
||||||
|
|
||||||
|
child.stderr.on("data", (c) => {
|
||||||
|
stderrTail = (stderrTail + c.toString("utf8")).slice(-400);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on("data", (c) => {
|
||||||
|
const chunk = stdoutRemainder + c.toString("utf8");
|
||||||
|
const lines = chunk.split("\n");
|
||||||
|
stdoutRemainder = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let o;
|
||||||
|
try {
|
||||||
|
o = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof o.thread_id === "string") thread.threadId = o.thread_id;
|
||||||
|
if (o.type === "thread.started") {
|
||||||
|
broadcast({ type: "chat_start", slug });
|
||||||
|
} else if (o.type === "item.completed" && o.item?.type === "agent_message" && o.item.text) {
|
||||||
|
lastText = String(o.item.text);
|
||||||
|
broadcast({ type: "chat_text", slug, text: lastText });
|
||||||
|
} else if (o.type === "turn.completed") {
|
||||||
|
sawDone = true;
|
||||||
|
broadcast({ type: "chat_done", slug, ok: true, text: lastText });
|
||||||
|
} else if (o.type === "turn.failed" || o.type === "error") {
|
||||||
|
sawDone = true;
|
||||||
|
broadcast({
|
||||||
|
type: "chat_done",
|
||||||
|
slug,
|
||||||
|
ok: false,
|
||||||
|
text: "",
|
||||||
|
error: String(o.error?.message ?? o.message ?? "turn failed").slice(0, 200),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdin.write(message);
|
||||||
|
child.stdin.end();
|
||||||
|
|
||||||
|
const killer = setTimeout(() => child.kill(), TASK_TIMEOUT_MS);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(killer);
|
||||||
|
thread.child = null;
|
||||||
|
if (!sawDone) {
|
||||||
|
broadcast({
|
||||||
|
type: "chat_done",
|
||||||
|
slug,
|
||||||
|
ok: code === 0 && Boolean(lastText),
|
||||||
|
text: lastText,
|
||||||
|
error: code === 0 ? undefined : stderrTail.trim().slice(-200) || `exit ${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on("error", (err) => {
|
||||||
|
clearTimeout(killer);
|
||||||
|
thread.child = null;
|
||||||
|
broadcast({ type: "chat_done", slug, ok: false, text: "", error: String(err).slice(0, 200) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, resumed: Boolean(resumeId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChatRequest(req, res) {
|
||||||
|
const cors = corsFor(req);
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(Object.keys(cors).length ? 204 : 403, cors);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Object.keys(cors).length) {
|
||||||
|
res.writeHead(403, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: "origin not allowed" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reply = (status, payload) => {
|
||||||
|
res.writeHead(status, { ...cors, "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(await readBody(req));
|
||||||
|
const slug = String(body.slug ?? "");
|
||||||
|
if (!projects.has(slug)) return reply(404, { ok: false, error: "unknown project" });
|
||||||
|
|
||||||
|
if (body.reset) {
|
||||||
|
const thread = chatThreads.get(slug);
|
||||||
|
if (thread?.child) thread.child.kill();
|
||||||
|
chatThreads.delete(slug);
|
||||||
|
return reply(200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = String(body.message ?? "").trim();
|
||||||
|
if (!message || message.length > 8000) {
|
||||||
|
return reply(400, { ok: false, error: "message must be 1–8000 chars" });
|
||||||
|
}
|
||||||
|
const result = launchChat(slug, message, Boolean(body.fromProject));
|
||||||
|
reply(result.ok ? 200 : 409, result);
|
||||||
|
} catch {
|
||||||
|
reply(400, { ok: false, error: "bad request" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function corsFor(req) {
|
function corsFor(req) {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
||||||
@@ -380,6 +523,10 @@ const server = createServer((req, res) => {
|
|||||||
handleTaskRequest(req, res);
|
handleTaskRequest(req, res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (req.url === "/chat") {
|
||||||
|
handleChatRequest(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.url === "/events") {
|
if (req.url === "/events") {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
|
|||||||
@@ -1,62 +1,93 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants";
|
import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants";
|
||||||
import { generateAppearance } from "@/lib/appearance";
|
import { generateAppearance } from "@/lib/appearance";
|
||||||
import { useOfficeStore } from "@/store/office-store";
|
import { useOfficeStore } from "@/store/office-store";
|
||||||
import { getDirector } from "@/sim/runtime";
|
import { getDirector } from "@/sim/runtime";
|
||||||
import { dispatchTask } from "@/gateway/live";
|
import { resetChat, sendChat } from "@/gateway/live";
|
||||||
import { Pawn } from "./Pawn";
|
import { Pawn } from "./Pawn";
|
||||||
|
|
||||||
const ROLE_LABELS = { lead: "Lead Agent", agent: "Agent", subagent: "Subagent" } as const;
|
const ROLE_LABELS = { lead: "Lead Agent", agent: "Agent", subagent: "Subagent" } as const;
|
||||||
|
|
||||||
/** LIVE mode: dispatch a real headless task to this project via the watcher. */
|
/** LIVE mode: two-way chat with this project — each message runs headless, stitched with --resume. */
|
||||||
function TaskComposer({ agentId }: { agentId: string }) {
|
function ChatPanel({ agentId }: { agentId: string }) {
|
||||||
const [prompt, setPrompt] = useState("");
|
const thread = useOfficeStore((s) => s.chats.get(agentId));
|
||||||
const [resume, setResume] = useState(false);
|
const [input, setInput] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [fromProject, setFromProject] = useState(false);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const send = async () => {
|
const busy = thread?.busy ?? false;
|
||||||
const text = prompt.trim();
|
const messages = thread?.messages ?? [];
|
||||||
if (!text || sending) return;
|
const started = messages.length > 0;
|
||||||
setSending(true);
|
|
||||||
const result = await dispatchTask(agentId, text, resume);
|
useEffect(() => {
|
||||||
setSending(false);
|
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
||||||
if (result.ok) {
|
}, [messages.length, busy]);
|
||||||
setPrompt("");
|
|
||||||
if (result.queued) {
|
const send = () => {
|
||||||
useOfficeStore.getState().addEvent("⏳", `任務已排隊(第 ${result.queued} 位)`);
|
const text = input.trim();
|
||||||
}
|
if (!text || busy) return;
|
||||||
} else {
|
sendChat(agentId, text, fromProject);
|
||||||
useOfficeStore.getState().addEvent("⚠️", `派發失敗:${result.error ?? "未知錯誤"}`);
|
setInput("");
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="task-composer">
|
<div className="chat-panel">
|
||||||
|
<div className="chat-head">
|
||||||
|
<h3>對話</h3>
|
||||||
|
{started && (
|
||||||
|
<button className="link-btn" onClick={() => resetChat(agentId)} disabled={busy}>
|
||||||
|
🔄 新對話
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{started && (
|
||||||
|
<div className="chat-list" ref={listRef}>
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<div key={i} className={`chat-msg ${m.role}`}>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{busy && <div className="chat-msg pending">⋯ 執行中,過程看辦公室裡的小人</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={prompt}
|
value={input}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="輸入要派發的任務,將以 headless session 在該專案執行…"
|
placeholder={started ? "繼續對話…(Enter 送出)" : "跟這個專案的 Codex 對話…(Enter 送出)"}
|
||||||
rows={3}
|
rows={2}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) send();
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="task-composer-row">
|
<div className="task-composer-row">
|
||||||
<label
|
{!started ? (
|
||||||
className="tip"
|
<label
|
||||||
data-tip="延續該專案最近一段「已結束」的 session 繼續對話(codex exec resume)。注意:無法插入正在終端機進行中的互動對話;若該專案已有任務在跑,新任務會自動排隊。"
|
className="tip"
|
||||||
>
|
data-tip="第一句話從該專案最近一段「已結束」的 session 接續(codex exec resume)。注意:無法插入正在終端機進行中的互動對話。"
|
||||||
<input type="checkbox" checked={resume} onChange={(e) => setResume(e.target.checked)} />
|
>
|
||||||
續上次 session
|
<input
|
||||||
</label>
|
type="checkbox"
|
||||||
<span
|
checked={fromProject}
|
||||||
className="tip"
|
onChange={(e) => setFromProject(e.target.checked)}
|
||||||
data-tip="在該專案目錄開一個 headless session 執行此任務,過程會即時顯示在辦公室,完成後於事件紀錄回報。"
|
/>
|
||||||
>
|
接續專案最近 session
|
||||||
<button className="btn assign-btn" onClick={send} disabled={sending || !prompt.trim()}>
|
</label>
|
||||||
{sending ? "派發中…" : "🚀 派發任務"}
|
) : (
|
||||||
</button>
|
<span
|
||||||
</span>
|
className="chat-hint tip"
|
||||||
|
data-tip="辦公室對話有自己的 session,每則訊息自動以 --resume 延續,每則訊息逐則接力。"
|
||||||
|
>
|
||||||
|
✓ 對話自動延續
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="btn assign-btn" onClick={send} disabled={busy || !input.trim()}>
|
||||||
|
{busy ? "執行中…" : "送出"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -155,7 +186,7 @@ export function SidePanel() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{agent.role !== "subagent" && mode === "live" && <TaskComposer agentId={agent.id} />}
|
{agent.role !== "subagent" && mode === "live" && <ChatPanel agentId={agent.id} />}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="panel-hint">
|
<div className="panel-hint">
|
||||||
|
|||||||
@@ -160,6 +160,40 @@ function onEvent(e: MessageEvent) {
|
|||||||
bump(id);
|
bump(id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "chat_start": {
|
||||||
|
if (!S().agents.has(id)) break;
|
||||||
|
S().setStatus(id, "thinking");
|
||||||
|
bump(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "chat_text": {
|
||||||
|
if (!S().agents.has(id)) break;
|
||||||
|
S().chatAppend(id, { role: "assistant", text: ev.text as string });
|
||||||
|
S().setStatus(id, "speaking");
|
||||||
|
S().setSpeech(id, ev.text as string);
|
||||||
|
bump(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "chat_done": {
|
||||||
|
if (!S().agents.has(id)) break;
|
||||||
|
S().chatSetBusy(id, false);
|
||||||
|
const thread = S().chats.get(id);
|
||||||
|
const lastAssistant = [...(thread?.messages ?? [])].reverse().find((m) => m.role === "assistant");
|
||||||
|
const finalText = (ev.text as string) ?? "";
|
||||||
|
if (ev.ok) {
|
||||||
|
// stream-json 的 result 可能與最後一則 assistant 相同,避免重複
|
||||||
|
if (finalText && finalText !== lastAssistant?.text) {
|
||||||
|
S().chatAppend(id, { role: "assistant", text: finalText });
|
||||||
|
S().setSpeech(id, finalText);
|
||||||
|
S().setStatus(id, "speaking");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
S().chatAppend(id, { role: "system", text: `執行失敗:${(ev.error as string) ?? "未知錯誤"}` });
|
||||||
|
S().setStatus(id, "error");
|
||||||
|
}
|
||||||
|
bump(id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +216,53 @@ export async function dispatchTask(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Send a chat message to the project's office thread (auto-resumed server-side). */
|
||||||
|
export async function sendChat(
|
||||||
|
agentId: string,
|
||||||
|
message: string,
|
||||||
|
fromProject: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const slug = agentId.replace(/^proj-/, "");
|
||||||
|
S().chatAppend(agentId, { role: "user", text: message });
|
||||||
|
S().chatSetBusy(agentId, true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${WATCH_ORIGIN}/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ slug, message, fromProject }),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { ok: boolean; error?: string };
|
||||||
|
if (!data.ok) {
|
||||||
|
S().chatSetBusy(agentId, false);
|
||||||
|
S().chatAppend(agentId, {
|
||||||
|
role: "system",
|
||||||
|
text: data.error === "busy" ? "上一則還在執行中,請稍候" : `送出失敗:${data.error ?? "未知錯誤"}`,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
S().chatSetBusy(agentId, false);
|
||||||
|
S().chatAppend(agentId, { role: "system", text: "無法連線監看伺服器 (npm run watch)" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the office chat thread (kills any in-flight run server-side). */
|
||||||
|
export async function resetChat(agentId: string): Promise<void> {
|
||||||
|
const slug = agentId.replace(/^proj-/, "");
|
||||||
|
S().chatClear(agentId);
|
||||||
|
try {
|
||||||
|
await fetch(`${WATCH_ORIGIN}/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ slug, reset: true }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* offline — local clear is enough */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Fade statuses back to idle / offline when a project goes quiet. */
|
/** Fade statuses back to idle / offline when a project goes quiet. */
|
||||||
function decayPass() {
|
function decayPass() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
+402
-355
@@ -1,355 +1,402 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { DESK_SLOTS, HOT_DESK_SLOTS, meetingSeats } from "@/lib/positions";
|
import { DESK_SLOTS, HOT_DESK_SLOTS, meetingSeats } from "@/lib/positions";
|
||||||
import { interpolatePath, planWalkPath, walkDuration } from "@/lib/movement";
|
import { interpolatePath, planWalkPath, walkDuration } from "@/lib/movement";
|
||||||
import { ENTRANCE, MEETING_CENTER } from "@/lib/constants";
|
import { ENTRANCE, MEETING_CENTER } from "@/lib/constants";
|
||||||
import type {
|
import type {
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
AgentZone,
|
AgentZone,
|
||||||
CollaborationLink,
|
CollaborationLink,
|
||||||
Meeting,
|
Meeting,
|
||||||
OfficeEvent,
|
OfficeEvent,
|
||||||
Point,
|
Point,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
VisualAgent,
|
VisualAgent,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
interface OfficeState {
|
export interface ChatMessage {
|
||||||
agents: Map<string, VisualAgent>;
|
role: "user" | "assistant" | "system";
|
||||||
links: CollaborationLink[];
|
text: string;
|
||||||
meetings: Meeting[];
|
}
|
||||||
events: OfficeEvent[];
|
|
||||||
selectedAgentId: string | null;
|
export interface ChatThread {
|
||||||
theme: "light" | "dark";
|
messages: ChatMessage[];
|
||||||
paused: boolean;
|
busy: boolean;
|
||||||
speed: number;
|
}
|
||||||
/** data source: scripted simulation or live Claude Code transcripts */
|
|
||||||
mode: "sim" | "live";
|
interface OfficeState {
|
||||||
/** side panel visibility (collapse to maximise the office view) */
|
agents: Map<string, VisualAgent>;
|
||||||
panelOpen: boolean;
|
links: CollaborationLink[];
|
||||||
/** simulation clock, seconds */
|
meetings: Meeting[];
|
||||||
clock: number;
|
events: OfficeEvent[];
|
||||||
|
/** LIVE-mode chat threads, keyed by agent id */
|
||||||
selectAgent: (id: string | null) => void;
|
chats: Map<string, ChatThread>;
|
||||||
setTheme: (t: "light" | "dark") => void;
|
selectedAgentId: string | null;
|
||||||
setPaused: (p: boolean) => void;
|
theme: "light" | "dark";
|
||||||
setSpeed: (s: number) => void;
|
paused: boolean;
|
||||||
setMode: (m: "sim" | "live") => void;
|
speed: number;
|
||||||
togglePanel: () => void;
|
/** data source: scripted simulation or live Claude Code transcripts */
|
||||||
clearWorld: () => void;
|
mode: "sim" | "live";
|
||||||
|
/** side panel visibility (collapse to maximise the office view) */
|
||||||
addEvent: (icon: string, text: string) => void;
|
panelOpen: boolean;
|
||||||
|
/** simulation clock, seconds */
|
||||||
spawnAgent: (a: {
|
clock: number;
|
||||||
id: string;
|
|
||||||
name: string;
|
selectAgent: (id: string | null) => void;
|
||||||
role: VisualAgent["role"];
|
setTheme: (t: "light" | "dark") => void;
|
||||||
model: string;
|
setPaused: (p: boolean) => void;
|
||||||
homeSlot: number;
|
setSpeed: (s: number) => void;
|
||||||
parentId?: string;
|
setMode: (m: "sim" | "live") => void;
|
||||||
}) => void;
|
togglePanel: () => void;
|
||||||
removeAgent: (id: string) => void;
|
clearWorld: () => void;
|
||||||
setStatus: (id: string, status: AgentStatus) => void;
|
|
||||||
setTool: (id: string, tool: ToolCall | null) => void;
|
chatAppend: (agentId: string, msg: ChatMessage) => void;
|
||||||
setSpeech: (id: string, text: string | null) => void;
|
chatSetBusy: (agentId: string, busy: boolean) => void;
|
||||||
startWalk: (
|
chatClear: (agentId: string) => void;
|
||||||
id: string,
|
|
||||||
to: Point,
|
addEvent: (icon: string, text: string) => void;
|
||||||
toZone: AgentZone,
|
|
||||||
opts?: { arriveStatus?: AgentStatus; despawnOnArrive?: boolean },
|
spawnAgent: (a: {
|
||||||
) => void;
|
id: string;
|
||||||
walkHome: (id: string, arriveStatus?: AgentStatus) => void;
|
name: string;
|
||||||
|
role: VisualAgent["role"];
|
||||||
addLink: (link: CollaborationLink) => void;
|
model: string;
|
||||||
removeLink: (sourceId: string, targetId: string) => void;
|
homeSlot: number;
|
||||||
|
parentId?: string;
|
||||||
startMeeting: (id: string, agentIds: string[]) => void;
|
}) => void;
|
||||||
endMeeting: (id: string) => void;
|
removeAgent: (id: string) => void;
|
||||||
|
setStatus: (id: string, status: AgentStatus) => void;
|
||||||
/** advance movements + clock; called once per frame with scaled dt */
|
setTool: (id: string, tool: ToolCall | null) => void;
|
||||||
tick: (dt: number) => void;
|
setSpeech: (id: string, text: string | null) => void;
|
||||||
}
|
startWalk: (
|
||||||
|
id: string,
|
||||||
let eventSeq = 0;
|
to: Point,
|
||||||
|
toZone: AgentZone,
|
||||||
/* UI preferences persisted across reloads. */
|
opts?: { arriveStatus?: AgentStatus; despawnOnArrive?: boolean },
|
||||||
function loadPref(key: string): string | null {
|
) => void;
|
||||||
try {
|
walkHome: (id: string, arriveStatus?: AgentStatus) => void;
|
||||||
return localStorage.getItem(key);
|
|
||||||
} catch {
|
addLink: (link: CollaborationLink) => void;
|
||||||
return null;
|
removeLink: (sourceId: string, targetId: string) => void;
|
||||||
}
|
|
||||||
}
|
startMeeting: (id: string, agentIds: string[]) => void;
|
||||||
|
endMeeting: (id: string) => void;
|
||||||
function savePref(key: string, value: string) {
|
|
||||||
try {
|
/** advance movements + clock; called once per frame with scaled dt */
|
||||||
localStorage.setItem(key, value);
|
tick: (dt: number) => void;
|
||||||
} catch {
|
}
|
||||||
/* ignore */
|
|
||||||
}
|
let eventSeq = 0;
|
||||||
}
|
|
||||||
|
/* UI preferences persisted across reloads. */
|
||||||
const savedTheme = loadPref("office-theme");
|
function loadPref(key: string): string | null {
|
||||||
const savedPanel = loadPref("office-panel");
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
/** Read the preferred data source ("live" | "sim"); the runtime applies it on boot. */
|
} catch {
|
||||||
export function loadPreferredMode(): "sim" | "live" {
|
return null;
|
||||||
return loadPref("office-mode") === "live" ? "live" : "sim";
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useOfficeStore = create<OfficeState>()((set, get) => ({
|
function savePref(key: string, value: string) {
|
||||||
agents: new Map(),
|
try {
|
||||||
links: [],
|
localStorage.setItem(key, value);
|
||||||
meetings: [],
|
} catch {
|
||||||
events: [],
|
/* ignore */
|
||||||
selectedAgentId: null,
|
}
|
||||||
theme: savedTheme === "dark" ? "dark" : "light",
|
}
|
||||||
paused: false,
|
|
||||||
speed: 1,
|
const savedTheme = loadPref("office-theme");
|
||||||
mode: "sim",
|
const savedPanel = loadPref("office-panel");
|
||||||
panelOpen: savedPanel !== "0",
|
|
||||||
clock: 0,
|
/** Read the preferred data source ("live" | "sim"); the runtime applies it on boot. */
|
||||||
|
export function loadPreferredMode(): "sim" | "live" {
|
||||||
selectAgent: (id) => set({ selectedAgentId: id }),
|
return loadPref("office-mode") === "live" ? "live" : "sim";
|
||||||
setTheme: (t) => {
|
}
|
||||||
savePref("office-theme", t);
|
|
||||||
set({ theme: t });
|
export const useOfficeStore = create<OfficeState>()((set, get) => ({
|
||||||
},
|
agents: new Map(),
|
||||||
setPaused: (p) => set({ paused: p }),
|
links: [],
|
||||||
setSpeed: (s) => set({ speed: s }),
|
meetings: [],
|
||||||
setMode: (m) => {
|
events: [],
|
||||||
savePref("office-mode", m);
|
chats: new Map(),
|
||||||
set({ mode: m });
|
selectedAgentId: null,
|
||||||
},
|
theme: savedTheme === "dark" ? "dark" : "light",
|
||||||
togglePanel: () =>
|
paused: false,
|
||||||
set((state) => {
|
speed: 1,
|
||||||
savePref("office-panel", state.panelOpen ? "0" : "1");
|
mode: "sim",
|
||||||
return { panelOpen: !state.panelOpen };
|
panelOpen: savedPanel !== "0",
|
||||||
}),
|
clock: 0,
|
||||||
clearWorld: () =>
|
|
||||||
set({ agents: new Map(), links: [], meetings: [], events: [], selectedAgentId: null }),
|
selectAgent: (id) => set({ selectedAgentId: id }),
|
||||||
|
setTheme: (t) => {
|
||||||
addEvent: (icon, text) =>
|
savePref("office-theme", t);
|
||||||
set((state) => ({
|
set({ theme: t });
|
||||||
events: [{ at: eventSeq++, text, icon }, ...state.events].slice(0, 60),
|
},
|
||||||
})),
|
setPaused: (p) => set({ paused: p }),
|
||||||
|
setSpeed: (s) => set({ speed: s }),
|
||||||
spawnAgent: ({ id, name, role, model, homeSlot, parentId }) =>
|
setMode: (m) => {
|
||||||
set((state) => {
|
savePref("office-mode", m);
|
||||||
const isSub = role === "subagent";
|
set({ mode: m });
|
||||||
const slot = isSub ? HOT_DESK_SLOTS[homeSlot] : DESK_SLOTS[homeSlot];
|
},
|
||||||
const agent: VisualAgent = {
|
togglePanel: () =>
|
||||||
id,
|
set((state) => {
|
||||||
name,
|
savePref("office-panel", state.panelOpen ? "0" : "1");
|
||||||
role,
|
return { panelOpen: !state.panelOpen };
|
||||||
model,
|
}),
|
||||||
status: "spawning",
|
clearWorld: () =>
|
||||||
position: { ...ENTRANCE },
|
set({
|
||||||
zone: "corridor",
|
agents: new Map(),
|
||||||
homePosition: { x: slot.x, y: slot.y },
|
links: [],
|
||||||
homeZone: isSub ? "hotDesk" : "desk",
|
meetings: [],
|
||||||
homeSlot,
|
events: [],
|
||||||
currentTool: null,
|
chats: new Map(),
|
||||||
speech: null,
|
selectedAgentId: null,
|
||||||
movement: null,
|
}),
|
||||||
parentId: parentId ?? null,
|
|
||||||
childIds: [],
|
chatAppend: (agentId, msg) =>
|
||||||
toolCallCount: 0,
|
set((state) => {
|
||||||
toolHistory: [],
|
const chats = new Map(state.chats);
|
||||||
spawnedAt: state.clock,
|
const thread = chats.get(agentId) ?? { messages: [], busy: false };
|
||||||
};
|
chats.set(agentId, { ...thread, messages: [...thread.messages, msg].slice(-100) });
|
||||||
const agents = new Map(state.agents);
|
return { chats };
|
||||||
agents.set(id, agent);
|
}),
|
||||||
if (parentId) {
|
|
||||||
const parent = agents.get(parentId);
|
chatSetBusy: (agentId, busy) =>
|
||||||
if (parent) agents.set(parentId, { ...parent, childIds: [...parent.childIds, id] });
|
set((state) => {
|
||||||
}
|
const chats = new Map(state.chats);
|
||||||
return { agents };
|
const thread = chats.get(agentId) ?? { messages: [], busy: false };
|
||||||
}),
|
chats.set(agentId, { ...thread, busy });
|
||||||
|
return { chats };
|
||||||
removeAgent: (id) =>
|
}),
|
||||||
set((state) => {
|
|
||||||
const agents = new Map(state.agents);
|
chatClear: (agentId) =>
|
||||||
const agent = agents.get(id);
|
set((state) => {
|
||||||
agents.delete(id);
|
const chats = new Map(state.chats);
|
||||||
if (agent?.parentId) {
|
chats.delete(agentId);
|
||||||
const parent = agents.get(agent.parentId);
|
return { chats };
|
||||||
if (parent) {
|
}),
|
||||||
agents.set(agent.parentId, {
|
|
||||||
...parent,
|
addEvent: (icon, text) =>
|
||||||
childIds: parent.childIds.filter((c) => c !== id),
|
set((state) => ({
|
||||||
});
|
events: [{ at: eventSeq++, text, icon }, ...state.events].slice(0, 60),
|
||||||
}
|
})),
|
||||||
}
|
|
||||||
return {
|
spawnAgent: ({ id, name, role, model, homeSlot, parentId }) =>
|
||||||
agents,
|
set((state) => {
|
||||||
links: state.links.filter((l) => l.sourceId !== id && l.targetId !== id),
|
const isSub = role === "subagent";
|
||||||
selectedAgentId: state.selectedAgentId === id ? null : state.selectedAgentId,
|
const slot = isSub ? HOT_DESK_SLOTS[homeSlot] : DESK_SLOTS[homeSlot];
|
||||||
};
|
const agent: VisualAgent = {
|
||||||
}),
|
id,
|
||||||
|
name,
|
||||||
setStatus: (id, status) =>
|
role,
|
||||||
set((state) => {
|
model,
|
||||||
const agent = state.agents.get(id);
|
status: "spawning",
|
||||||
if (!agent || agent.status === status) return {};
|
position: { ...ENTRANCE },
|
||||||
const agents = new Map(state.agents);
|
zone: "corridor",
|
||||||
agents.set(id, {
|
homePosition: { x: slot.x, y: slot.y },
|
||||||
...agent,
|
homeZone: isSub ? "hotDesk" : "desk",
|
||||||
status,
|
homeSlot,
|
||||||
speech: status === "speaking" ? agent.speech : null,
|
currentTool: null,
|
||||||
currentTool: status === "tool_calling" ? agent.currentTool : null,
|
speech: null,
|
||||||
});
|
movement: null,
|
||||||
return { agents };
|
parentId: parentId ?? null,
|
||||||
}),
|
childIds: [],
|
||||||
|
toolCallCount: 0,
|
||||||
setTool: (id, tool) =>
|
toolHistory: [],
|
||||||
set((state) => {
|
spawnedAt: state.clock,
|
||||||
const agent = state.agents.get(id);
|
};
|
||||||
if (!agent) return {};
|
const agents = new Map(state.agents);
|
||||||
const agents = new Map(state.agents);
|
agents.set(id, agent);
|
||||||
agents.set(id, {
|
if (parentId) {
|
||||||
...agent,
|
const parent = agents.get(parentId);
|
||||||
currentTool: tool,
|
if (parent) agents.set(parentId, { ...parent, childIds: [...parent.childIds, id] });
|
||||||
toolCallCount: tool ? agent.toolCallCount + 1 : agent.toolCallCount,
|
}
|
||||||
toolHistory: tool ? [tool.name, ...agent.toolHistory].slice(0, 12) : agent.toolHistory,
|
return { agents };
|
||||||
});
|
}),
|
||||||
return { agents };
|
|
||||||
}),
|
removeAgent: (id) =>
|
||||||
|
set((state) => {
|
||||||
setSpeech: (id, text) =>
|
const agents = new Map(state.agents);
|
||||||
set((state) => {
|
const agent = agents.get(id);
|
||||||
const agent = state.agents.get(id);
|
agents.delete(id);
|
||||||
if (!agent) return {};
|
if (agent?.parentId) {
|
||||||
const agents = new Map(state.agents);
|
const parent = agents.get(agent.parentId);
|
||||||
agents.set(id, { ...agent, speech: text });
|
if (parent) {
|
||||||
return { agents };
|
agents.set(agent.parentId, {
|
||||||
}),
|
...parent,
|
||||||
|
childIds: parent.childIds.filter((c) => c !== id),
|
||||||
startWalk: (id, to, toZone, opts) =>
|
});
|
||||||
set((state) => {
|
}
|
||||||
const agent = state.agents.get(id);
|
}
|
||||||
if (!agent) return {};
|
return {
|
||||||
const from = agent.movement
|
agents,
|
||||||
? interpolatePath(agent.movement.path, agent.movement.elapsed / agent.movement.duration)
|
links: state.links.filter((l) => l.sourceId !== id && l.targetId !== id),
|
||||||
: agent.position;
|
selectedAgentId: state.selectedAgentId === id ? null : state.selectedAgentId,
|
||||||
const path = planWalkPath(from, to, agent.zone, toZone);
|
};
|
||||||
const agents = new Map(state.agents);
|
}),
|
||||||
agents.set(id, {
|
|
||||||
...agent,
|
setStatus: (id, status) =>
|
||||||
position: { ...from },
|
set((state) => {
|
||||||
movement: {
|
const agent = state.agents.get(id);
|
||||||
path,
|
if (!agent || agent.status === status) return {};
|
||||||
duration: walkDuration(path),
|
const agents = new Map(state.agents);
|
||||||
elapsed: 0,
|
agents.set(id, {
|
||||||
toZone,
|
...agent,
|
||||||
arriveStatus: opts?.arriveStatus,
|
status,
|
||||||
despawnOnArrive: opts?.despawnOnArrive,
|
speech: status === "speaking" ? agent.speech : null,
|
||||||
},
|
currentTool: status === "tool_calling" ? agent.currentTool : null,
|
||||||
});
|
});
|
||||||
return { agents };
|
return { agents };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
walkHome: (id, arriveStatus = "idle") => {
|
setTool: (id, tool) =>
|
||||||
const agent = get().agents.get(id);
|
set((state) => {
|
||||||
if (!agent) return;
|
const agent = state.agents.get(id);
|
||||||
get().startWalk(id, agent.homePosition, agent.homeZone, { arriveStatus });
|
if (!agent) return {};
|
||||||
},
|
const agents = new Map(state.agents);
|
||||||
|
agents.set(id, {
|
||||||
addLink: (link) =>
|
...agent,
|
||||||
set((state) => {
|
currentTool: tool,
|
||||||
const others = state.links.filter(
|
toolCallCount: tool ? agent.toolCallCount + 1 : agent.toolCallCount,
|
||||||
(l) => !(l.sourceId === link.sourceId && l.targetId === link.targetId),
|
toolHistory: tool ? [tool.name, ...agent.toolHistory].slice(0, 12) : agent.toolHistory,
|
||||||
);
|
});
|
||||||
return { links: [...others, link] };
|
return { agents };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
removeLink: (sourceId, targetId) =>
|
setSpeech: (id, text) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
links: state.links.filter((l) => !(l.sourceId === sourceId && l.targetId === targetId)),
|
const agent = state.agents.get(id);
|
||||||
})),
|
if (!agent) return {};
|
||||||
|
const agents = new Map(state.agents);
|
||||||
startMeeting: (id, agentIds) => {
|
agents.set(id, { ...agent, speech: text });
|
||||||
set((state) => ({
|
return { agents };
|
||||||
meetings: [...state.meetings, { id, agentIds, center: { ...MEETING_CENTER } }],
|
}),
|
||||||
}));
|
|
||||||
const seats = meetingSeats(agentIds.length);
|
startWalk: (id, to, toZone, opts) =>
|
||||||
agentIds.forEach((agentId, i) => {
|
set((state) => {
|
||||||
get().startWalk(agentId, seats[i], "meeting", { arriveStatus: "idle" });
|
const agent = state.agents.get(id);
|
||||||
});
|
if (!agent) return {};
|
||||||
// pairwise meeting links
|
const from = agent.movement
|
||||||
for (let i = 0; i < agentIds.length; i++) {
|
? interpolatePath(agent.movement.path, agent.movement.elapsed / agent.movement.duration)
|
||||||
for (let j = i + 1; j < agentIds.length; j++) {
|
: agent.position;
|
||||||
get().addLink({ sourceId: agentIds[i], targetId: agentIds[j], strength: 0.85, kind: "meeting" });
|
const path = planWalkPath(from, to, agent.zone, toZone);
|
||||||
}
|
const agents = new Map(state.agents);
|
||||||
}
|
agents.set(id, {
|
||||||
},
|
...agent,
|
||||||
|
position: { ...from },
|
||||||
endMeeting: (id) => {
|
movement: {
|
||||||
const meeting = get().meetings.find((m) => m.id === id);
|
path,
|
||||||
if (!meeting) return;
|
duration: walkDuration(path),
|
||||||
set((state) => ({ meetings: state.meetings.filter((m) => m.id !== id) }));
|
elapsed: 0,
|
||||||
const ids = meeting.agentIds;
|
toZone,
|
||||||
for (let i = 0; i < ids.length; i++) {
|
arriveStatus: opts?.arriveStatus,
|
||||||
for (let j = i + 1; j < ids.length; j++) {
|
despawnOnArrive: opts?.despawnOnArrive,
|
||||||
get().removeLink(ids[i], ids[j]);
|
},
|
||||||
}
|
});
|
||||||
}
|
return { agents };
|
||||||
ids.forEach((agentId) => {
|
}),
|
||||||
const agent = get().agents.get(agentId);
|
|
||||||
if (agent) get().walkHome(agentId);
|
walkHome: (id, arriveStatus = "idle") => {
|
||||||
});
|
const agent = get().agents.get(id);
|
||||||
},
|
if (!agent) return;
|
||||||
|
get().startWalk(id, agent.homePosition, agent.homeZone, { arriveStatus });
|
||||||
tick: (dt) =>
|
},
|
||||||
set((state) => {
|
|
||||||
let agents: Map<string, VisualAgent> | null = null;
|
addLink: (link) =>
|
||||||
const toRemove: string[] = [];
|
set((state) => {
|
||||||
|
const others = state.links.filter(
|
||||||
for (const [id, agent] of state.agents) {
|
(l) => !(l.sourceId === link.sourceId && l.targetId === link.targetId),
|
||||||
if (!agent.movement) continue;
|
);
|
||||||
if (!agents) agents = new Map(state.agents);
|
return { links: [...others, link] };
|
||||||
|
}),
|
||||||
const elapsed = agent.movement.elapsed + dt;
|
|
||||||
if (elapsed >= agent.movement.duration) {
|
removeLink: (sourceId, targetId) =>
|
||||||
const end = agent.movement.path[agent.movement.path.length - 1];
|
set((state) => ({
|
||||||
if (agent.movement.despawnOnArrive) {
|
links: state.links.filter((l) => !(l.sourceId === sourceId && l.targetId === targetId)),
|
||||||
toRemove.push(id);
|
})),
|
||||||
continue;
|
|
||||||
}
|
startMeeting: (id, agentIds) => {
|
||||||
agents.set(id, {
|
set((state) => ({
|
||||||
...agent,
|
meetings: [...state.meetings, { id, agentIds, center: { ...MEETING_CENTER } }],
|
||||||
position: { ...end },
|
}));
|
||||||
zone: agent.movement.toZone,
|
const seats = meetingSeats(agentIds.length);
|
||||||
status: agent.movement.arriveStatus ?? agent.status,
|
agentIds.forEach((agentId, i) => {
|
||||||
movement: null,
|
get().startWalk(agentId, seats[i], "meeting", { arriveStatus: "idle" });
|
||||||
});
|
});
|
||||||
} else {
|
// pairwise meeting links
|
||||||
const pos = interpolatePath(agent.movement.path, elapsed / agent.movement.duration);
|
for (let i = 0; i < agentIds.length; i++) {
|
||||||
agents.set(id, {
|
for (let j = i + 1; j < agentIds.length; j++) {
|
||||||
...agent,
|
get().addLink({ sourceId: agentIds[i], targetId: agentIds[j], strength: 0.85, kind: "meeting" });
|
||||||
position: pos,
|
}
|
||||||
movement: { ...agent.movement, elapsed },
|
}
|
||||||
});
|
},
|
||||||
}
|
|
||||||
}
|
endMeeting: (id) => {
|
||||||
|
const meeting = get().meetings.find((m) => m.id === id);
|
||||||
if (toRemove.length > 0 && agents) {
|
if (!meeting) return;
|
||||||
for (const id of toRemove) agents.delete(id);
|
set((state) => ({ meetings: state.meetings.filter((m) => m.id !== id) }));
|
||||||
}
|
const ids = meeting.agentIds;
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
return {
|
for (let j = i + 1; j < ids.length; j++) {
|
||||||
clock: state.clock + dt,
|
get().removeLink(ids[i], ids[j]);
|
||||||
...(agents ? { agents } : {}),
|
}
|
||||||
...(toRemove.length > 0
|
}
|
||||||
? {
|
ids.forEach((agentId) => {
|
||||||
links: state.links.filter(
|
const agent = get().agents.get(agentId);
|
||||||
(l) => !toRemove.includes(l.sourceId) && !toRemove.includes(l.targetId),
|
if (agent) get().walkHome(agentId);
|
||||||
),
|
});
|
||||||
selectedAgentId:
|
},
|
||||||
state.selectedAgentId && toRemove.includes(state.selectedAgentId)
|
|
||||||
? null
|
tick: (dt) =>
|
||||||
: state.selectedAgentId,
|
set((state) => {
|
||||||
}
|
let agents: Map<string, VisualAgent> | null = null;
|
||||||
: {}),
|
const toRemove: string[] = [];
|
||||||
};
|
|
||||||
}),
|
for (const [id, agent] of state.agents) {
|
||||||
}));
|
if (!agent.movement) continue;
|
||||||
|
if (!agents) agents = new Map(state.agents);
|
||||||
|
|
||||||
|
const elapsed = agent.movement.elapsed + dt;
|
||||||
|
if (elapsed >= agent.movement.duration) {
|
||||||
|
const end = agent.movement.path[agent.movement.path.length - 1];
|
||||||
|
if (agent.movement.despawnOnArrive) {
|
||||||
|
toRemove.push(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
agents.set(id, {
|
||||||
|
...agent,
|
||||||
|
position: { ...end },
|
||||||
|
zone: agent.movement.toZone,
|
||||||
|
status: agent.movement.arriveStatus ?? agent.status,
|
||||||
|
movement: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const pos = interpolatePath(agent.movement.path, elapsed / agent.movement.duration);
|
||||||
|
agents.set(id, {
|
||||||
|
...agent,
|
||||||
|
position: pos,
|
||||||
|
movement: { ...agent.movement, elapsed },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toRemove.length > 0 && agents) {
|
||||||
|
for (const id of toRemove) agents.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clock: state.clock + dt,
|
||||||
|
...(agents ? { agents } : {}),
|
||||||
|
...(toRemove.length > 0
|
||||||
|
? {
|
||||||
|
links: state.links.filter(
|
||||||
|
(l) => !toRemove.includes(l.sourceId) && !toRemove.includes(l.targetId),
|
||||||
|
),
|
||||||
|
selectedAgentId:
|
||||||
|
state.selectedAgentId && toRemove.includes(state.selectedAgentId)
|
||||||
|
? null
|
||||||
|
: state.selectedAgentId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|||||||
+102
@@ -348,6 +348,108 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-panel {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-head h3 {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-head .link-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg {
|
||||||
|
max-width: 88%;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
animation: card-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--terracotta);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.system {
|
||||||
|
align-self: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
background: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.pending {
|
||||||
|
align-self: flex-start;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
background: none;
|
||||||
|
animation: emote-bob 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel textarea {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--terracotta);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.task-composer {
|
.task-composer {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user