Add virtual office sidebar showing real-time agent status
A collapsible right sidebar renders agent presence as a visual office floor plan grouped by department. Each agent shows at their desk with a status ring (active/thinking/busy/idle) and a tooltip revealing their current task on hover. Toggle via the Office button in the left nav.
This commit is contained in:
1 parent
77f191fa25
commit
07fa105bf3
3 files changed
+248
No files matched your search
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -73,6 +74,7 @@ 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) => {
|
||||
@@ -122,6 +124,15 @@ 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"
|
||||
@@ -160,6 +171,20 @@ export function Shell({
|
||||
</TopbarSlotContext.Provider>
|
||||
</div>
|
||||
</main>
|
||||
{officeOpen && (
|
||||
<aside className="office-sidebar">
|
||||
<div className="office-sidebar-header">
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-2)" }}>Office</span>
|
||||
<IconButton icon="x" label="Close office" onClick={() => setOfficeOpen(false)} />
|
||||
</div>
|
||||
<VirtualOffice
|
||||
agents={snapshot.agents}
|
||||
tasks={snapshot.tasks}
|
||||
presence={snapshot.chatPresence}
|
||||
orgId={organization.id}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
<SearchDialog
|
||||
snapshot={snapshot}
|
||||
open={searchOpen}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from "react";
|
||||
import type { AgentRecord, TaskRecord } from "../../../shared/domain";
|
||||
import type { ChatPresenceRecord } from "../../../shared/chat-domain";
|
||||
import { Avatar } from "./ui";
|
||||
|
||||
interface VirtualOfficeProps {
|
||||
agents: AgentRecord[];
|
||||
tasks: TaskRecord[];
|
||||
presence: ChatPresenceRecord[];
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
type PresenceStatus = "active" | "thinking" | "busy" | "idle";
|
||||
|
||||
interface AgentDesk {
|
||||
agent: AgentRecord;
|
||||
status: PresenceStatus;
|
||||
statusText: string;
|
||||
currentTask: string | null;
|
||||
}
|
||||
|
||||
function resolveDesks(props: VirtualOfficeProps): AgentDesk[] {
|
||||
const { agents, tasks, presence, orgId } = props;
|
||||
const orgAgents = agents.filter((a) => a.orgId === orgId);
|
||||
return orgAgents.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 status = mapStatus(p?.status);
|
||||
return {
|
||||
agent,
|
||||
status,
|
||||
statusText: p?.statusText ?? "",
|
||||
currentTask: activeTask?.title ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mapStatus(raw: string | undefined): PresenceStatus {
|
||||
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_RING: Record<PresenceStatus, string> = {
|
||||
active: "var(--green)",
|
||||
thinking: "var(--accent)",
|
||||
busy: "var(--amber)",
|
||||
idle: "var(--text-4)",
|
||||
};
|
||||
|
||||
const DEPT_FLOOR: Record<string, { label: string; color: string }> = {
|
||||
engineering: { label: "Engineering", color: "var(--accent-soft)" },
|
||||
product: { label: "Product", color: "rgba(168, 130, 255, 0.08)" },
|
||||
design: { label: "Design", color: "rgba(255, 170, 130, 0.08)" },
|
||||
marketing: { label: "Marketing", color: "rgba(130, 200, 255, 0.08)" },
|
||||
operations: { label: "Operations", color: "rgba(180, 180, 180, 0.06)" },
|
||||
};
|
||||
|
||||
function floorForDept(dept: string): { label: string; color: string } {
|
||||
const key = dept.toLowerCase();
|
||||
return DEPT_FLOOR[key] ?? { label: dept, color: "rgba(180, 180, 180, 0.06)" };
|
||||
}
|
||||
|
||||
export function VirtualOffice(props: VirtualOfficeProps) {
|
||||
const desks = resolveDesks(props);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
const departments = 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 (desks.length === 0) {
|
||||
return <div style={{ padding: 24, color: "var(--text-3)", fontSize: 13 }}>No agents yet</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "12px 8px", overflowY: "auto", height: "100%" }}>
|
||||
{[...departments.entries()].map(([dept, deptDesks]) => {
|
||||
const floor = floorForDept(dept);
|
||||
return (
|
||||
<div key={dept} style={{ marginBottom: 16 }}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-3)",
|
||||
padding: "0 8px 6px",
|
||||
}}>
|
||||
{floor.label}
|
||||
</div>
|
||||
<div style={{
|
||||
background: floor.color,
|
||||
borderRadius: 10,
|
||||
padding: 8,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, 64px)",
|
||||
gap: 4,
|
||||
justifyContent: "start",
|
||||
}}>
|
||||
{deptDesks.map((desk) => (
|
||||
<DeskCell
|
||||
key={desk.agent.id}
|
||||
desk={desk}
|
||||
hovered={hoveredId === desk.agent.id}
|
||||
onHover={setHoveredId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeskCell({
|
||||
desk,
|
||||
hovered,
|
||||
onHover,
|
||||
}: {
|
||||
desk: AgentDesk;
|
||||
hovered: boolean;
|
||||
onHover: (id: string | null) => void;
|
||||
}) {
|
||||
const ring = STATUS_RING[desk.status];
|
||||
const isWorking = desk.status === "active" || desk.status === "thinking";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
padding: "6px 2px",
|
||||
borderRadius: 8,
|
||||
cursor: "default",
|
||||
position: "relative",
|
||||
transition: "background 0.15s",
|
||||
background: hovered ? "rgba(255,255,255,0.06)" : "transparent",
|
||||
}}
|
||||
onMouseEnter={() => onHover(desk.agent.id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Avatar name={desk.agent.name} size="lg" bot />
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: -1,
|
||||
right: -1,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: ring,
|
||||
border: "2px solid var(--panel)",
|
||||
boxShadow: isWorking ? `0 0 4px ${ring}` : undefined,
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-2)",
|
||||
marginTop: 4,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
maxWidth: 60,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{desk.agent.name.split(" ")[0]}
|
||||
</div>
|
||||
{hovered && desk.currentTask && (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: "100%",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
padding: "4px 8px",
|
||||
fontSize: 11,
|
||||
color: "var(--text-1)",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
zIndex: 20,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.12)",
|
||||
marginBottom: 4,
|
||||
pointerEvents: "none",
|
||||
}}>
|
||||
{desk.currentTask}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -369,6 +369,25 @@ a { color: inherit; }
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.office-sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--border-soft);
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.office-sidebar-header {
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-3);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
|
||||
Reference in new issue
Block a user