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:
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user