Rewrite virtual office with pixel-art characters and compact layout
Replaced generic bot icons with CSS pixel-art people — colored head, shirt body, and legs, each agent with a stable color derived from their name. Layout is now compact flex-wrap (42px per agent) instead of one agent per row. Sidebar narrowed to 172px. Tooltip anchored to right edge to prevent clipping. Department floors use the app's panel-2 background for a clean integrated look.
This commit is contained in:
1 parent
7ff8e4becd
commit
e37faf1a2e
3 files changed
+102
-173
No files matched your search
@@ -174,7 +174,7 @@ export function Shell({
|
||||
{officeOpen && (
|
||||
<aside className="office-sidebar">
|
||||
<div className="office-sidebar-header">
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-2)" }}>Office</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--text-3)" }}>Office</span>
|
||||
<IconButton icon="x" label="Close office" onClick={() => setOfficeOpen(false)} />
|
||||
</div>
|
||||
<VirtualOffice
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { useState } from "react";
|
||||
import type { AgentRecord, TaskRecord, TaskRunRecord } from "../../../shared/domain";
|
||||
import type { ChatPresenceRecord } from "../../../shared/chat-domain";
|
||||
import { Avatar } from "./ui";
|
||||
|
||||
interface VirtualOfficeProps {
|
||||
agents: AgentRecord[];
|
||||
@@ -11,237 +10,167 @@ interface VirtualOfficeProps {
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
type PresenceStatus = "active" | "thinking" | "busy" | "idle";
|
||||
type Status = "active" | "thinking" | "busy" | "idle";
|
||||
|
||||
interface AgentDesk {
|
||||
interface Desk {
|
||||
agent: AgentRecord;
|
||||
status: PresenceStatus;
|
||||
currentTask: string | null;
|
||||
status: Status;
|
||||
task: string | null;
|
||||
}
|
||||
|
||||
function resolveDesks(props: VirtualOfficeProps): AgentDesk[] {
|
||||
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");
|
||||
return { agent, status: deriveStatus(p?.status, running), currentTask: activeTask?.title ?? null };
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
function deriveStatus(raw: string | undefined, running: boolean): PresenceStatus {
|
||||
if (running) return "active";
|
||||
if (raw === "thinking") return "thinking";
|
||||
if (raw === "waiting_approval" || raw === "busy") return "busy";
|
||||
if (raw === "active" || raw === "online") return "active";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
const COLOR: Record<PresenceStatus, string> = {
|
||||
active: "#34d399",
|
||||
thinking: "#818cf8",
|
||||
busy: "#fbbf24",
|
||||
idle: "rgba(120,120,120,0.3)",
|
||||
};
|
||||
|
||||
export function VirtualOffice(props: VirtualOfficeProps) {
|
||||
const desks = resolveDesks(props);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
|
||||
const floors = new Map<string, AgentDesk[]>();
|
||||
for (const desk of desks) {
|
||||
const dept = desk.agent.department;
|
||||
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(desk);
|
||||
floors.get(dept)!.push(d);
|
||||
}
|
||||
|
||||
if (desks.length === 0) return null;
|
||||
|
||||
const floorEntries = [...floors.entries()];
|
||||
const activeCount = desks.filter((d) => d.status === "active" || d.status === "thinking").length;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "8px 6px", overflowY: "auto", height: "100%" }}>
|
||||
<BuildingHeader total={desks.length} active={activeCount} />
|
||||
<div style={buildingFrame}>
|
||||
{floorEntries.map(([dept, deptDesks], index) => (
|
||||
<Floor
|
||||
key={dept}
|
||||
label={dept}
|
||||
level={floorEntries.length - index}
|
||||
desks={deptDesks}
|
||||
hoveredId={hoveredId}
|
||||
onHover={setHoveredId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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 BuildingHeader({ total, active }: { total: number; active: number }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "0 4px 8px", justifyContent: "space-between" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: "50%", background: active > 0 ? "#34d399" : "rgba(120,120,120,0.3)" }} />
|
||||
<span style={{ fontSize: 10, color: "var(--text-3)" }}>{active}/{total}</span>
|
||||
</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";
|
||||
|
||||
const buildingFrame: CSSProperties = {
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
background: "rgba(0,0,0,0.08)",
|
||||
};
|
||||
|
||||
function Floor({ label, level, desks, hoveredId, onHover }: {
|
||||
label: string;
|
||||
level: number;
|
||||
desks: AgentDesk[];
|
||||
hoveredId: string | null;
|
||||
onHover: (id: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ borderBottom: "1px solid rgba(255,255,255,0.04)" }}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "4px 8px 2px",
|
||||
}}>
|
||||
<span style={{ fontSize: 8, fontWeight: 700, color: "var(--text-4)", fontVariantNumeric: "tabular-nums", width: 12 }}>{level}F</span>
|
||||
<span style={{ fontSize: 9, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--text-4)" }}>{label}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: "4px 6px 8px",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 3,
|
||||
}}>
|
||||
{desks.map((desk) => (
|
||||
<Workstation key={desk.agent.id} desk={desk} hovered={hoveredId === desk.agent.id} onHover={onHover} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Workstation({ desk, hovered, onHover }: { desk: AgentDesk; hovered: boolean; onHover: (id: string | null) => void }) {
|
||||
const color = COLOR[desk.status];
|
||||
const alive = desk.status === "active" || desk.status === "thinking";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
padding: "5px 3px 4px",
|
||||
borderRadius: 8,
|
||||
width: 42,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
padding: "4px 0 2px",
|
||||
borderRadius: 6,
|
||||
cursor: "default",
|
||||
transition: "background 0.15s",
|
||||
background: hovered ? "rgba(255,255,255,0.05)" : "transparent",
|
||||
position: "relative",
|
||||
background: isHovered ? "var(--hover)" : "transparent",
|
||||
}}
|
||||
onMouseEnter={() => onHover(desk.agent.id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
{/* Monitor */}
|
||||
<div style={{
|
||||
width: 24,
|
||||
height: 15,
|
||||
borderRadius: 2,
|
||||
border: `1.5px solid ${alive ? color : "rgba(255,255,255,0.08)"}`,
|
||||
marginBottom: 1,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
transition: "border-color 0.2s",
|
||||
}}>
|
||||
{alive && <ScreenGlow color={color} />}
|
||||
</div>
|
||||
{/* Monitor stand */}
|
||||
<div style={{ width: 4, height: 2, background: "rgba(255,255,255,0.06)" }} />
|
||||
<div style={{ width: 10, height: 1, background: "rgba(255,255,255,0.06)", borderRadius: 1, marginBottom: 3 }} />
|
||||
{/* Agent */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<Avatar name={desk.agent.name} size="sm" bot />
|
||||
{/* 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",
|
||||
bottom: -1,
|
||||
right: -2,
|
||||
width: 6,
|
||||
height: 6,
|
||||
top: -1,
|
||||
right: -1,
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
border: "1.5px solid var(--bg)",
|
||||
transition: "background 0.2s",
|
||||
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: 8,
|
||||
fontSize: 7,
|
||||
color: "var(--text-3)",
|
||||
marginTop: 2,
|
||||
textAlign: "center",
|
||||
maxWidth: 52,
|
||||
maxWidth: 40,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{desk.agent.name.split(" ")[0]}
|
||||
</div>
|
||||
{hovered && <HoverCard desk={desk} />}
|
||||
{isHovered && <Tip desk={desk} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenGlow({ color }: { color: string }) {
|
||||
function Tip({ desk }: { desk: Desk }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: `${color}33`,
|
||||
animation: "office-pulse 2s ease-in-out infinite",
|
||||
}}>
|
||||
<div style={{ width: "60%", height: 2, background: `${color}55`, margin: "3px 2px", borderRadius: 1 }} />
|
||||
<div style={{ width: "40%", height: 2, background: `${color}44`, margin: "1px 2px", borderRadius: 1 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverCard({ desk }: { desk: AgentDesk }) {
|
||||
const color = COLOR[desk.status];
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 4px)",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
bottom: "calc(100% + 2px)",
|
||||
right: 0,
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "6px 10px",
|
||||
zIndex: 30,
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.2)",
|
||||
borderRadius: 6,
|
||||
padding: "4px 8px",
|
||||
zIndex: 50,
|
||||
boxShadow: "0 2px 10px rgba(0,0,0,0.15)",
|
||||
pointerEvents: "none",
|
||||
minWidth: 110,
|
||||
maxWidth: 190,
|
||||
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)", marginBottom: desk.currentTask ? 3 : 0 }}>{desk.agent.role}</div>
|
||||
{desk.currentTask && (
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
color,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{desk.currentTask}
|
||||
</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";
|
||||
}
|
||||
@@ -370,7 +370,7 @@ a { color: inherit; }
|
||||
}
|
||||
|
||||
.office-sidebar {
|
||||
width: 220px;
|
||||
width: 172px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -379,12 +379,12 @@ a { color: inherit; }
|
||||
overflow: hidden;
|
||||
}
|
||||
.office-sidebar-header {
|
||||
height: 48px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-3);
|
||||
padding: 0 var(--sp-2) 0 var(--sp-3);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user