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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 13:51:04 -04:00
1 parent 202418f81c
commit ccd698602e
15 files changed
+164 -69

No files matched your search

+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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": "今天",
+10 -21
View File
@@ -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;
}
+18 -4
View File
@@ -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)),
+3 -3
View File
@@ -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);
}