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:
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);
|
||||
|
||||
@@ -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}`)));
|
||||
|
||||
Reference in new issue
Block a user