diff --git a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx index 2013c35c..0af0b06a 100644 --- a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx @@ -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(null); const keysRef = useRef({ up: false, down: false, left: false, right: false }); + const cameraRef = useRef({ x: 0, y: 0 }); + const selectedIdRef = useRef(null); + const [selectedId, setSelectedId] = useState(null); const simRef = useRef(null); if (simRef.current === null) simRef.current = new OfficeSimulation(); const inputs = useMemo(() => { 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): 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) => { + const point = worldPointFrom(event); + if (!point) return; + const hit = simRef.current!.hitTest(point); + setSelectedId(hit ? hit.input.id : null); + }; + + const onCanvasMouseMove = (event: MouseEvent) => { + const canvas = canvasRef.current; + const point = worldPointFrom(event); + if (!canvas || !point) return; + canvas.style.cursor = simRef.current!.hitTest(point) ? "pointer" : "default"; + }; + return ( -
- +
+ + {selectedAgent && setSelectedId(null)} />}
); } diff --git a/AgentCompany-New/src/renderer/src/pages/office/AgentDetailCard.tsx b/AgentCompany-New/src/renderer/src/pages/office/AgentDetailCard.tsx new file mode 100644 index 00000000..6ddade5c --- /dev/null +++ b/AgentCompany-New/src/renderer/src/pages/office/AgentDetailCard.tsx @@ -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 = { + active: "Active", + thinking: "Thinking", + busy: "Busy", + idle: "Idle", +}; + +const ACTIVITY_TONE: Record = { + 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 ( +
+
+ +
+
{agent.name}
+
{agent.title}
+
+ +
+
+ {agent.department} + {humanizeToken(agent.roleLevel)} + {ACTIVITY_LABEL[agent.activity]} +
+ {agent.taskTitle && ( +
+ + + {agent.taskTitle} + +
+ )} +
+ ); +} diff --git a/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts index aeff8346..183f41d9 100644 --- a/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts +++ b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts @@ -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, diff --git a/AgentCompany-New/src/renderer/src/pages/office/simulation.ts b/AgentCompany-New/src/renderer/src/pages/office/simulation.ts index 9391a154..8b82c196 100644 --- a/AgentCompany-New/src/renderer/src/pages/office/simulation.ts +++ b/AgentCompany-New/src/renderer/src/pages/office/simulation.ts @@ -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); diff --git a/AgentCompany-New/tests/e2e/office.spec.ts b/AgentCompany-New/tests/e2e/office.spec.ts new file mode 100644 index 00000000..1c26d372 --- /dev/null +++ b/AgentCompany-New/tests/e2e/office.spec.ts @@ -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(); + } +}); diff --git a/AgentCompany-New/tests/unit/office-simulation.test.ts b/AgentCompany-New/tests/unit/office-simulation.test.ts index 986f5769..daa73d9f 100644 --- a/AgentCompany-New/tests/unit/office-simulation.test.ts +++ b/AgentCompany-New/tests/unit/office-simulation.test.ts @@ -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}`)));