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; /* UI preferences persisted across reloads. */ function loadPref(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; } } function savePref(key: string, value: string) { try { localStorage.setItem(key, value); } catch { /* ignore */ } } const savedTheme = loadPref("office-theme"); const savedPanel = loadPref("office-panel"); /** Read the preferred data source ("live" | "sim"); the runtime applies it on boot. */ export function loadPreferredMode(): "sim" | "live" { return loadPref("office-mode") === "live" ? "live" : "sim"; } export const useOfficeStore = create()((set, get) => ({ agents: new Map(), links: [], meetings: [], events: [], selectedAgentId: null, theme: savedTheme === "dark" ? "dark" : "light", paused: false, speed: 1, mode: "sim", panelOpen: savedPanel !== "0", clock: 0, selectAgent: (id) => set({ selectedAgentId: id }), setTheme: (t) => { savePref("office-theme", t); set({ theme: t }); }, setPaused: (p) => set({ paused: p }), setSpeed: (s) => set({ speed: s }), setMode: (m) => { savePref("office-mode", m); set({ mode: m }); }, togglePanel: () => set((state) => { savePref("office-panel", state.panelOpen ? "0" : "1"); return { 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, } : {}), }; }), }));