Replace office sidebar with full-page canvas-rendered office

The virtual office is now a dedicated page (nav: Office) with an HTML5
Canvas rendering animated pixel-art characters. Each agent is drawn as
a pixel person with deterministic skin/shirt colors, walking legs, and
a bobbing head when active. Idle agents wander randomly near their desk.
Active agents show a green dot, thinking agents show a question mark.
Departments are labeled zones with desks and monitors. Removed the old
sidebar component entirely.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-30 22:30:00 -04:00
1 parent 7a85f73dbb
commit f60ef60da1
6 files changed
+259 -227

No files matched your search

@@ -16,6 +16,7 @@ import { RunsPage } from "../pages/RunsPage";
import { MeetingsPage } from "../pages/MeetingsPage";
import { SprintsPage } from "../pages/SprintsPage";
import { SettingsPage } from "../pages/SettingsPage";
import { OfficePage } from "../pages/OfficePage";
export function App() {
const { snapshot, actions } = useSnapshot();
@@ -51,6 +52,7 @@ export function App() {
sprints: <SprintsPage {...pageProps} />,
approvals: <ApprovalsPage {...pageProps} />,
social: <SocialPage {...pageProps} />,
office: <OfficePage {...pageProps} />,
settings: <SettingsPage {...pageProps} />,
}[route];
@@ -0,0 +1,251 @@
import { useEffect, useRef, useMemo } from "react";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
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";
function hash(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
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";
}
export function OfficePage({ snapshot }: PageProps) {
const orgId = activeOrgId(snapshot);
const canvasRef = useRef<HTMLCanvasElement>(null);
const agentsRef = useRef<OfficeAgent[]>([]);
const animRef = useRef<number>(0);
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]);
useEffect(() => {
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 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,
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",
};
});
}, [orgAgents, orgTasks, snapshot.chatPresence, snapshot.taskRuns]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
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);
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";
}
}
animRef.current = requestAnimationFrame(tick);
};
animRef.current = requestAnimationFrame(tick);
return () => { running = false; cancelAnimationFrame(animRef.current); };
}, []);
useEffect(() => {
const interval = 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);
}
}
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div style={{ flex: 1, overflow: "hidden", background: FLOOR_COLOR }}>
<canvas
ref={canvasRef}
style={{ width: "100%", height: "100%", display: "block", imageRendering: "pixelated" }}
/>
</div>
);
}
function drawOffice(ctx: CanvasRenderingContext2D, w: number, h: number, agents: OfficeAgent[]) {
ctx.fillStyle = FLOOR_COLOR;
ctx.fillRect(0, 0, w, h);
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<string, OfficeAgent[]>();
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";
}
@@ -5,7 +5,6 @@ import type { IconName } from "./Icon";
import { OrgSwitcher } from "./OrgSwitcher";
import { SearchDialog } from "./SearchDialog";
import { TopbarSlotContext } from "./topbar-context";
import { VirtualOffice } from "./VirtualOffice";
export type RouteId =
| "home"
@@ -21,6 +20,7 @@ export type RouteId =
| "sprints"
| "approvals"
| "social"
| "office"
| "settings";
interface NavSpec {
@@ -31,7 +31,7 @@ interface NavSpec {
/** Routes whose pages own a full-height, multi-pane layout with their own
* internal scroll regions, so the content area must not scroll as one block. */
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "social", "runs"]);
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "social", "runs", "office"]);
/** The daily-driver surfaces — the core operating loop. */
const primaryNav: NavSpec[] = [
@@ -52,6 +52,7 @@ const secondaryNav: NavSpec[] = [
{ id: "meetings", label: "Meetings", icon: "calendar" },
{ id: "sprints", label: "Sprints", icon: "target" },
{ id: "social", label: "Social", icon: "social" },
{ id: "office", label: "Office", icon: "team" },
];
const allNav: NavSpec[] = [...primaryNav, ...secondaryNav];
@@ -74,7 +75,6 @@ export function Shell({
children: ReactNode;
}) {
const [searchOpen, setSearchOpen] = useState(false);
const [officeOpen, setOfficeOpen] = useState(false);
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
@@ -124,15 +124,6 @@ export function Shell({
</nav>
<div className="sidebar-spacer" />
<nav className="nav">
<button
type="button"
aria-label="Office"
className={`nav-item ${officeOpen ? "active" : ""}`.trim()}
onClick={() => setOfficeOpen((v) => !v)}
>
<span className="icon-box"><Icon name="team" size={18} /></span>
<span>Office</span>
</button>
<button
type="button"
aria-label="Settings"
@@ -171,21 +162,6 @@ export function Shell({
</TopbarSlotContext.Provider>
</div>
</main>
{officeOpen && (
<aside className="office-sidebar">
<div className="office-sidebar-header">
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--text-3)" }}>Office</span>
<IconButton icon="x" label="Close office" onClick={() => setOfficeOpen(false)} />
</div>
<VirtualOffice
agents={snapshot.agents}
tasks={snapshot.tasks}
taskRuns={snapshot.taskRuns}
presence={snapshot.chatPresence}
orgId={organization.id}
/>
</aside>
)}
<SearchDialog
snapshot={snapshot}
open={searchOpen}
@@ -1,176 +0,0 @@
import { useState } from "react";
import type { AgentRecord, TaskRecord, TaskRunRecord } from "../../../shared/domain";
import type { ChatPresenceRecord } from "../../../shared/chat-domain";
interface VirtualOfficeProps {
agents: AgentRecord[];
tasks: TaskRecord[];
taskRuns: TaskRunRecord[];
presence: ChatPresenceRecord[];
orgId: string;
}
type Status = "active" | "thinking" | "busy" | "idle";
interface Desk {
agent: AgentRecord;
status: Status;
task: string | null;
}
const SKIN = ["#f4c28b", "#e8b472", "#c68e5b", "#a56c3e", "#8b5232"];
const SHIRT = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4"];
function stableColor(palette: string[], name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
return palette[Math.abs(h) % palette.length];
}
function resolveDesks(props: VirtualOfficeProps): Desk[] {
const { agents, tasks, taskRuns, presence, orgId } = props;
return agents.filter((a) => a.orgId === orgId).map((agent) => {
const p = presence.find((r) => r.principalType === "agent" && r.principalId === agent.id);
const activeTask = tasks.find((t) => t.orgId === orgId && t.assigneeAgentId === agent.id && t.status === "in_progress");
const running = taskRuns.some((r) => r.agentId === agent.id && r.state === "running");
let status: Status = "idle";
if (running) status = "active";
else if (p?.status === "thinking") status = "thinking";
else if (p?.status === "waiting_approval" || p?.status === "busy") status = "busy";
else if (p?.status === "active" || p?.status === "online") status = "active";
return { agent, status, task: activeTask?.title ?? null };
});
}
export function VirtualOffice(props: VirtualOfficeProps) {
const desks = resolveDesks(props);
const [hovered, setHovered] = useState<string | null>(null);
const floors = new Map<string, Desk[]>();
for (const d of desks) {
const dept = d.agent.department;
if (!floors.has(dept)) floors.set(dept, []);
floors.get(dept)!.push(d);
}
if (desks.length === 0) return null;
return (
<div style={{ padding: "6px 4px", overflowY: "auto", overflowX: "hidden", height: "100%" }}>
{[...floors.entries()].map(([dept, agents]) => (
<div key={dept} style={{ marginBottom: 8 }}>
<div style={{ fontSize: 9, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--text-4)", padding: "0 6px 3px" }}>
{dept}
</div>
<div style={{
background: "var(--panel-2)",
borderRadius: 8,
padding: "6px 4px",
display: "flex",
flexWrap: "wrap",
gap: 2,
}}>
{agents.map((d) => (
<PixelDesk key={d.agent.id} desk={d} isHovered={hovered === d.agent.id} onHover={setHovered} />
))}
</div>
</div>
))}
</div>
);
}
function PixelDesk({ desk, isHovered, onHover }: { desk: Desk; isHovered: boolean; onHover: (id: string | null) => void }) {
const skin = stableColor(SKIN, desk.agent.name);
const shirt = stableColor(SHIRT, desk.agent.name + desk.agent.role);
const glow = desk.status === "active" || desk.status === "thinking";
return (
<div
style={{
width: 42,
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "4px 0 2px",
borderRadius: 6,
cursor: "default",
position: "relative",
background: isHovered ? "var(--hover)" : "transparent",
}}
onMouseEnter={() => onHover(desk.agent.id)}
onMouseLeave={() => onHover(null)}
>
{/* Pixel person */}
<div style={{ position: "relative", width: 16, height: 24 }}>
{/* Head */}
<div style={{ width: 8, height: 8, borderRadius: 2, background: skin, margin: "0 auto" }} />
{/* Body */}
<div style={{ width: 12, height: 8, borderRadius: "2px 2px 0 0", background: shirt, margin: "1px auto 0" }} />
{/* Legs */}
<div style={{ display: "flex", justifyContent: "center", gap: 2 }}>
<div style={{ width: 4, height: 6, background: "#555", borderRadius: "0 0 1px 1px" }} />
<div style={{ width: 4, height: 6, background: "#555", borderRadius: "0 0 1px 1px" }} />
</div>
{/* Status dot */}
<div style={{
position: "absolute",
top: -1,
right: -1,
width: 5,
height: 5,
borderRadius: "50%",
background: statusColor(desk.status),
boxShadow: glow ? `0 0 4px ${statusColor(desk.status)}` : undefined,
}} />
</div>
{/* Desk surface */}
<div style={{ width: 20, height: 3, background: "var(--border)", borderRadius: 1, marginTop: 1 }} />
{/* Name */}
<div style={{
fontSize: 7,
color: "var(--text-3)",
marginTop: 2,
textAlign: "center",
maxWidth: 40,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
lineHeight: 1,
}}>
{desk.agent.name.split(" ")[0]}
</div>
{isHovered && <Tip desk={desk} />}
</div>
);
}
function Tip({ desk }: { desk: Desk }) {
return (
<div style={{
position: "absolute",
bottom: "calc(100% + 2px)",
right: 0,
background: "var(--panel)",
border: "1px solid var(--border)",
borderRadius: 6,
padding: "4px 8px",
zIndex: 50,
boxShadow: "0 2px 10px rgba(0,0,0,0.15)",
pointerEvents: "none",
whiteSpace: "nowrap",
maxWidth: 180,
}}>
<div style={{ fontSize: 11, fontWeight: 560, color: "var(--text-1)" }}>{desk.agent.name}</div>
<div style={{ fontSize: 9, color: "var(--text-3)" }}>{desk.agent.role}</div>
{desk.task && <div style={{ fontSize: 9, color: statusColor(desk.status), overflow: "hidden", textOverflow: "ellipsis", marginTop: 2 }}>{desk.task}</div>}
</div>
);
}
function statusColor(s: Status): string {
if (s === "active") return "#34d399";
if (s === "thinking") return "#818cf8";
if (s === "busy") return "#fbbf24";
return "#ccc";
}
@@ -369,25 +369,6 @@ a { color: inherit; }
white-space: nowrap;
}
.office-sidebar {
width: 172px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border-soft);
background: var(--bg);
overflow: hidden;
}
.office-sidebar-header {
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--sp-2) 0 var(--sp-3);
border-bottom: 1px solid var(--border-soft);
}
.main {
min-width: 0;
flex: 1;
+3 -5
View File
@@ -164,11 +164,9 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await page.getByRole("button", { name: "Set", exact: true }).click();
await expect(page.getByRole("button", { name: "active", exact: true })).toBeDisabled();
// Virtual office sidebar toggle
// Virtual office page
await page.getByRole("button", { name: "Office" }).click();
await expect(page.locator(".office-sidebar")).toBeVisible();
await page.locator(".office-sidebar").getByRole("button", { name: "Close office" }).click();
await expect(page.locator(".office-sidebar")).not.toBeVisible();
await expect(page.locator("canvas")).toBeVisible();
// Composio settings panel
await page.getByRole("button", { name: "Settings" }).click();
@@ -220,7 +218,7 @@ function createSkillDirectory(): string {
async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) {
await page.setViewportSize(size);
for (const nav of ["Home", "Team", "Work", "Messages", "Files", "Approvals", "Social", "Settings"]) {
for (const nav of ["Home", "Team", "Work", "Messages", "Files", "Approvals", "Social", "Office", "Settings"]) {
await page.getByRole("button", { name: nav, exact: true }).click();
await expectPageToFit(page);
}