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;
|
agent: AgentRecord;
|
||||||
status: PresenceStatus;
|
status: PresenceStatus;
|
||||||
currentTask: string | null;
|
currentTask: string | null;
|
||||||
runningNow: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDesks(props: VirtualOfficeProps): AgentDesk[] {
|
function resolveDesks(props: VirtualOfficeProps): AgentDesk[] {
|
||||||
@@ -25,103 +24,102 @@ function resolveDesks(props: VirtualOfficeProps): AgentDesk[] {
|
|||||||
return agents.filter((a) => a.orgId === orgId).map((agent) => {
|
return agents.filter((a) => a.orgId === orgId).map((agent) => {
|
||||||
const p = presence.find((r) => r.principalType === "agent" && r.principalId === agent.id);
|
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 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");
|
const running = taskRuns.some((r) => r.agentId === agent.id && r.state === "running");
|
||||||
return { agent, status: mapStatus(p?.status, runningNow), currentTask: activeTask?.title ?? null, runningNow };
|
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 (running) return "active";
|
||||||
if (!raw) return "idle";
|
|
||||||
if (raw === "thinking") return "thinking";
|
if (raw === "thinking") return "thinking";
|
||||||
if (raw === "waiting_approval" || raw === "busy") return "busy";
|
if (raw === "waiting_approval" || raw === "busy") return "busy";
|
||||||
if (raw === "active" || raw === "online") return "active";
|
if (raw === "active" || raw === "online") return "active";
|
||||||
return "idle";
|
return "idle";
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLOR: Record<PresenceStatus, string> = {
|
const COLOR: Record<PresenceStatus, string> = {
|
||||||
active: "#34d399",
|
active: "#34d399",
|
||||||
thinking: "#818cf8",
|
thinking: "#818cf8",
|
||||||
busy: "#fbbf24",
|
busy: "#fbbf24",
|
||||||
idle: "rgba(120,120,120,0.35)",
|
idle: "rgba(120,120,120,0.3)",
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VirtualOffice(props: VirtualOfficeProps) {
|
export function VirtualOffice(props: VirtualOfficeProps) {
|
||||||
const desks = resolveDesks(props);
|
const desks = resolveDesks(props);
|
||||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||||
|
|
||||||
const departments = new Map<string, AgentDesk[]>();
|
const floors = new Map<string, AgentDesk[]>();
|
||||||
for (const desk of desks) {
|
for (const desk of desks) {
|
||||||
const dept = desk.agent.department;
|
const dept = desk.agent.department;
|
||||||
if (!departments.has(dept)) departments.set(dept, []);
|
if (!floors.has(dept)) floors.set(dept, []);
|
||||||
departments.get(dept)!.push(desk);
|
floors.get(dept)!.push(desk);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (desks.length === 0) return null;
|
if (desks.length === 0) return null;
|
||||||
|
|
||||||
|
const floorEntries = [...floors.entries()];
|
||||||
|
const activeCount = desks.filter((d) => d.status === "active" || d.status === "thinking").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "12px 10px", overflowY: "auto", height: "100%" }}>
|
<div style={{ padding: "8px 6px", overflowY: "auto", height: "100%" }}>
|
||||||
{[...departments.entries()].map(([dept, deptDesks]) => (
|
<BuildingHeader total={desks.length} active={activeCount} />
|
||||||
<Room key={dept} label={dept} desks={deptDesks} hoveredId={hoveredId} onHover={setHoveredId} />
|
<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>
|
</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;
|
label: string;
|
||||||
|
level: number;
|
||||||
desks: AgentDesk[];
|
desks: AgentDesk[];
|
||||||
hoveredId: string | null;
|
hoveredId: string | null;
|
||||||
onHover: (id: string | null) => void;
|
onHover: (id: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ borderBottom: "1px solid rgba(255,255,255,0.04)" }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 10,
|
display: "flex",
|
||||||
fontWeight: 600,
|
alignItems: "center",
|
||||||
textTransform: "uppercase",
|
gap: 4,
|
||||||
letterSpacing: "0.06em",
|
padding: "4px 8px 2px",
|
||||||
color: "var(--text-4)",
|
|
||||||
padding: "0 4px 5px",
|
|
||||||
}}>
|
}}>
|
||||||
{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>
|
||||||
<div style={{
|
<div style={{
|
||||||
border: "1px solid rgba(255,255,255,0.05)",
|
padding: "4px 6px 8px",
|
||||||
borderRadius: 12,
|
display: "flex",
|
||||||
padding: 6,
|
flexWrap: "wrap",
|
||||||
background: "rgba(255,255,255,0.015)",
|
gap: 3,
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fill, 72px)",
|
|
||||||
gap: 4,
|
|
||||||
justifyContent: "start",
|
|
||||||
}}>
|
}}>
|
||||||
{desks.map((desk) => (
|
{desks.map((desk) => (
|
||||||
<Workstation key={desk.agent.id} desk={desk} hovered={hoveredId === desk.agent.id} onHover={onHover} />
|
<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 }) {
|
function Workstation({ desk, hovered, onHover }: { desk: AgentDesk; hovered: boolean; onHover: (id: string | null) => void }) {
|
||||||
const color = STATUS_COLOR[desk.status];
|
const color = COLOR[desk.status];
|
||||||
const glowing = desk.status === "active" || desk.status === "thinking";
|
const alive = desk.status === "active" || desk.status === "thinking";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...DESK_BG,
|
width: 56,
|
||||||
borderColor: hovered ? "rgba(255,255,255,0.14)" : undefined,
|
padding: "5px 3px 4px",
|
||||||
boxShadow: glowing ? `0 0 12px -4px ${color}` : undefined,
|
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)}
|
onMouseEnter={() => onHover(desk.agent.id)}
|
||||||
onMouseLeave={() => onHover(null)}
|
onMouseLeave={() => onHover(null)}
|
||||||
>
|
>
|
||||||
<div style={{ ...MONITOR, borderColor: glowing ? color : undefined }}>
|
{/* Monitor */}
|
||||||
{glowing && <div style={{ width: "100%", height: "100%", background: `${color}22` }} />}
|
<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>
|
||||||
<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" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<Avatar name={desk.agent.name} size="sm" bot />
|
<Avatar name={desk.agent.name} size="sm" bot />
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: -1,
|
bottom: -1,
|
||||||
right: -2,
|
right: -2,
|
||||||
width: 7,
|
width: 6,
|
||||||
height: 7,
|
height: 6,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
background: color,
|
background: color,
|
||||||
border: "1.5px solid var(--bg)",
|
border: "1.5px solid var(--bg)",
|
||||||
boxShadow: glowing ? `0 0 5px ${color}` : undefined,
|
transition: "background 0.2s",
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 9,
|
fontSize: 8,
|
||||||
color: "var(--text-3)",
|
color: "var(--text-3)",
|
||||||
marginTop: 3,
|
marginTop: 2,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
lineHeight: 1.15,
|
maxWidth: 52,
|
||||||
maxWidth: 64,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}>
|
}}>
|
||||||
{desk.agent.name.split(" ")[0]}
|
{desk.agent.name.split(" ")[0]}
|
||||||
</div>
|
</div>
|
||||||
{hovered && (
|
{hovered && <HoverCard desk={desk} />}
|
||||||
<Tooltip name={desk.agent.name} role={desk.agent.role} task={desk.currentTask} status={desk.status} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tooltip({ name, role, task, status }: { name: string; role: string; task: string | null; status: PresenceStatus }) {
|
function ScreenGlow({ color }: { color: string }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute",
|
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%",
|
left: "50%",
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
background: "var(--panel)",
|
background: "var(--panel)",
|
||||||
@@ -194,22 +224,22 @@ function Tooltip({ name, role, task, status }: { name: string; role: string; tas
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: "6px 10px",
|
padding: "6px 10px",
|
||||||
zIndex: 30,
|
zIndex: 30,
|
||||||
boxShadow: "0 4px 16px rgba(0,0,0,0.18)",
|
boxShadow: "0 4px 20px rgba(0,0,0,0.2)",
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
minWidth: 120,
|
minWidth: 110,
|
||||||
maxWidth: 200,
|
maxWidth: 190,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 560, color: "var(--text-1)", marginBottom: 1 }}>{name}</div>
|
<div style={{ fontSize: 11, fontWeight: 560, color: "var(--text-1)" }}>{desk.agent.name}</div>
|
||||||
<div style={{ fontSize: 10, color: "var(--text-3)", marginBottom: task ? 4 : 0 }}>{role}</div>
|
<div style={{ fontSize: 9, color: "var(--text-3)", marginBottom: desk.currentTask ? 3 : 0 }}>{desk.agent.role}</div>
|
||||||
{task && (
|
{desk.currentTask && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 10,
|
fontSize: 9,
|
||||||
color: STATUS_COLOR[status],
|
color,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}>
|
}}>
|
||||||
{task}
|
{desk.currentTask}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -434,6 +434,10 @@ a { color: inherit; }
|
|||||||
from { opacity: 0; transform: translateY(6px); }
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
to { opacity: 1; transform: none; }
|
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
|
/* Split-layout routes fill the content area and let their panes scroll
|
||||||
independently instead of scrolling the whole page. */
|
independently instead of scrolling the whole page. */
|
||||||
.content.flush {
|
.content.flush {
|
||||||
|
|||||||
Reference in new issue
Block a user