Open an agent profile card by clicking them on the floor

Canvas clicks hit-test against the exact positions the renderer draws -
frontmost person wins - and select the agent: a floating card in the app's
design language shows their name, title, department, level, live status, and
current task, with a selection ring under their feet. Escape, the close
button, or clicking empty floor dismisses it; hovering a person shows a
pointer cursor. The floor now seats only lifecycle-active agents, matching
how the backend wakes and assigns work. Covered by a hit-test unit test and
a dedicated office e2e that clicks the CEO through the real floor-plan math.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-06 21:58:35 -04:00
1 parent 8e0716021f
commit e8c8414613
6 files changed
+233 -6

No files matched your search

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import type { PageProps } from "./types";
import { activeOrgId, requireActiveEmployee } from "./types";
import { hashString, renderOffice, type RenderPerson } from "./office/office-renderer";
import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation";
import type { AgentActivity, OfficeAgentInput } from "./office/office-types";
import { AgentDetailCard } from "./office/AgentDetailCard";
import type { AgentActivity, Camera, OfficeAgentInput, Vec } from "./office/office-types";
const FPS_INTERVAL = 1000 / 24;
@@ -23,12 +24,15 @@ export function OfficePage({ snapshot }: PageProps) {
const employee = requireActiveEmployee(snapshot);
const canvasRef = useRef<HTMLCanvasElement>(null);
const keysRef = useRef<MoveInput>({ up: false, down: false, left: false, right: false });
const cameraRef = useRef<Camera>({ x: 0, y: 0 });
const selectedIdRef = useRef<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const simRef = useRef<OfficeSimulation | null>(null);
if (simRef.current === null) simRef.current = new OfficeSimulation();
const inputs = useMemo<OfficeAgentInput[]>(() => {
const orgAgents = snapshot.agents
.filter((agent) => agent.orgId === orgId)
.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);
return orgAgents.map((agent) => ({
@@ -47,6 +51,20 @@ export function OfficePage({ snapshot }: PageProps) {
simRef.current!.sync(inputs);
}, [inputs]);
const selectedAgent = selectedId ? inputs.find((input) => input.id === selectedId) ?? null : null;
useEffect(() => {
selectedIdRef.current = selectedId;
}, [selectedId]);
useEffect(() => {
const onEscape = (event: KeyboardEvent) => {
if (event.key === "Escape" && selectedIdRef.current) setSelectedId(null);
};
window.addEventListener("keydown", onEscape);
return () => window.removeEventListener("keydown", onEscape);
}, []);
useEffect(() => {
simRef.current!.syncPlayer(employee.id, employee.displayName);
}, [employee.id, employee.displayName]);
@@ -112,6 +130,7 @@ export function OfficePage({ snapshot }: PageProps) {
sim.step(elapsed, keysRef.current);
const focus = sim.player ? { x: sim.player.x, y: sim.player.y } : { x: sim.plan.world.w / 2, y: viewH / 2 };
const camera = cameraFor(sim.plan.world, viewW, viewH, focus);
cameraRef.current = camera;
const people: RenderPerson[] = sim.agents.map((agent) => ({
id: agent.input.id,
name: agent.input.name,
@@ -144,7 +163,7 @@ export function OfficePage({ snapshot }: PageProps) {
carrying: null,
});
}
renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now, new Date());
renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now, new Date(), selectedIdRef.current);
};
raf = requestAnimationFrame(tick);
@@ -154,9 +173,39 @@ export function OfficePage({ snapshot }: PageProps) {
};
}, []);
const worldPointFrom = (event: MouseEvent<HTMLCanvasElement>): Vec | null => {
const canvas = canvasRef.current;
if (!canvas) return null;
const bounds = canvas.getBoundingClientRect();
return {
x: event.clientX - bounds.left + cameraRef.current.x,
y: event.clientY - bounds.top + cameraRef.current.y,
};
};
const onCanvasClick = (event: MouseEvent<HTMLCanvasElement>) => {
const point = worldPointFrom(event);
if (!point) return;
const hit = simRef.current!.hitTest(point);
setSelectedId(hit ? hit.input.id : null);
};
const onCanvasMouseMove = (event: MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
const point = worldPointFrom(event);
if (!canvas || !point) return;
canvas.style.cursor = simRef.current!.hitTest(point) ? "pointer" : "default";
};
return (
<div style={{ flex: 1, overflow: "hidden" }}>
<canvas ref={canvasRef} style={{ width: "100%", height: "100%", display: "block" }} />
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<canvas
ref={canvasRef}
onClick={onCanvasClick}
onMouseMove={onCanvasMouseMove}
style={{ width: "100%", height: "100%", display: "block" }}
/>
{selectedAgent && <AgentDetailCard agent={selectedAgent} onClose={() => setSelectedId(null)} />}
</div>
);
}
@@ -0,0 +1,70 @@
import { Avatar, Icon, IconButton, Pill } from "../../shared/ui";
import { humanizeToken } from "../types";
import type { AgentActivity, OfficeAgentInput } from "./office-types";
const ACTIVITY_LABEL: Record<AgentActivity, string> = {
active: "Active",
thinking: "Thinking",
busy: "Busy",
idle: "Idle",
};
const ACTIVITY_TONE: Record<AgentActivity, "green" | "violet" | "amber" | undefined> = {
active: "green",
thinking: "violet",
busy: "amber",
idle: undefined,
};
/** Floating profile card for the agent selected on the office floor. */
export function AgentDetailCard({ agent, onClose }: { agent: OfficeAgentInput; onClose: () => void }) {
return (
<div
role="dialog"
aria-label={`Agent ${agent.name}`}
style={{
position: "absolute",
top: 16,
right: 16,
width: 264,
background: "var(--panel)",
border: "1px solid var(--border-strong)",
borderRadius: 12,
boxShadow: "0 12px 32px rgba(16,16,24,0.18)",
padding: 14,
}}
>
<div className="row" style={{ gap: 10, alignItems: "flex-start" }}>
<Avatar name={agent.name} size="lg" bot />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3, wordBreak: "break-word" }}>{agent.name}</div>
<div style={{ fontSize: 12, color: "var(--text-2)", marginTop: 1, wordBreak: "break-word" }}>{agent.title}</div>
</div>
<IconButton icon="x" label="Close agent details" onClick={onClose} />
</div>
<div className="row" style={{ gap: 6, marginTop: 10, flexWrap: "wrap" }}>
<Pill>{agent.department}</Pill>
<Pill>{humanizeToken(agent.roleLevel)}</Pill>
<Pill tone={ACTIVITY_TONE[agent.activity]} dot>{ACTIVITY_LABEL[agent.activity]}</Pill>
</div>
{agent.taskTitle && (
<div className="row" style={{ gap: 7, marginTop: 12, alignItems: "flex-start" }}>
<Icon name="work" size={13} color="var(--text-3)" />
<span
style={{
fontSize: 12,
color: "var(--text-2)",
lineHeight: 1.45,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{agent.taskTitle}
</span>
</div>
)}
</div>
);
}
@@ -44,6 +44,7 @@ export function renderOffice(
people: RenderPerson[],
timeMs: number,
clock: Date,
selectedId: string | null,
): void {
ctx.fillStyle = "#14161c";
ctx.fillRect(0, 0, viewW, viewH);
@@ -68,6 +69,13 @@ export function renderOffice(
const ordered = [...people].sort((a, b) => a.y - b.y);
for (const person of ordered) {
if (person.id === selectedId) {
ctx.strokeStyle = "rgba(94,106,210,0.9)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(Math.round(person.x), Math.round(person.y) + 2, 13, 6, 0, 0, Math.PI * 2);
ctx.stroke();
}
const working = (person.activity === "active" || person.activity === "thinking") && person.walkPhase === 0;
drawPerson(ctx, person.x, person.y, person.appearance, {
facing: person.facing,
@@ -79,6 +79,22 @@ export class OfficeSimulation {
if (this.player) this.stepPlayer(this.player, dt, input);
}
/**
* Returns the agent whose drawn body contains the world point, preferring
* the visually frontmost (largest y). Tests against the same positions the
* renderer draws, so clicks always match what is on screen.
*/
hitTest(point: Vec): AgentSim | null {
let hit: AgentSim | null = null;
for (const agent of this.agents) {
const inBody =
point.x >= agent.x - 10 && point.x <= agent.x + 10 &&
point.y >= agent.y - 36 && point.y <= agent.y + 4;
if (inBody && (hit === null || agent.y > hit.y)) hit = agent;
}
return hit;
}
private stepPlayer(player: PlayerSim, dt: number, input: MoveInput): void {
let dx = (input.right ? 1 : 0) - (input.left ? 1 : 0);
let dy = (input.down ? 1 : 0) - (input.up ? 1 : 0);
+72
View File
@@ -0,0 +1,72 @@
import { _electron as electron, expect, test } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { buildFloorPlan } from "../../src/renderer/src/pages/office/floor-plan";
import { cameraFor } from "../../src/renderer/src/pages/office/simulation";
test("the office floor opens agent details on click", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-office-${randomUUID()}`);
mkdirSync(profileDir, { recursive: true });
const app = await electron.launch({
args: [resolve("out/main/index.js")],
env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0" },
});
try {
const page = await app.firstWindow();
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole("button", { name: "Get started" }).click();
await page.getByLabel("API key").fill("office-e2e-key");
await page.getByLabel("Model").fill("gpt-5.4");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Company name").fill("Pixel Works");
await page.getByLabel("Your name").fill("Morgan Reyes");
await page.getByLabel("Description").fill("Office e2e coverage.");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Goal").fill("Verify the office floor.");
await page.getByLabel("Acceptance criteria").fill("Clicking an agent opens their card.");
await page.getByRole("button", { name: "Launch" }).click();
await expect(page.getByRole("button", { name: /Pixel Works/ })).toBeVisible();
await page.getByRole("button", { name: "Office" }).click();
const canvas = page.locator("canvas");
await expect(canvas).toBeVisible();
await page.waitForTimeout(300);
// Onboarding seeds exactly one agent (the CEO), so the floor plan and
// camera are fully determined by the same pure modules the app uses.
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
const plan = buildFloorPlan(0);
const camera = cameraFor(plan.world, box!.width, box!.height, plan.entrance);
const clickX = box!.x + plan.ceoSeat.x - camera.x;
const clickY = box!.y + plan.ceoSeat.y - 20 - camera.y;
await page.mouse.click(clickX, clickY);
const card = page.getByRole("dialog", { name: "Agent CEO" });
await expect(card).toBeVisible();
await expect(card.getByText("CEO", { exact: true })).toBeVisible();
await expect(card.getByText("Chief Executive Officer")).toBeVisible();
await expect(card.getByText("Executive", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(card).not.toBeVisible();
// Clicking empty corridor floor selects nothing.
await page.mouse.click(box!.x + 500 - camera.x, box!.y + 320 - camera.y);
await expect(page.getByRole("dialog", { name: /Agent / })).not.toBeVisible();
// WASD input is accepted without disturbing the page.
for (const key of ["KeyD", "KeyS", "KeyA", "KeyW"]) {
await page.keyboard.down(key);
await page.waitForTimeout(120);
await page.keyboard.up(key);
}
await expect(canvas).toBeVisible();
} finally {
await app.close();
}
});
@@ -94,6 +94,18 @@ describe("office simulation", () => {
expect(diagonal).toBeGreaterThan(straight * 0.7);
});
test("clicks hit the agent drawn at that spot, frontmost first", () => {
const sim = new OfficeSimulation();
sim.sync([agentInput("a"), agentInput("b")]);
const first = sim.agents[0];
expect(sim.hitTest({ x: first.x, y: first.y - 10 })!.input.id).toBe("a");
expect(sim.hitTest({ x: first.x - 60, y: first.y - 10 })).toBeNull();
const second = sim.agents[1];
second.x = first.x;
second.y = first.y + 6;
expect(sim.hitTest({ x: first.x, y: first.y })!.input.id).toBe("b");
});
test("camera follows the player and clamps at world edges", () => {
const sim = new OfficeSimulation();
sim.sync(Array.from({ length: 30 }, (_, i) => agentInput(`a${i}`)));