From ccd698602e768fc486136a56ee6b6fee7274a3c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 13:51:04 -0400 Subject: [PATCH] S3.8: office people show real agent state: running, queued, cooling down, blocked or idle, from sessions, task holds and pending approvals Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/i18n/labels.ts | 4 +-- src/renderer/src/i18n/zh.ts | 4 +-- src/renderer/src/pages/OfficePage.tsx | 31 ++++++---------- .../src/pages/office/AgentDetailCard.tsx | 19 +++++----- src/renderer/src/pages/office/agent-state.ts | 33 +++++++++++++++++ .../src/pages/office/office-renderer.ts | 23 ++++++------ src/renderer/src/pages/office/office-types.ts | 5 ++- src/renderer/src/pages/office/props.ts | 22 +++++++++--- .../src/pages/office/scene-renderer.ts | 13 +++---- src/renderer/src/pages/office/simulation.ts | 6 ++-- tests/e2e/keyboard.spec.ts | 3 +- tests/e2e/office.spec.ts | 16 +++++++++ tests/unit/office-agent-state.test.ts | 35 +++++++++++++++++++ tests/unit/office-iso.test.ts | 2 +- tests/unit/office-simulation.test.ts | 17 ++++----- 15 files changed, 164 insertions(+), 69 deletions(-) create mode 100644 src/renderer/src/pages/office/agent-state.ts create mode 100644 tests/unit/office-agent-state.test.ts diff --git a/src/renderer/src/i18n/labels.ts b/src/renderer/src/i18n/labels.ts index da783253..61470cfa 100644 --- a/src/renderer/src/i18n/labels.ts +++ b/src/renderer/src/i18n/labels.ts @@ -20,8 +20,8 @@ const APPROVAL_KINDS: Record = { }; export const approvalKindLabel = (kind: string) => labelOf("approval kind", APPROVAL_KINDS, kind); -const OFFICE_ACTIVITIES: Record = { active: t("Active"), thinking: t("Thinking"), busy: t("Busy"), idle: t("Idle") }; -export const officeActivityLabel = (activity: string) => labelOf("office activity", OFFICE_ACTIVITIES, activity); +const OFFICE_STATES: Record = { running: t("Running"), queued: t("Queued"), cooldown: t("Cooling down"), blocked: t("Blocked"), idle: t("Idle") }; +export const officeStateLabel = (state: string) => labelOf("office state", OFFICE_STATES, state); const APPROVAL_STATES: Record = { pending: t("Pending"), approved: t("Approved"), declined: t("Declined"), superseded: t("Superseded") }; export const approvalStateLabel = (state: string) => labelOf("approval state", APPROVAL_STATES, state); diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 222a4f42..c65a0e2d 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -256,12 +256,13 @@ export const zh: Record = { "Base URL is required.": "请填写基础 URL。", "Billing": "用量账单", "Blocked": "受阻", + "Cooling down": "冷却中", + "Waiting for your approval.": "正在等待你的审批。", "Board": "看板", "Browse": "浏览", "Budget": "预算", "Budget cap": "预算上限", "Budget utilization": "预算使用率", - "Busy": "忙碌", "By agent": "按智能体", "CEO": "CEO", "CEO OFFICE": "CEO 办公室", @@ -579,7 +580,6 @@ export const zh: Record = { "Team": "团队", "Tests": "测试", "Theme": "主题", - "Thinking": "思考中", "Progress": "进度", "Title": "标题", "Today": "今天", diff --git a/src/renderer/src/pages/OfficePage.tsx b/src/renderer/src/pages/OfficePage.tsx index eb5cc62a..55bf07c9 100644 --- a/src/renderer/src/pages/OfficePage.tsx +++ b/src/renderer/src/pages/OfficePage.tsx @@ -5,12 +5,13 @@ import { activeOrgId, requireActiveEmployee } from "./types"; import { hashString, renderOffice, type RenderPerson } from "./office/office-renderer"; import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation"; import { AgentDetailCard } from "./office/AgentDetailCard"; -import { officeActivityLabel } from "../i18n/labels"; +import { officeStateLabel } from "../i18n/labels"; import { cardPositionFor, CARD_FALLBACK_H } from "./office/agent-card-position"; import { isoFrame, isoPixelSize, pickAt, toIso, type IsoFrame } from "./office/iso"; import { dayPart, season } from "../shared/palette-clock"; import { pixelScale } from "../shared/pixel-scale"; -import type { AgentActivity, Camera, OfficeAgentInput } from "./office/office-types"; +import type { Camera, OfficeAgentInput } from "./office/office-types"; +import { officeStatus } from "./office/agent-state"; import { t } from "../i18n/i18n"; const FPS_INTERVAL = 1000 / 24; @@ -44,7 +45,8 @@ export function OfficePage({ snapshot }: PageProps) { const orgAgents = snapshot.agents .filter((agent) => agent.orgId === orgId && agent.lifecycle === "active") .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); - const orgTasks = snapshot.tasks.filter((task) => task.orgId === orgId && isBusinessTask(task)); + const allTasks = snapshot.tasks.filter((task) => task.orgId === orgId); + const orgTasks = allTasks.filter(isBusinessTask); return orgAgents.map((agent) => ({ id: agent.id, name: agent.name, @@ -52,10 +54,10 @@ export function OfficePage({ snapshot }: PageProps) { title: agent.title, department: agent.department, roleLevel: agent.roleLevel, - activity: resolveActivity(snapshot.chatPresence, snapshot.taskRuns, agent.id), + status: officeStatus(agent.id, { runs: snapshot.taskRuns, tasks: allTasks, approvals: snapshot.approvals }), taskTitle: orgTasks.find((task) => task.assigneeAgentId === agent.id && task.status === "in_progress")?.title ?? null, })); - }, [snapshot.agents, snapshot.tasks, snapshot.chatPresence, snapshot.taskRuns, orgId]); + }, [snapshot.agents, snapshot.tasks, snapshot.approvals, snapshot.taskRuns, orgId]); useEffect(() => { simRef.current!.sync(inputs); @@ -149,7 +151,7 @@ export function OfficePage({ snapshot }: PageProps) { const people: RenderPerson[] = sim.agents.map((agent) => ({ id: agent.input.id, name: agent.input.name, - activity: agent.input.activity, + state: agent.input.status.state, x: agent.x, y: agent.y, desk: agent.isCeo ? sim.plan.ceoDesk : sim.plan.desks[agent.deskIndex!].bounds, @@ -165,7 +167,7 @@ export function OfficePage({ snapshot }: PageProps) { people.push({ id: `board:${sim.player.employeeId}`, name: sim.player.name, - activity: "idle", + state: "idle", x: sim.player.x, y: sim.player.y, desk: null, @@ -262,22 +264,9 @@ export function OfficePage({ snapshot }: PageProps) { style={{ width: 200 }} > - {inputs.map((input) => )} + {inputs.map((input) => )} ); } - -function resolveActivity( - presence: Array<{ principalType: string; principalId: string; status: string }>, - runs: Array<{ agentId: string | null; state: string }>, - agentId: string, -): AgentActivity { - if (runs.some((run) => run.agentId === agentId && run.state === "running")) return "active"; - const record = presence.find((row) => row.principalType === "agent" && row.principalId === agentId); - if (record?.status === "thinking") return "thinking"; - if (record?.status === "waiting_approval" || record?.status === "busy") return "busy"; - if (record?.status === "active" || record?.status === "online") return "active"; - return "idle"; -} diff --git a/src/renderer/src/pages/office/AgentDetailCard.tsx b/src/renderer/src/pages/office/AgentDetailCard.tsx index fd9182e0..36ebd43c 100644 --- a/src/renderer/src/pages/office/AgentDetailCard.tsx +++ b/src/renderer/src/pages/office/AgentDetailCard.tsx @@ -1,13 +1,15 @@ import { Avatar, Icon, IconButton, Pill } from "../../shared/ui"; -import { officeActivityLabel, roleLevelLabel } from "../../i18n/labels"; +import { officeStateLabel, roleLevelLabel } from "../../i18n/labels"; import { t } from "../../i18n/i18n"; -import type { AgentActivity, OfficeAgentInput } from "./office-types"; +import { TaskHoldNotice } from "../work/TaskHoldNotice"; +import type { OfficeAgentInput } from "./office-types"; +import type { OfficeState } from "./agent-state"; - -const ACTIVITY_TONE: Record = { - active: "green", - thinking: "violet", - busy: "amber", +const STATE_TONE: Record = { + running: "green", + queued: undefined, + cooldown: "amber", + blocked: "red", idle: undefined, }; @@ -39,8 +41,9 @@ export function AgentDetailCard({ agent, onClose }: { agent: OfficeAgentInput; o
{agent.department} {roleLevelLabel(agent.roleLevel)} - {officeActivityLabel(agent.activity)} + {officeStateLabel(agent.status.state)}
+ {agent.status.hold &&
} {agent.taskTitle && (
diff --git a/src/renderer/src/pages/office/agent-state.ts b/src/renderer/src/pages/office/agent-state.ts new file mode 100644 index 00000000..8e5820d8 --- /dev/null +++ b/src/renderer/src/pages/office/agent-state.ts @@ -0,0 +1,33 @@ +import type { TaskHold } from "../../../../shared/domain"; +import { t } from "../../i18n/i18n"; + +/** What an agent in the office is doing, from real records: a running session, work held for the operator or a provider cooldown, a queued session, or nothing. */ +export type OfficeState = "running" | "blocked" | "cooldown" | "queued" | "idle"; + +export interface OfficeStatus { + state: OfficeState; + /** What holds a blocked or cooling-down agent. */ + hold: TaskHold | null; +} + +interface Inputs { + runs: Array<{ agentId: string | null; state: string }>; + tasks: Array<{ id: string; assigneeAgentId: string | null; status: string; hold: TaskHold | null }>; + approvals: Array<{ sourceEntityType: string | null; sourceEntityId: string | null; state: string }>; +} + +/** The one state an agent shows: running beats blocked, which beats a cooldown, which beats a queued session. */ +export function officeStatus(agentId: string, inputs: Inputs): OfficeStatus { + const runs = inputs.runs.filter((run) => run.agentId === agentId); + if (runs.some((run) => run.state === "running")) return { state: "running", hold: null }; + const open = inputs.tasks.filter((task) => task.assigneeAgentId === agentId && task.status !== "done"); + const holds = open.filter((task) => task.hold !== null).map((task) => task.hold!); + const operator = holds.find((hold) => hold.waitingOn === "operator"); + if (operator) return { state: "blocked", hold: operator }; + const awaitingApproval = inputs.approvals.some((approval) => approval.state === "pending" && approval.sourceEntityType === "task" && open.some((task) => task.id === approval.sourceEntityId)); + if (awaitingApproval) return { state: "blocked", hold: { reason: t("Waiting for your approval."), waitingOn: "operator", resumesAt: null } }; + const provider = holds.find((hold) => hold.waitingOn === "provider"); + if (provider) return { state: "cooldown", hold: provider }; + if (runs.some((run) => run.state === "queued")) return { state: "queued", hold: null }; + return { state: "idle", hold: null }; +} diff --git a/src/renderer/src/pages/office/office-renderer.ts b/src/renderer/src/pages/office/office-renderer.ts index 61ce5518..4e7535e7 100644 --- a/src/renderer/src/pages/office/office-renderer.ts +++ b/src/renderer/src/pages/office/office-renderer.ts @@ -1,4 +1,5 @@ -import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types"; +import type { Camera, FloorPlan, Rect } from "./office-types"; +import type { OfficeState } from "./agent-state"; import { drawBackWalls, drawGround, sceneDrawables, type Drawable } from "./scene-renderer"; import { depthOf, toIso, type IsoFrame } from "./iso"; import { skyScene, weatherFor } from "./sky"; @@ -8,7 +9,7 @@ import type { DayPart, Season } from "../../shared/palette-clock"; export interface RenderPerson { id: string; name: string; - activity: AgentActivity; + state: OfficeState; x: number; y: number; desk: Rect | null; @@ -41,10 +42,12 @@ export interface OfficeFrame { selectedId: string | null; } -const ACTIVITY_DOT: Partial> = { - active: "#34d399", - thinking: "#818cf8", - busy: "#f59e0b", +/** Marker over the head: green running, grey queued, amber cooling down, red blocked; none when idle. */ +const STATE_MARK: Partial> = { + running: "#34d399", + queued: "#9aa3b2", + cooldown: "#f59e0b", + blocked: "#ef4444", }; /** The day part washes over the whole floor: warm at dawn and dusk, blue at night. */ @@ -78,8 +81,8 @@ export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasEle drawGround(sctx, input.frame, input.plan); drawBackWalls(sctx, input.frame, input.plan, { sky: skyScene(input.clock), weather: weatherFor(input.clock), clock: input.clock, timeMs: input.timeMs }); - const lit = new Set(input.people.filter((person) => person.desk && (person.activity === "active" || person.activity === "thinking")).map((person) => person.desk!)); - const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, lit, input.timeMs), ...input.people.map((person) => personDrawable(input, person))]; + const screens = new Map(input.people.filter((person) => person.desk).map((person) => [person.desk!, person.state])); + const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, screens, input.timeMs), ...input.people.map((person) => personDrawable(input, person))]; items.sort((a, b) => a.depth - b.depth); for (const item of items) item.draw(sctx); @@ -109,7 +112,7 @@ function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable { ctx.fillRect(Math.round(feet.x) - 14, Math.round(feet.y) - 2, 2, 2); ctx.fillRect(Math.round(feet.x) + 12, Math.round(feet.y) - 2, 2, 2); } - const working = (person.activity === "active" || person.activity === "thinking") && person.walkPhase === 0; + const working = person.state === "running" && person.walkPhase === 0; drawPerson(ctx, feet.x, feet.y, person.appearance, { facing: person.facing, walkPhase: person.walkPhase, @@ -118,7 +121,7 @@ function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable { timeMs: input.timeMs, animSeed: person.animSeed, }, person.isCeo); - const dot = ACTIVITY_DOT[person.activity]; + const dot = STATE_MARK[person.state]; if (dot) { ctx.fillStyle = dot; ctx.fillRect(Math.round(feet.x) + 8, Math.round(feet.y) - 40, 4, 4); diff --git a/src/renderer/src/pages/office/office-types.ts b/src/renderer/src/pages/office/office-types.ts index 2fa9d3c4..31504888 100644 --- a/src/renderer/src/pages/office/office-types.ts +++ b/src/renderer/src/pages/office/office-types.ts @@ -1,3 +1,4 @@ +import type { OfficeStatus } from "./agent-state"; export interface Vec { x: number; y: number; @@ -61,8 +62,6 @@ export interface FloorPlan { entrance: Vec; } -export type AgentActivity = "active" | "thinking" | "busy" | "idle"; - /** Snapshot-derived facts the office simulation needs about one agent. */ export interface OfficeAgentInput { id: string; @@ -71,7 +70,7 @@ export interface OfficeAgentInput { title: string; department: string; roleLevel: string; - activity: AgentActivity; + status: OfficeStatus; taskTitle: string | null; } diff --git a/src/renderer/src/pages/office/props.ts b/src/renderer/src/pages/office/props.ts index 8ee9c7a2..6f18579f 100644 --- a/src/renderer/src/pages/office/props.ts +++ b/src/renderer/src/pages/office/props.ts @@ -1,3 +1,4 @@ +import type { OfficeState } from "./agent-state"; import type { Rect } from "./office-types"; import { drawBox, setSouthFaceTransform, toIso, type IsoFrame } from "./iso"; @@ -31,14 +32,14 @@ function hand(ctx: CanvasRenderingContext2D, cx: number, cy: number, angle: numb } } -/** A desk monitor's screen on its south face: scrolling code and a blinking cursor while its agent works, a standby light otherwise. */ -export function drawMonitorScreen(ctx: CanvasRenderingContext2D, frame: IsoFrame, monitor: Rect, z: number, height: number, on: boolean, timeMs: number, seed: number): void { +/** A desk monitor's screen on its south face: scrolling code while running, a progress dot for queued, an amber sweep for a cooldown, a blinking red mark when blocked, a standby light when idle. */ +export function drawMonitorScreen(ctx: CanvasRenderingContext2D, frame: IsoFrame, monitor: Rect, z: number, height: number, state: OfficeState, timeMs: number, seed: number): void { ctx.save(); setSouthFaceTransform(ctx, frame, monitor.y + monitor.h, z + height); const glass = { x: monitor.x + 2, y: 2, w: monitor.w - 4, h: height - 4 }; - ctx.fillStyle = on ? "#0f2a1c" : "#20242c"; + ctx.fillStyle = state === "running" ? "#0f2a1c" : state === "blocked" ? "#2a1012" : "#20242c"; ctx.fillRect(glass.x, glass.y, glass.w, glass.h); - if (on) { + if (state === "running") { const scroll = Math.floor(timeMs / 350); ctx.fillStyle = "#37d38a"; for (let row = 0; row < 4; row++) { @@ -46,6 +47,19 @@ export function drawMonitorScreen(ctx: CanvasRenderingContext2D, frame: IsoFrame ctx.fillRect(glass.x + 1 + (row % 2) * 2, glass.y + 1 + row * 2, width, 1); } if (Math.floor(timeMs / 500) % 2 === 0) ctx.fillRect(glass.x + glass.w - 3, glass.y + glass.h - 2, 2, 1); + } else if (state === "queued") { + ctx.fillStyle = "#9aa3b2"; + for (let dot = 0; dot <= Math.floor(timeMs / 400) % 3; dot++) ctx.fillRect(glass.x + 2 + dot * 3, glass.y + glass.h / 2, 2, 1); + } else if (state === "cooldown") { + ctx.fillStyle = "#e0a23a"; + ctx.fillRect(glass.x + 1, glass.y + glass.h / 2, Math.max(1, (glass.w - 2) * (1 - ((timeMs / 4000) % 1))), 1); + } else if (state === "blocked") { + if (Math.floor(timeMs / 500) % 2 === 0) { + ctx.fillStyle = "#ef4444"; + const cx = Math.round(glass.x + glass.w / 2); + ctx.fillRect(cx, glass.y + 1, 1, glass.h - 4); + ctx.fillRect(cx, glass.y + glass.h - 2, 1, 1); + } } else { ctx.fillStyle = "#e0a23a"; ctx.fillRect(glass.x + glass.w - 2, glass.y + glass.h - 1, 1, 1); diff --git a/src/renderer/src/pages/office/scene-renderer.ts b/src/renderer/src/pages/office/scene-renderer.ts index cc7d0634..1f5f9182 100644 --- a/src/renderer/src/pages/office/scene-renderer.ts +++ b/src/renderer/src/pages/office/scene-renderer.ts @@ -1,3 +1,4 @@ +import type { OfficeState } from "./agent-state"; import type { FloorPlan, Rect, RoomSpec } from "./office-types"; import { WALL } from "./floor-plan"; import { drawWeather, drawWindowSky, type SkyScene, type Weather } from "./sky"; @@ -179,8 +180,8 @@ function centerDepth(rect: Rect): number { return depthOf({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 }); } -/** Interior walls, furniture and desks as blocks, each sorted by depth against the people; `lit` desks have their screen on, and `timeMs` animates screens and steam. */ -export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, lit: Set, timeMs: number): SceneDrawable[] { +/** Interior walls, furniture and desks as blocks, each sorted by depth against the people; `screens` gives each desk's screen its agent's state, and `timeMs` animates screens and steam. */ +export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, screens: Map, timeMs: number): SceneDrawable[] { const items: SceneDrawable[] = []; // Long blocks go in pieces so each sorts against nearby people on its own. const block = (foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect) => { @@ -197,7 +198,7 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, // CEO office: rug, executive desk, sofa, plant. block({ x: 32, y: 96, w: 24, h: 72 }, 10, faces("#7a4a41")); plant(280, 40); - deskBlocks(plan.ceoDesk, true, lit.has(plan.ceoDesk), timeMs).forEach((item) => items.push(item(frame))); + deskBlocks(plan.ceoDesk, true, screens.get(plan.ceoDesk) ?? "idle", timeMs).forEach((item) => items.push(item(frame))); // Meeting room: table and chairs. block({ x: 392, y: 120, w: 160, h: 64 }, 12, faces("#75593b")); for (let i = 0; i < 3; i++) { @@ -229,12 +230,12 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, items.push({ foot: basin, depth: restingDepth(basin, sinks), draw: (ctx) => drawBasin(ctx, frame, basin, 14) }); } for (const [cx, cy] of [[312, 300], [632, 300], [904, 300]] as const) plant(cx, cy); - for (const desk of plan.desks) deskBlocks(desk.bounds, false, lit.has(desk.bounds), timeMs).forEach((item) => items.push(item(frame))); + for (const desk of plan.desks) deskBlocks(desk.bounds, false, screens.get(desk.bounds) ?? "idle", timeMs).forEach((item) => items.push(item(frame))); return items; } /** A desk, its monitor and its chair, each a separate block so a seated person sorts between them. */ -function deskBlocks(desk: Rect, executive: boolean, screenOn: boolean, timeMs: number): Array<(frame: IsoFrame) => SceneDrawable> { +function deskBlocks(desk: Rect, executive: boolean, screen: OfficeState, timeMs: number): Array<(frame: IsoFrame) => SceneDrawable> { const monitor = deskMonitor(desk); return [ ...split(desk).map((piece) => (frame: IsoFrame) => blockDrawable(frame, piece, 14, faces(executive ? PALETTE.deskExec : PALETTE.desk))), @@ -242,7 +243,7 @@ function deskBlocks(desk: Rect, executive: boolean, screenOn: boolean, timeMs: n const shell = blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: PALETTE.monitor, east: shade(PALETTE.monitor, 0.7) }, 14, desk); return { foot: monitor, depth: shell.depth, draw: (ctx) => { shell.draw(ctx); - drawMonitorScreen(ctx, frame, monitor, 14, 12, screenOn, timeMs, Math.round(desk.x + desk.y)); + drawMonitorScreen(ctx, frame, monitor, 14, 12, screen, timeMs, Math.round(desk.x + desk.y)); } }; }, (frame) => blockDrawable(frame, { x: desk.x + desk.w / 2 - 8, y: desk.y + desk.h + 6, w: 16, h: 12 }, 9, faces(PALETTE.chair)), diff --git a/src/renderer/src/pages/office/simulation.ts b/src/renderer/src/pages/office/simulation.ts index ee6f954f..ebe541a3 100644 --- a/src/renderer/src/pages/office/simulation.ts +++ b/src/renderer/src/pages/office/simulation.ts @@ -116,7 +116,7 @@ export class OfficeSimulation { switch (agent.behavior.kind) { case "seated": { agent.settledMs += dt; - if (agent.input.activity === "idle" && agent.settledMs >= SETTLE_MS && this.random() < dt / MEAN_TRIP_INTERVAL_MS) { + if (agent.input.status.state === "idle" && agent.settledMs >= SETTLE_MS && this.random() < dt / MEAN_TRIP_INTERVAL_MS) { agent.settledMs = 0; this.startTrip(agent); } @@ -160,7 +160,7 @@ export class OfficeSimulation { conveneMeeting(): number { const spots = this.plan.amenities.filter((spot) => spot.kind === "meeting"); const candidates = this.agents.filter( - (agent) => agent.input.activity === "idle" && agent.behavior.kind === "seated" && agent.settledMs >= SETTLE_MS, + (agent) => agent.input.status.state === "idle" && agent.behavior.kind === "seated" && agent.settledMs >= SETTLE_MS, ); if (candidates.length < 2 || spots.length < 2) return 0; const size = Math.min(spots.length, candidates.length, 2 + Math.floor(this.random() * 3)); @@ -309,7 +309,7 @@ export class OfficeSimulation { settledMs: prev?.settledMs ?? 0, }; const onBreak = agent.behavior.kind === "dwelling" || (agent.behavior.kind === "walking" && agent.behavior.destination === "amenity"); - const workStarted = input.activity !== "idle" && onBreak; + const workStarted = input.status.state !== "idle" && onBreak; if (workStarted || (planRebuilt && prev && (agent.x !== seat.x || agent.y !== seat.y))) { this.sendHome(agent); } diff --git a/tests/e2e/keyboard.spec.ts b/tests/e2e/keyboard.spec.ts index 982221bc..5bc0fb9a 100644 --- a/tests/e2e/keyboard.spec.ts +++ b/tests/e2e/keyboard.spec.ts @@ -154,7 +154,8 @@ test("the list width, office people and chart values all work from the keyboard await page.getByRole("button", { name: "Office", exact: true }).click(); await expect(page.getByRole("img", { name: /People here: 1./ })).toBeVisible(); const people = page.getByRole("combobox", { name: "Show a person" }); - await people.selectOption({ label: "CEO, Idle" }); + // The only person is the CEO, listed after the empty choice with whatever state their real work gives them. + await people.selectOption({ index: 1 }); await expect(page.getByRole("dialog", { name: "Agent CEO" })).toBeVisible(); await people.selectOption({ label: "Show a person…" }); await expect(page.getByRole("dialog", { name: "Agent CEO" })).toHaveCount(0); diff --git a/tests/e2e/office.spec.ts b/tests/e2e/office.spec.ts index eb4bd765..8eb1c95e 100644 --- a/tests/e2e/office.spec.ts +++ b/tests/e2e/office.spec.ts @@ -3,6 +3,7 @@ import { mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { randomUUID } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; import { buildFloorPlan } from "../../src/renderer/src/pages/office/floor-plan"; import { cameraFor } from "../../src/renderer/src/pages/office/simulation"; import { isoFrame, isoPixelSize, toIso } from "../../src/renderer/src/pages/office/iso"; @@ -78,6 +79,21 @@ test("the office floor opens agent details on click", async () => { await page.keyboard.press("Escape"); await expect(card).not.toBeVisible(); + // The office shows real state: a blocked task makes its agent blocked, with the reason on the card. + const db = new DatabaseSync(join(profileDir, "agentcompany.db")); + const blocked = db.prepare("update tasks set blocked_reason = 'needs the staging key' where assignee_agent_id = (select id from agents where name = 'CEO') and status <> 'done'").run(); + db.close(); + expect(blocked.changes).toBeGreaterThan(0); + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]!.webContents.send("agentcompany:renderer-event", { type: "snapshot-changed", version: Number.MAX_SAFE_INTEGER }); + }); + await expect(page.getByLabel("Show a person").locator("option", { hasText: "CEO, Blocked" })).toHaveCount(1); + await page.getByLabel("Show a person").selectOption({ label: "CEO, Blocked" }); + await expect(card.getByText("Blocked: needs the staging key")).toBeVisible(); + await expect(card.getByText("Waiting on you.")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(card).not.toBeVisible(); + // Clicking empty corridor floor selects nothing. const corridor = onScreen(plan, box!, ratio, { x: 500, y: 320 }, 0); await page.mouse.click(corridor.x, corridor.y); diff --git a/tests/unit/office-agent-state.test.ts b/tests/unit/office-agent-state.test.ts new file mode 100644 index 00000000..9e69dcac --- /dev/null +++ b/tests/unit/office-agent-state.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "vitest"; +import { officeStatus } from "../../src/renderer/src/pages/office/agent-state"; +import type { TaskHold } from "../../src/shared/domain"; + +const blocked: TaskHold = { reason: "Blocked: needs the staging key", waitingOn: "operator", resumesAt: null }; +const cooling: TaskHold = { reason: "Provider is cooling down", waitingOn: "provider", resumesAt: "2026-10-09T14:00:00.000Z" }; +const task = (hold: TaskHold | null, status = "in_progress", assigneeAgentId: string | null = "agt_a", id = "tsk_1") => ({ id, assigneeAgentId, status, hold }); +const pending = (taskId: string, state = "pending") => ({ sourceEntityType: "task", sourceEntityId: taskId, state }); +const none = { runs: [], tasks: [], approvals: [] }; + +test("each state comes from real records", () => { + expect(officeStatus("agt_a", none)).toEqual({ state: "idle", hold: null }); + expect(officeStatus("agt_a", { ...none, runs: [{ agentId: "agt_a", state: "queued" }] }).state).toBe("queued"); + expect(officeStatus("agt_a", { ...none, runs: [{ agentId: "agt_a", state: "running" }] }).state).toBe("running"); + expect(officeStatus("agt_a", { ...none, tasks: [task(cooling)] })).toEqual({ state: "cooldown", hold: cooling }); + expect(officeStatus("agt_a", { ...none, tasks: [task(blocked)] })).toEqual({ state: "blocked", hold: blocked }); + // A pending approval on the agent's open task blocks the agent before any hold is written. + expect(officeStatus("agt_a", { ...none, tasks: [task(null)], approvals: [pending("tsk_1")] })).toEqual({ state: "blocked", hold: { reason: "Waiting for your approval.", waitingOn: "operator", resumesAt: null } }); +}); + +test("running beats blocked, blocked beats cooldown, cooldown beats queued", () => { + const queued = { agentId: "agt_a", state: "queued" }; + expect(officeStatus("agt_a", { ...none, runs: [{ agentId: "agt_a", state: "running" }], tasks: [task(blocked)] }).state).toBe("running"); + expect(officeStatus("agt_a", { ...none, runs: [queued], tasks: [task(cooling), task(blocked)] }).state).toBe("blocked"); + expect(officeStatus("agt_a", { ...none, runs: [queued], tasks: [task(cooling)], approvals: [pending("tsk_1")] }).state).toBe("blocked"); + expect(officeStatus("agt_a", { ...none, runs: [queued], tasks: [task(cooling)] }).state).toBe("cooldown"); +}); + +test("only this agent's open work counts", () => { + expect(officeStatus("agt_a", { ...none, runs: [{ agentId: "agt_b", state: "running" }] }).state).toBe("idle"); + expect(officeStatus("agt_a", { ...none, tasks: [task(blocked, "in_progress", "agt_b")] }).state).toBe("idle"); + expect(officeStatus("agt_a", { ...none, tasks: [task(blocked, "done")] }).state).toBe("idle"); + expect(officeStatus("agt_a", { ...none, tasks: [task(null, "in_progress", "agt_b")], approvals: [pending("tsk_1")] }).state).toBe("idle"); + expect(officeStatus("agt_a", { ...none, tasks: [task(null)], approvals: [pending("tsk_1", "approved")] }).state).toBe("idle"); +}); diff --git a/tests/unit/office-iso.test.ts b/tests/unit/office-iso.test.ts index 30f6f676..b0d794ff 100644 --- a/tests/unit/office-iso.test.ts +++ b/tests/unit/office-iso.test.ts @@ -84,7 +84,7 @@ describe("office depth order", () => { test("every piece of the scene sorts by a footprint no longer than 32 on either side, so a person beside a long block is never painted over", () => { const plan = buildFloorPlan(12); - const items = sceneDrawables(isoFrame(plan.world), plan, "winter", new Set(), 0); + const items = sceneDrawables(isoFrame(plan.world), plan, "winter", new Map(), 0); const oversized = items.filter((item) => item.foot.w > 32 || item.foot.h > 32).map((item) => item.foot); expect(oversized).toEqual([]); // A person at the left basin sorts after both the counter piece and the basin in front of them. diff --git a/tests/unit/office-simulation.test.ts b/tests/unit/office-simulation.test.ts index 86fa850e..5ef285cc 100644 --- a/tests/unit/office-simulation.test.ts +++ b/tests/unit/office-simulation.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "vitest"; import { OfficeSimulation, cameraFor, type MoveInput } from "../../src/renderer/src/pages/office/simulation"; -import type { AgentActivity, OfficeAgentInput } from "../../src/renderer/src/pages/office/office-types"; +import type { OfficeAgentInput } from "../../src/renderer/src/pages/office/office-types"; +import type { OfficeState } from "../../src/renderer/src/pages/office/agent-state"; const IDLE: MoveInput = { up: false, down: false, left: false, right: false }; -function agentInput(id: string, roleLevel = "staff", activity: AgentActivity = "idle"): OfficeAgentInput { +function agentInput(id: string, roleLevel = "staff", state: OfficeState = "idle"): OfficeAgentInput { return { id, name: `Agent ${id}`, @@ -12,7 +13,7 @@ function agentInput(id: string, roleLevel = "staff", activity: AgentActivity = " title: "Engineer", department: "Engineering", roleLevel, - activity, + status: { state, hold: null }, taskTitle: null, }; } @@ -121,10 +122,10 @@ describe("office simulation", () => { expect(agent.carrying).toBe("coffee"); }); - test("agents with a run, thinking or busy never leave their desk", () => { - for (const activity of ["active", "thinking", "busy"] as const) { + test("agents running, queued, cooling down or blocked never leave their desk", () => { + for (const state of ["running", "queued", "cooldown", "blocked"] as const) { const sim = new OfficeSimulation(() => 0); - sim.sync([agentInput("ceo", "c_suite", activity), agentInput("a", "staff", activity)]); + sim.sync([agentInput("ceo", "c_suite", state), agentInput("a", "staff", state)]); for (let i = 0; i < 6000; i++) { sim.step(16, IDLE); for (const agent of sim.agents) expect(agent.behavior.kind).toBe("seated"); @@ -144,7 +145,7 @@ describe("office simulation", () => { sim.sync([agentInput("a")]); expect(sim.agents[0].behavior.kind === "walking" && sim.agents[0].behavior.destination).toBe("amenity"); - sim.sync([agentInput("a", "staff", "active")]); + sim.sync([agentInput("a", "staff", "running")]); const working = sim.agents[0]; expect(working.behavior.kind === "walking" && working.behavior.destination).toBe("seat"); for (let i = 0; i < 6000 && working.behavior.kind !== "seated"; i++) { @@ -157,7 +158,7 @@ describe("office simulation", () => { test("only idle agents gather around the table, and anyone with a run stays at their desk", () => { const sim = new OfficeSimulation(() => 0); - sim.sync([agentInput("a"), agentInput("b"), agentInput("ceo", "c_suite", "active")]); + sim.sync([agentInput("a"), agentInput("b"), agentInput("ceo", "c_suite", "running")]); // Nobody has settled yet: a meeting cannot be convened. expect(sim.conveneMeeting()).toBe(0); for (const agent of sim.agents) agent.settledMs = 30_000;