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 <noreply@anthropic.com>
This commit is contained in:
1 parent
202418f81c
commit
ccd698602e
15 files changed
+164
-69
No files matched your search
@@ -20,8 +20,8 @@ const APPROVAL_KINDS: Record<string, string> = {
|
||||
};
|
||||
export const approvalKindLabel = (kind: string) => labelOf("approval kind", APPROVAL_KINDS, kind);
|
||||
|
||||
const OFFICE_ACTIVITIES: Record<string, string> = { 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<string, string> = { 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<string, string> = { pending: t("Pending"), approved: t("Approved"), declined: t("Declined"), superseded: t("Superseded") };
|
||||
export const approvalStateLabel = (state: string) => labelOf("approval state", APPROVAL_STATES, state);
|
||||
|
||||
@@ -256,12 +256,13 @@ export const zh: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"Team": "团队",
|
||||
"Tests": "测试",
|
||||
"Theme": "主题",
|
||||
"Thinking": "思考中",
|
||||
"Progress": "进度",
|
||||
"Title": "标题",
|
||||
"Today": "今天",
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
<option value="">{t("Show a person…")}</option>
|
||||
{inputs.map((input) => <option key={input.id} value={input.id}>{t("{name}, {activity}", { name: input.name, activity: officeActivityLabel(input.activity) })}</option>)}
|
||||
{inputs.map((input) => <option key={input.id} value={input.id}>{t("{name}, {activity}", { name: input.name, activity: officeStateLabel(input.status.state) })}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -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<AgentActivity, "green" | "violet" | "amber" | undefined> = {
|
||||
active: "green",
|
||||
thinking: "violet",
|
||||
busy: "amber",
|
||||
const STATE_TONE: Record<OfficeState, "green" | "amber" | "red" | undefined> = {
|
||||
running: "green",
|
||||
queued: undefined,
|
||||
cooldown: "amber",
|
||||
blocked: "red",
|
||||
idle: undefined,
|
||||
};
|
||||
|
||||
@@ -39,8 +41,9 @@ export function AgentDetailCard({ agent, onClose }: { agent: OfficeAgentInput; o
|
||||
<div className="row" style={{ gap: 6, marginTop: 10, flexWrap: "wrap" }}>
|
||||
<Pill>{agent.department}</Pill>
|
||||
<Pill>{roleLevelLabel(agent.roleLevel)}</Pill>
|
||||
<Pill tone={ACTIVITY_TONE[agent.activity]} dot>{officeActivityLabel(agent.activity)}</Pill>
|
||||
<Pill tone={STATE_TONE[agent.status.state]} dot>{officeStateLabel(agent.status.state)}</Pill>
|
||||
</div>
|
||||
{agent.status.hold && <div style={{ marginTop: 10 }}><TaskHoldNotice hold={agent.status.hold} /></div>}
|
||||
{agent.taskTitle && (
|
||||
<div className="row" style={{ gap: 7, marginTop: 12, alignItems: "flex-start" }}>
|
||||
<Icon name="work" color="var(--text-3)" />
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<Record<AgentActivity, string>> = {
|
||||
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<Record<OfficeState, string>> = {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Rect>, 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<Rect, OfficeState>, 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)),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user