Polish virtual office with building floors and animated workstations
Inspired by claude-office's multi-floor building metaphor but rendered as a polished UI instead of pixel art. Departments appear as numbered floors in a building frame. Each workstation has a monitor with animated screen glow for active agents, a stand, and a status dot. The building header shows the active/total agent ratio at a glance.
This commit is contained in:
1 parent
bb24a444f4
commit
7d9995f62c
2 files changed
+121
-87
No files matched your search
@@ -17,7 +17,6 @@ interface AgentDesk {
|
||||
agent: AgentRecord;
|
||||
status: PresenceStatus;
|
||||
currentTask: string | null;
|
||||
runningNow: boolean;
|
||||
}
|
||||
|
||||
function resolveDesks(props: VirtualOfficeProps): AgentDesk[] {
|
||||
@@ -25,103 +24,102 @@ function resolveDesks(props: VirtualOfficeProps): AgentDesk[] {
|
||||
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 runningNow = taskRuns.some((r) => r.agentId === agent.id && r.state === "running");
|
||||
return { agent, status: mapStatus(p?.status, runningNow), currentTask: activeTask?.title ?? null, runningNow };
|
||||
const running = taskRuns.some((r) => r.agentId === agent.id && r.state === "running");
|
||||
return { agent, status: deriveStatus(p?.status, running), currentTask: activeTask?.title ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
function mapStatus(raw: string | undefined, running: boolean): PresenceStatus {
|
||||
function deriveStatus(raw: string | undefined, running: boolean): PresenceStatus {
|
||||
if (running) return "active";
|
||||
if (!raw) return "idle";
|
||||
if (raw === "thinking") return "thinking";
|
||||
if (raw === "waiting_approval" || raw === "busy") return "busy";
|
||||
if (raw === "active" || raw === "online") return "active";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<PresenceStatus, string> = {
|
||||
const COLOR: Record<PresenceStatus, string> = {
|
||||
active: "#34d399",
|
||||
thinking: "#818cf8",
|
||||
busy: "#fbbf24",
|
||||
idle: "rgba(120,120,120,0.35)",
|
||||
};
|
||||
|
||||
const DESK_BG: CSSProperties = {
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
borderRadius: 10,
|
||||
padding: "10px 6px 8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
cursor: "default",
|
||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||
};
|
||||
|
||||
const MONITOR: CSSProperties = {
|
||||
width: 28,
|
||||
height: 18,
|
||||
borderRadius: 3,
|
||||
border: "1.5px solid rgba(255,255,255,0.12)",
|
||||
marginBottom: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 8,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
idle: "rgba(120,120,120,0.3)",
|
||||
};
|
||||
|
||||
export function VirtualOffice(props: VirtualOfficeProps) {
|
||||
const desks = resolveDesks(props);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
const departments = new Map<string, AgentDesk[]>();
|
||||
const floors = new Map<string, AgentDesk[]>();
|
||||
for (const desk of desks) {
|
||||
const dept = desk.agent.department;
|
||||
if (!departments.has(dept)) departments.set(dept, []);
|
||||
departments.get(dept)!.push(desk);
|
||||
if (!floors.has(dept)) floors.set(dept, []);
|
||||
floors.get(dept)!.push(desk);
|
||||
}
|
||||
|
||||
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: "12px 10px", overflowY: "auto", height: "100%" }}>
|
||||
{[...departments.entries()].map(([dept, deptDesks]) => (
|
||||
<Room key={dept} label={dept} desks={deptDesks} hoveredId={hoveredId} onHover={setHoveredId} />
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function Room({ label, desks, hoveredId, onHover }: {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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={{ marginBottom: 14 }}>
|
||||
<div style={{ borderBottom: "1px solid rgba(255,255,255,0.04)" }}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
color: "var(--text-4)",
|
||||
padding: "0 4px 5px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "4px 8px 2px",
|
||||
}}>
|
||||
{label}
|
||||
<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={{
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
borderRadius: 12,
|
||||
padding: 6,
|
||||
background: "rgba(255,255,255,0.015)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, 72px)",
|
||||
gap: 4,
|
||||
justifyContent: "start",
|
||||
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} />
|
||||
@@ -132,61 +130,93 @@ function Room({ label, desks, hoveredId, onHover }: {
|
||||
}
|
||||
|
||||
function Workstation({ desk, hovered, onHover }: { desk: AgentDesk; hovered: boolean; onHover: (id: string | null) => void }) {
|
||||
const color = STATUS_COLOR[desk.status];
|
||||
const glowing = desk.status === "active" || desk.status === "thinking";
|
||||
const color = COLOR[desk.status];
|
||||
const alive = desk.status === "active" || desk.status === "thinking";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...DESK_BG,
|
||||
borderColor: hovered ? "rgba(255,255,255,0.14)" : undefined,
|
||||
boxShadow: glowing ? `0 0 12px -4px ${color}` : undefined,
|
||||
width: 56,
|
||||
padding: "5px 3px 4px",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
cursor: "default",
|
||||
transition: "background 0.15s",
|
||||
background: hovered ? "rgba(255,255,255,0.05)" : "transparent",
|
||||
}}
|
||||
onMouseEnter={() => onHover(desk.agent.id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<div style={{ ...MONITOR, borderColor: glowing ? color : undefined }}>
|
||||
{glowing && <div style={{ width: "100%", height: "100%", background: `${color}22` }} />}
|
||||
{/* 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>
|
||||
<div style={{ width: 6, height: 3, background: "rgba(255,255,255,0.08)", borderRadius: "0 0 2px 2px", marginBottom: 4 }} />
|
||||
{/* 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 />
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: -1,
|
||||
right: -2,
|
||||
width: 7,
|
||||
height: 7,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
border: "1.5px solid var(--bg)",
|
||||
boxShadow: glowing ? `0 0 5px ${color}` : undefined,
|
||||
transition: "background 0.2s",
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontSize: 8,
|
||||
color: "var(--text-3)",
|
||||
marginTop: 3,
|
||||
marginTop: 2,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.15,
|
||||
maxWidth: 64,
|
||||
maxWidth: 52,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{desk.agent.name.split(" ")[0]}
|
||||
</div>
|
||||
{hovered && (
|
||||
<Tooltip name={desk.agent.name} role={desk.agent.role} task={desk.currentTask} status={desk.status} />
|
||||
)}
|
||||
{hovered && <HoverCard desk={desk} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ name, role, task, status }: { name: string; role: string; task: string | null; status: PresenceStatus }) {
|
||||
function ScreenGlow({ color }: { color: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 6px)",
|
||||
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%)",
|
||||
background: "var(--panel)",
|
||||
@@ -194,22 +224,22 @@ function Tooltip({ name, role, task, status }: { name: string; role: string; tas
|
||||
borderRadius: 8,
|
||||
padding: "6px 10px",
|
||||
zIndex: 30,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.18)",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.2)",
|
||||
pointerEvents: "none",
|
||||
minWidth: 120,
|
||||
maxWidth: 200,
|
||||
minWidth: 110,
|
||||
maxWidth: 190,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 560, color: "var(--text-1)", marginBottom: 1 }}>{name}</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text-3)", marginBottom: task ? 4 : 0 }}>{role}</div>
|
||||
{task && (
|
||||
<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: 10,
|
||||
color: STATUS_COLOR[status],
|
||||
fontSize: 9,
|
||||
color,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{task}
|
||||
{desk.currentTask}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -434,6 +434,10 @@ a { color: inherit; }
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes office-pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
/* Split-layout routes fill the content area and let their panes scroll
|
||||
independently instead of scrolling the whole page. */
|
||||
.content.flush {
|
||||
|
||||
Reference in new issue
Block a user