From 2447bfba9ca618db7177c5b5b0739ff819468386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 30 Jun 2026 22:34:58 -0400 Subject: [PATCH] Rebuild office with rich pixel-art rendering inspired by claude-office MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Characters now have round heads, headphones, eyes, shirts, pants, and shoes — proper pixel-art people like claude-office. Desks are detailed wooden tables with monitors showing task text, keyboards, and coffee cups. The wall has a city skyline window, analog clock, whiteboard with TODO items, and an employee-of-the-month painting. Active agents bob and show speech bubbles with their current task. Floor has tile grid. --- .../src/renderer/src/pages/OfficePage.tsx | 260 ++++----------- .../src/pages/office/office-renderer.ts | 301 ++++++++++++++++++ 2 files changed, 369 insertions(+), 192 deletions(-) create mode 100644 AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts diff --git a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx index 6bbd2503..dec24215 100644 --- a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx @@ -1,30 +1,10 @@ -import { useEffect, useRef, useMemo } from "react"; +import { useEffect, useRef, useMemo, useCallback } from "react"; import type { PageProps } from "./types"; import { activeOrgId } from "./types"; +import { drawScene, type OfficeAgent } from "./office/office-renderer"; -type Status = "active" | "thinking" | "busy" | "idle"; - -interface OfficeAgent { - id: string; - name: string; - department: string; - status: Status; - task: string | null; - x: number; - y: number; - targetX: number; - targetY: number; - skin: string; - shirt: string; - frame: number; - facing: "left" | "right"; -} - -const SKIN = ["#f4c28b", "#e8b472", "#c68e5b", "#a56c3e", "#8b5232"]; -const SHIRT = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4", "#d97b5b", "#5b9bd9"]; -const DESK_COLOR = "#c8b89a"; -const FLOOR_COLOR = "#f0ebe3"; -const WALL_COLOR = "#e8e3db"; +const FACE_COLORS = ["#4a90d9", "#50b866", "#9b59b6", "#e67e22", "#e74c3c", "#3498db", "#1abc9c", "#f39c12"]; +const SHIRT_COLORS = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4", "#d97b5b", "#7fd95b"]; function hash(s: string): number { let h = 0; @@ -32,24 +12,16 @@ function hash(s: string): number { return Math.abs(h); } -function stableColor(palette: string[], name: string): string { - return palette[hash(name) % palette.length]; -} - -function deriveStatus(presence: Array<{ principalType: string; principalId: string; status: string }>, runs: Array<{ agentId: string | null; state: string }>, agentId: string): Status { - if (runs.some((r) => r.agentId === agentId && r.state === "running")) return "active"; - const p = presence.find((r) => r.principalType === "agent" && r.principalId === agentId); - if (p?.status === "thinking") return "thinking"; - if (p?.status === "waiting_approval" || p?.status === "busy") return "busy"; - if (p?.status === "active" || p?.status === "online") return "active"; - return "idle"; +function pick(palette: string[], seed: string): string { + return palette[hash(seed) % palette.length]; } export function OfficePage({ snapshot }: PageProps) { const orgId = activeOrgId(snapshot); const canvasRef = useRef(null); const agentsRef = useRef([]); - const animRef = useRef(0); + const animRef = useRef(0); + const startRef = useRef(Date.now()); const orgAgents = useMemo(() => snapshot.agents.filter((a) => a.orgId === orgId), [snapshot.agents, orgId]); const orgTasks = useMemo(() => snapshot.tasks.filter((t) => t.orgId === orgId), [snapshot.tasks, orgId]); @@ -58,194 +30,98 @@ export function OfficePage({ snapshot }: PageProps) { const existing = agentsRef.current; agentsRef.current = orgAgents.map((a, i) => { const prev = existing.find((e) => e.id === a.id); - const status = deriveStatus(snapshot.chatPresence, snapshot.taskRuns, a.id); + const status = resolveStatus(snapshot.chatPresence, snapshot.taskRuns, a.id); const task = orgTasks.find((t) => t.assigneeAgentId === a.id && t.status === "in_progress")?.title ?? null; - const deskX = 80 + (i % 6) * 120; - const deskY = 100 + Math.floor(i / 6) * 140; return { id: a.id, name: a.name, + role: a.role, department: a.department, status, task, - x: prev?.x ?? deskX, - y: prev?.y ?? deskY, - targetX: status === "idle" ? deskX + (hash(a.id + String(Date.now()).slice(0, -4)) % 60) - 30 : deskX, - targetY: status === "idle" ? deskY + (hash(a.name + String(Date.now()).slice(0, -4)) % 40) - 20 : deskY, - skin: stableColor(SKIN, a.name), - shirt: stableColor(SHIRT, a.name + a.role), - frame: prev?.frame ?? 0, - facing: prev?.facing ?? "right", + x: prev?.x ?? 100 + i * 120, + y: prev?.y ?? 200, + targetX: prev?.targetX ?? 100 + i * 120, + targetY: prev?.targetY ?? 200, + deskX: 0, + deskY: 0, + faceColor: pick(FACE_COLORS, a.name), + shirtColor: pick(SHIRT_COLORS, a.name + a.role), + frame: prev?.frame ?? hash(a.id) % 100, + facing: prev?.facing ?? 1, }; }); }, [orgAgents, orgTasks, snapshot.chatPresence, snapshot.taskRuns]); - useEffect(() => { + const render = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); - let running = true; - const tick = () => { - if (!running) return; - const w = canvas.clientWidth * 2; - const h = canvas.clientHeight * 2; - canvas.width = w; - canvas.height = h; - ctx.scale(2, 2); + const time = Date.now() - startRef.current; + const agents = agentsRef.current; - drawOffice(ctx, canvas.clientWidth, canvas.clientHeight, agentsRef.current); - - for (const agent of agentsRef.current) { - const dx = agent.targetX - agent.x; - const dy = agent.targetY - agent.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist > 1) { - const speed = 0.6; - agent.x += (dx / dist) * speed; - agent.y += (dy / dist) * speed; - agent.frame += 0.08; - agent.facing = dx > 0 ? "right" : "left"; - } + for (const agent of agents) { + const dx = agent.targetX - agent.x; + const dy = agent.targetY - agent.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 1) { + agent.x += (dx / dist) * 0.8; + agent.y += (dy / dist) * 0.8; + agent.frame += 0.1; + agent.facing = dx > 0 ? 1 : -1; } + } - animRef.current = requestAnimationFrame(tick); - }; - animRef.current = requestAnimationFrame(tick); - return () => { running = false; cancelAnimationFrame(animRef.current); }; + drawScene(ctx, w, h, agents, time); + animRef.current = requestAnimationFrame(render); }, []); useEffect(() => { - const interval = setInterval(() => { + animRef.current = requestAnimationFrame(render); + return () => cancelAnimationFrame(animRef.current); + }, [render]); + + useEffect(() => { + const wander = setInterval(() => { for (const agent of agentsRef.current) { if (agent.status === "idle") { - const baseX = 80 + (agentsRef.current.indexOf(agent) % 6) * 120; - const baseY = 100 + Math.floor(agentsRef.current.indexOf(agent) / 6) * 140; - agent.targetX = baseX + (Math.random() * 80 - 40); - agent.targetY = baseY + (Math.random() * 60 - 30); + agent.targetX = agent.deskX + DESK_CENTER_OFFSET + (Math.random() * 100 - 50); + agent.targetY = agent.deskY + 56 + (Math.random() * 40 - 20); + } else { + agent.targetX = agent.deskX + DESK_CENTER_OFFSET; + agent.targetY = agent.deskY + 56; } } - }, 3000); - return () => clearInterval(interval); + }, 4000); + return () => clearInterval(wander); }, []); return ( -
- +
+
); } -function drawOffice(ctx: CanvasRenderingContext2D, w: number, h: number, agents: OfficeAgent[]) { - ctx.fillStyle = FLOOR_COLOR; - ctx.fillRect(0, 0, w, h); +const DESK_CENTER_OFFSET = 50; - ctx.fillStyle = WALL_COLOR; - ctx.fillRect(0, 0, w, 50); - - for (let x = 20; x < w; x += 160) { - drawWindow(ctx, x, 8, 60, 36); - } - - const deptGroups = new Map(); - for (const a of agents) { - if (!deptGroups.has(a.department)) deptGroups.set(a.department, []); - deptGroups.get(a.department)!.push(a); - } - - let groupIdx = 0; - for (const [dept, group] of deptGroups) { - const gx = 40 + (groupIdx % 3) * ((w - 60) / 3); - const gy = 60 + Math.floor(groupIdx / 3) * 150; - - ctx.fillStyle = "rgba(0,0,0,0.04)"; - ctx.fillRect(gx, gy, (w - 100) / 3, 130); - ctx.strokeStyle = "rgba(0,0,0,0.06)"; - ctx.strokeRect(gx, gy, (w - 100) / 3, 130); - - ctx.fillStyle = "#999"; - ctx.font = "9px monospace"; - ctx.fillText(dept.toUpperCase(), gx + 6, gy + 12); - - for (let i = 0; i < group.length; i++) { - const dx = gx + 30 + i * 70; - const dy = gy + 50; - drawDesk(ctx, dx, dy); - } - groupIdx++; - } - - const sorted = [...agents].sort((a, b) => a.y - b.y); - for (const agent of sorted) { - drawPixelPerson(ctx, agent); - } -} - -function drawWindow(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { - ctx.fillStyle = "#b8d4e8"; - ctx.fillRect(x, y, w, h); - ctx.strokeStyle = "#d0c8c0"; - ctx.lineWidth = 1; - ctx.strokeRect(x, y, w, h); - ctx.beginPath(); - ctx.moveTo(x + w / 2, y); - ctx.lineTo(x + w / 2, y + h); - ctx.moveTo(x, y + h / 2); - ctx.lineTo(x + w, y + h / 2); - ctx.stroke(); -} - -function drawDesk(ctx: CanvasRenderingContext2D, x: number, y: number) { - ctx.fillStyle = DESK_COLOR; - ctx.fillRect(x - 16, y, 32, 4); - ctx.fillStyle = "#a89880"; - ctx.fillRect(x - 4, y + 4, 2, 12); - ctx.fillRect(x + 2, y + 4, 2, 12); - - ctx.fillStyle = "#444"; - ctx.fillRect(x - 8, y - 12, 16, 10); - ctx.fillStyle = "#6af"; - ctx.fillRect(x - 7, y - 11, 14, 8); -} - -function drawPixelPerson(ctx: CanvasRenderingContext2D, agent: OfficeAgent) { - const x = Math.round(agent.x); - const y = Math.round(agent.y); - const bobble = agent.status === "active" ? Math.sin(agent.frame * 3) * 1.5 : 0; - const legOffset = Math.abs(Math.sin(agent.frame * 2)) > 0.5 ? 1 : 0; - - ctx.fillStyle = agent.skin; - ctx.fillRect(x - 3, y - 18 + bobble, 6, 6); - - ctx.fillStyle = "#333"; - const eyeX = agent.facing === "right" ? 1 : -2; - ctx.fillRect(x + eyeX, y - 16 + bobble, 1, 1); - ctx.fillRect(x + eyeX + 2, y - 16 + bobble, 1, 1); - - ctx.fillStyle = agent.shirt; - ctx.fillRect(x - 4, y - 12 + bobble, 8, 8); - - ctx.fillStyle = "#555"; - ctx.fillRect(x - 3, y - 4 + bobble, 3, 5 + legOffset); - ctx.fillRect(x, y - 4 + bobble, 3, 5 + (1 - legOffset)); - - if (agent.status === "active") { - ctx.fillStyle = "#34d399"; - ctx.beginPath(); - ctx.arc(x + 5, y - 19 + bobble, 2, 0, Math.PI * 2); - ctx.fill(); - } else if (agent.status === "thinking") { - ctx.fillStyle = "#818cf8"; - ctx.fillText("?", x + 4, y - 18 + bobble); - } - - ctx.fillStyle = "#777"; - ctx.font = "8px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText(agent.name.split(" ")[0], x, y + 8); - ctx.textAlign = "start"; +function resolveStatus( + presence: Array<{ principalType: string; principalId: string; status: string }>, + runs: Array<{ agentId: string | null; state: string }>, + agentId: string, +): OfficeAgent["status"] { + if (runs.some((r) => r.agentId === agentId && r.state === "running")) return "active"; + const p = presence.find((r) => r.principalType === "agent" && r.principalId === agentId); + if (p?.status === "thinking") return "thinking"; + if (p?.status === "waiting_approval" || p?.status === "busy") return "busy"; + if (p?.status === "active" || p?.status === "online") return "active"; + return "idle"; } diff --git a/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts new file mode 100644 index 00000000..ba562b91 --- /dev/null +++ b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts @@ -0,0 +1,301 @@ +export interface OfficeAgent { + id: string; + name: string; + role: string; + department: string; + status: "active" | "thinking" | "busy" | "idle"; + task: string | null; + x: number; + y: number; + targetX: number; + targetY: number; + deskX: number; + deskY: number; + faceColor: string; + shirtColor: string; + frame: number; + facing: number; +} + +const WALL_H = 120; +const TILE = 24; +const DESK_W = 100; +const DESK_H = 48; +const CHAR_H = 44; + +const WALL = "#3a3a4a"; +const WALL_TILE = "#424252"; +const FLOOR = "#4a4a5a"; +const FLOOR_TILE = "#52526a"; +const DESK_TOP = "#8b7355"; +const DESK_LEG = "#6b5335"; +const MONITOR_BODY = "#555"; +const MONITOR_SCREEN = "#222"; +const MONITOR_ACTIVE = "#1a3a2a"; + +export function drawScene(ctx: CanvasRenderingContext2D, w: number, h: number, agents: OfficeAgent[], time: number) { + drawFloor(ctx, w, h); + drawWall(ctx, w); + drawWallDecorations(ctx, w, time); + + const groups = groupByDepartment(agents); + let col = 0; + let row = 0; + const cols = Math.max(1, Math.floor((w - 40) / (DESK_W + 60))); + for (const [, group] of groups) { + for (const agent of group) { + agent.deskX = 60 + (col % cols) * (DESK_W + 60); + agent.deskY = WALL_H + 40 + row * (DESK_H + 90); + if (agent.status !== "idle") { + agent.targetX = agent.deskX + DESK_W / 2; + agent.targetY = agent.deskY + DESK_H + 8; + } + col++; + if (col % cols === 0) row++; + } + if (col % cols !== 0) { row++; col = Math.ceil(col / cols) * cols; } + } + + for (const [, group] of groups) { + for (const agent of group) drawDeskUnit(ctx, agent, time); + } + + const sorted = [...agents].sort((a, b) => a.y - b.y); + for (const agent of sorted) drawCharacter(ctx, agent, time); +} + +function drawFloor(ctx: CanvasRenderingContext2D, w: number, h: number) { + ctx.fillStyle = FLOOR; + ctx.fillRect(0, 0, w, h); + ctx.strokeStyle = FLOOR_TILE; + ctx.lineWidth = 0.5; + for (let x = 0; x < w; x += TILE) { ctx.beginPath(); ctx.moveTo(x, WALL_H); ctx.lineTo(x, h); ctx.stroke(); } + for (let y = WALL_H; y < h; y += TILE) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } +} + +function drawWall(ctx: CanvasRenderingContext2D, w: number) { + ctx.fillStyle = WALL; + ctx.fillRect(0, 0, w, WALL_H); + ctx.strokeStyle = WALL_TILE; + ctx.lineWidth = 0.5; + for (let x = 0; x < w; x += TILE) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, WALL_H); ctx.stroke(); } + for (let y = 0; y < WALL_H; y += TILE / 2) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } + ctx.fillStyle = "#333"; + ctx.fillRect(0, WALL_H - 4, w, 4); +} + +function drawWallDecorations(ctx: CanvasRenderingContext2D, w: number, time: number) { + drawCityWindow(ctx, 30, 12, 90, 70); + if (w > 400) drawCityWindow(ctx, w - 130, 12, 90, 70); + drawClock(ctx, w / 2, 50, 28, time); + if (w > 600) drawWhiteboard(ctx, w / 2 + 80, 14, 120, 76); + if (w > 300) drawPainting(ctx, 160, 16, 60, 50); +} + +function drawCityWindow(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { + ctx.fillStyle = "#1a1a3a"; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = "#666"; + ctx.lineWidth = 2; + ctx.strokeRect(x, y, w, h); + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(x + w / 2, y); ctx.lineTo(x + w / 2, y + h); ctx.stroke(); + for (let bx = x + 8; bx < x + w - 8; bx += 14) { + for (let by = y + h - 50; by < y + h - 6; by += 8) { + ctx.fillStyle = Math.random() > 0.4 ? "#ff9" : "#553"; + ctx.fillRect(bx, by, 4, 4); + } + } + ctx.fillStyle = "#446"; + ctx.fillRect(x + 2, y + 2, 8, 8); + ctx.fillStyle = "#ff8"; + ctx.beginPath(); ctx.arc(x + 6, y + 6, 4, 0, Math.PI * 2); ctx.fill(); +} + +function drawClock(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, time: number) { + ctx.fillStyle = "#eee"; + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = "#333"; + ctx.lineWidth = 2; + ctx.stroke(); + for (let i = 0; i < 12; i++) { + const a = (i / 12) * Math.PI * 2 - Math.PI / 2; + ctx.fillStyle = "#333"; + ctx.fillRect(cx + Math.cos(a) * (r - 5) - 1, cy + Math.sin(a) * (r - 5) - 1, 2, 2); + } + const min = (time * 0.001) % (Math.PI * 2); + const hr = min / 12; + ctx.strokeStyle = "#333"; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(hr - Math.PI / 2) * (r * 0.5), cy + Math.sin(hr - Math.PI / 2) * (r * 0.5)); ctx.stroke(); + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(min - Math.PI / 2) * (r * 0.7), cy + Math.sin(min - Math.PI / 2) * (r * 0.7)); ctx.stroke(); +} + +function drawWhiteboard(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { + ctx.fillStyle = "#f5f5f0"; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = "#888"; + ctx.lineWidth = 2; + ctx.strokeRect(x, y, w, h); + ctx.fillStyle = "#333"; + ctx.font = "bold 7px monospace"; + ctx.fillText("TODO", x + 6, y + 12); + ctx.font = "6px monospace"; + ctx.fillStyle = "#555"; + const items = ["Review PRD", "Ship v1.0", "Fix bug #42", "Deploy staging"]; + items.forEach((item, i) => ctx.fillText(`• ${item}`, x + 6, y + 22 + i * 10)); +} + +function drawPainting(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { + ctx.fillStyle = "#654"; + ctx.fillRect(x - 3, y - 3, w + 6, h + 6); + ctx.fillStyle = "#1a2a3a"; + ctx.fillRect(x, y, w, h); + ctx.fillStyle = "#2a4a3a"; + ctx.fillRect(x, y + h * 0.6, w, h * 0.4); + ctx.fillStyle = "#ff8"; + ctx.beginPath(); ctx.arc(x + w * 0.7, y + h * 0.25, 6, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = "#888"; + ctx.font = "6px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("EMPLOYEE", x + w / 2, y + h + 10); + ctx.fillText("OF THE MONTH", x + w / 2, y + h + 18); + ctx.textAlign = "start"; +} + +function drawDeskUnit(ctx: CanvasRenderingContext2D, agent: OfficeAgent, _time: number) { + const dx = agent.deskX; + const dy = agent.deskY; + ctx.fillStyle = DESK_TOP; + ctx.fillRect(dx, dy, DESK_W, 6); + ctx.fillStyle = DESK_LEG; + ctx.fillRect(dx + 4, dy + 6, 4, DESK_H - 6); + ctx.fillRect(dx + DESK_W - 8, dy + 6, 4, DESK_H - 6); + ctx.fillRect(dx + DESK_W / 2 - 2, dy + 6, 4, DESK_H - 10); + + const mx = dx + DESK_W / 2 - 16; + const my = dy - 28; + ctx.fillStyle = MONITOR_BODY; + ctx.fillRect(mx, my, 32, 24); + ctx.fillStyle = agent.status === "active" ? MONITOR_ACTIVE : MONITOR_SCREEN; + ctx.fillRect(mx + 2, my + 2, 28, 18); + ctx.fillStyle = MONITOR_BODY; + ctx.fillRect(mx + 13, my + 24, 6, 4); + ctx.fillRect(mx + 8, my + 28, 16, 2); + + if (agent.status === "active" && agent.task) { + ctx.fillStyle = "#4f8"; + ctx.font = "5px monospace"; + const taskText = agent.task.slice(0, 18); + ctx.fillText(taskText, mx + 4, my + 12); + } + + ctx.fillStyle = "#666"; + ctx.fillRect(dx + DESK_W / 2 - 12, dy - 2, 24, 3); + ctx.fillRect(dx + DESK_W / 2 - 14, dy - 3, 2, 4); + ctx.fillRect(dx + DESK_W / 2 + 12, dy - 3, 2, 4); + + if (agent.status !== "idle") { + ctx.fillStyle = "#86654a"; + ctx.fillRect(dx + DESK_W - 20, dy - 8, 8, 8); + ctx.fillStyle = "#f5f5f0"; + ctx.fillRect(dx + DESK_W - 19, dy - 7, 6, 4); + ctx.fillStyle = "#86654a"; + ctx.fillRect(dx + DESK_W - 17, dy - 2, 2, 2); + } +} + +function drawCharacter(ctx: CanvasRenderingContext2D, agent: OfficeAgent, time: number) { + const x = Math.round(agent.x); + const y = Math.round(agent.y); + const active = agent.status === "active" || agent.status === "thinking"; + const bob = active ? Math.sin(time * 0.003 + agent.frame) * 1.5 : 0; + + ctx.fillStyle = "#222"; + ctx.beginPath(); + ctx.arc(x - 10, y - CHAR_H + 6 + bob, 12, Math.PI, 0); + ctx.lineTo(x + 10, y - CHAR_H + 6 + bob); + ctx.arc(x + 10, y - CHAR_H + 6 + bob, 12, 0, Math.PI, true); + ctx.fill(); + + ctx.fillStyle = agent.faceColor; + ctx.beginPath(); ctx.arc(x, y - CHAR_H + 10 + bob, 10, 0, Math.PI * 2); ctx.fill(); + + ctx.fillStyle = "#fff"; + ctx.fillRect(x - 4, y - CHAR_H + 8 + bob, 3, 3); + ctx.fillRect(x + 1, y - CHAR_H + 8 + bob, 3, 3); + ctx.fillStyle = "#111"; + ctx.fillRect(x - 3, y - CHAR_H + 9 + bob, 2, 2); + ctx.fillRect(x + 2, y - CHAR_H + 9 + bob, 2, 2); + + ctx.fillStyle = "#222"; + ctx.fillRect(x - 14, y - CHAR_H + 4 + bob, 4, 10); + ctx.fillRect(x + 10, y - CHAR_H + 4 + bob, 4, 10); + ctx.beginPath(); ctx.arc(x - 12, y - CHAR_H + 4 + bob, 5, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(x + 12, y - CHAR_H + 4 + bob, 5, 0, Math.PI * 2); ctx.fill(); + + ctx.fillStyle = agent.shirtColor; + roundRect(ctx, x - 10, y - CHAR_H + 20 + bob, 20, 16, 3); + + ctx.fillStyle = agent.faceColor; + ctx.fillRect(x - 12, y - CHAR_H + 22 + bob, 4, 10); + ctx.fillRect(x + 8, y - CHAR_H + 22 + bob, 4, 10); + + ctx.fillStyle = "#444"; + ctx.fillRect(x - 6, y - CHAR_H + 36 + bob, 5, 8); + ctx.fillRect(x + 1, y - CHAR_H + 36 + bob, 5, 8); + ctx.fillStyle = "#333"; + ctx.fillRect(x - 7, y - CHAR_H + 43 + bob, 6, 3); + ctx.fillRect(x + 1, y - CHAR_H + 43 + bob, 6, 3); + + if (active) { + ctx.fillStyle = agent.status === "active" ? "#34d399" : "#818cf8"; + ctx.beginPath(); ctx.arc(x + 12, y - CHAR_H + 2 + bob, 3, 0, Math.PI * 2); ctx.fill(); + } + + if (agent.task && active) drawSpeechBubble(ctx, x, y - CHAR_H - 10, agent.task.slice(0, 24)); + + ctx.fillStyle = "#ccc"; + ctx.font = "9px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(agent.name.split(" ")[0], x, y + 6); + ctx.textAlign = "start"; +} + +function drawSpeechBubble(ctx: CanvasRenderingContext2D, x: number, y: number, text: string) { + ctx.font = "7px monospace"; + const tw = ctx.measureText(text).width + 12; + const bx = x - tw / 2; + ctx.fillStyle = "#fff"; + roundRect(ctx, bx, y - 14, tw, 16, 4); + ctx.fillStyle = "#fff"; + ctx.beginPath(); + ctx.moveTo(x - 3, y + 2); + ctx.lineTo(x, y + 7); + ctx.lineTo(x + 3, y + 2); + ctx.fill(); + ctx.fillStyle = "#333"; + ctx.textAlign = "center"; + ctx.fillText(text, x, y - 3); + ctx.textAlign = "start"; +} + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.fill(); +} + +function groupByDepartment(agents: OfficeAgent[]): Map { + const m = new Map(); + for (const a of agents) { + if (!m.has(a.department)) m.set(a.department, []); + m.get(a.department)!.push(a); + } + return m; +}