Files
codex-office/src/store/office-store.ts
T

321 lines
9.5 KiB
TypeScript
Raw Normal View History

import { create } from "zustand";
import { DESK_SLOTS, HOT_DESK_SLOTS, meetingSeats } from "@/lib/positions";
import { interpolatePath, planWalkPath, walkDuration } from "@/lib/movement";
import { ENTRANCE, MEETING_CENTER } from "@/lib/constants";
import type {
AgentStatus,
AgentZone,
CollaborationLink,
Meeting,
OfficeEvent,
Point,
ToolCall,
VisualAgent,
} from "@/lib/types";
interface OfficeState {
agents: Map<string, VisualAgent>;
links: CollaborationLink[];
meetings: Meeting[];
events: OfficeEvent[];
selectedAgentId: string | null;
theme: "light" | "dark";
paused: boolean;
speed: number;
/** data source: scripted simulation or live Claude Code transcripts */
mode: "sim" | "live";
/** side panel visibility (collapse to maximise the office view) */
panelOpen: boolean;
/** simulation clock, seconds */
clock: number;
selectAgent: (id: string | null) => void;
setTheme: (t: "light" | "dark") => void;
setPaused: (p: boolean) => void;
setSpeed: (s: number) => void;
setMode: (m: "sim" | "live") => void;
togglePanel: () => void;
clearWorld: () => void;
addEvent: (icon: string, text: string) => void;
spawnAgent: (a: {
id: string;
name: string;
role: VisualAgent["role"];
model: string;
homeSlot: number;
parentId?: string;
}) => void;
removeAgent: (id: string) => void;
setStatus: (id: string, status: AgentStatus) => void;
setTool: (id: string, tool: ToolCall | null) => void;
setSpeech: (id: string, text: string | null) => void;
startWalk: (
id: string,
to: Point,
toZone: AgentZone,
opts?: { arriveStatus?: AgentStatus; despawnOnArrive?: boolean },
) => void;
walkHome: (id: string, arriveStatus?: AgentStatus) => void;
addLink: (link: CollaborationLink) => void;
removeLink: (sourceId: string, targetId: string) => void;
startMeeting: (id: string, agentIds: string[]) => void;
endMeeting: (id: string) => void;
/** advance movements + clock; called once per frame with scaled dt */
tick: (dt: number) => void;
}
let eventSeq = 0;
export const useOfficeStore = create<OfficeState>()((set, get) => ({
agents: new Map(),
links: [],
meetings: [],
events: [],
selectedAgentId: null,
theme: "light",
paused: false,
speed: 1,
mode: "sim",
panelOpen: true,
clock: 0,
selectAgent: (id) => set({ selectedAgentId: id }),
setTheme: (t) => set({ theme: t }),
setPaused: (p) => set({ paused: p }),
setSpeed: (s) => set({ speed: s }),
setMode: (m) => set({ mode: m }),
togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })),
clearWorld: () =>
set({ agents: new Map(), links: [], meetings: [], events: [], selectedAgentId: null }),
addEvent: (icon, text) =>
set((state) => ({
events: [{ at: eventSeq++, text, icon }, ...state.events].slice(0, 60),
})),
spawnAgent: ({ id, name, role, model, homeSlot, parentId }) =>
set((state) => {
const isSub = role === "subagent";
const slot = isSub ? HOT_DESK_SLOTS[homeSlot] : DESK_SLOTS[homeSlot];
const agent: VisualAgent = {
id,
name,
role,
model,
status: "spawning",
position: { ...ENTRANCE },
zone: "corridor",
homePosition: { x: slot.x, y: slot.y },
homeZone: isSub ? "hotDesk" : "desk",
homeSlot,
currentTool: null,
speech: null,
movement: null,
parentId: parentId ?? null,
childIds: [],
toolCallCount: 0,
toolHistory: [],
spawnedAt: state.clock,
};
const agents = new Map(state.agents);
agents.set(id, agent);
if (parentId) {
const parent = agents.get(parentId);
if (parent) agents.set(parentId, { ...parent, childIds: [...parent.childIds, id] });
}
return { agents };
}),
removeAgent: (id) =>
set((state) => {
const agents = new Map(state.agents);
const agent = agents.get(id);
agents.delete(id);
if (agent?.parentId) {
const parent = agents.get(agent.parentId);
if (parent) {
agents.set(agent.parentId, {
...parent,
childIds: parent.childIds.filter((c) => c !== id),
});
}
}
return {
agents,
links: state.links.filter((l) => l.sourceId !== id && l.targetId !== id),
selectedAgentId: state.selectedAgentId === id ? null : state.selectedAgentId,
};
}),
setStatus: (id, status) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent || agent.status === status) return {};
const agents = new Map(state.agents);
agents.set(id, {
...agent,
status,
speech: status === "speaking" ? agent.speech : null,
currentTool: status === "tool_calling" ? agent.currentTool : null,
});
return { agents };
}),
setTool: (id, tool) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const agents = new Map(state.agents);
agents.set(id, {
...agent,
currentTool: tool,
toolCallCount: tool ? agent.toolCallCount + 1 : agent.toolCallCount,
toolHistory: tool ? [tool.name, ...agent.toolHistory].slice(0, 12) : agent.toolHistory,
});
return { agents };
}),
setSpeech: (id, text) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const agents = new Map(state.agents);
agents.set(id, { ...agent, speech: text });
return { agents };
}),
startWalk: (id, to, toZone, opts) =>
set((state) => {
const agent = state.agents.get(id);
if (!agent) return {};
const from = agent.movement
? interpolatePath(agent.movement.path, agent.movement.elapsed / agent.movement.duration)
: agent.position;
const path = planWalkPath(from, to, agent.zone, toZone);
const agents = new Map(state.agents);
agents.set(id, {
...agent,
position: { ...from },
movement: {
path,
duration: walkDuration(path),
elapsed: 0,
toZone,
arriveStatus: opts?.arriveStatus,
despawnOnArrive: opts?.despawnOnArrive,
},
});
return { agents };
}),
walkHome: (id, arriveStatus = "idle") => {
const agent = get().agents.get(id);
if (!agent) return;
get().startWalk(id, agent.homePosition, agent.homeZone, { arriveStatus });
},
addLink: (link) =>
set((state) => {
const others = state.links.filter(
(l) => !(l.sourceId === link.sourceId && l.targetId === link.targetId),
);
return { links: [...others, link] };
}),
removeLink: (sourceId, targetId) =>
set((state) => ({
links: state.links.filter((l) => !(l.sourceId === sourceId && l.targetId === targetId)),
})),
startMeeting: (id, agentIds) => {
set((state) => ({
meetings: [...state.meetings, { id, agentIds, center: { ...MEETING_CENTER } }],
}));
const seats = meetingSeats(agentIds.length);
agentIds.forEach((agentId, i) => {
get().startWalk(agentId, seats[i], "meeting", { arriveStatus: "idle" });
});
// pairwise meeting links
for (let i = 0; i < agentIds.length; i++) {
for (let j = i + 1; j < agentIds.length; j++) {
get().addLink({ sourceId: agentIds[i], targetId: agentIds[j], strength: 0.85, kind: "meeting" });
}
}
},
endMeeting: (id) => {
const meeting = get().meetings.find((m) => m.id === id);
if (!meeting) return;
set((state) => ({ meetings: state.meetings.filter((m) => m.id !== id) }));
const ids = meeting.agentIds;
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
get().removeLink(ids[i], ids[j]);
}
}
ids.forEach((agentId) => {
const agent = get().agents.get(agentId);
if (agent) get().walkHome(agentId);
});
},
tick: (dt) =>
set((state) => {
let agents: Map<string, VisualAgent> | null = null;
const toRemove: string[] = [];
for (const [id, agent] of state.agents) {
if (!agent.movement) continue;
if (!agents) agents = new Map(state.agents);
const elapsed = agent.movement.elapsed + dt;
if (elapsed >= agent.movement.duration) {
const end = agent.movement.path[agent.movement.path.length - 1];
if (agent.movement.despawnOnArrive) {
toRemove.push(id);
continue;
}
agents.set(id, {
...agent,
position: { ...end },
zone: agent.movement.toZone,
status: agent.movement.arriveStatus ?? agent.status,
movement: null,
});
} else {
const pos = interpolatePath(agent.movement.path, elapsed / agent.movement.duration);
agents.set(id, {
...agent,
position: pos,
movement: { ...agent.movement, elapsed },
});
}
}
if (toRemove.length > 0 && agents) {
for (const id of toRemove) agents.delete(id);
}
return {
clock: state.clock + dt,
...(agents ? { agents } : {}),
...(toRemove.length > 0
? {
links: state.links.filter(
(l) => !toRemove.includes(l.sourceId) && !toRemove.includes(l.targetId),
),
selectedAgentId:
state.selectedAgentId && toRemove.includes(state.selectedAgentId)
? null
: state.selectedAgentId,
}
: {}),
};
}),
}));