defceb1169
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
105 lines
5.5 KiB
TypeScript
105 lines
5.5 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Plus, ChevronDown, ChevronRight } from "lucide-react";
|
|
import { COLORS, FONT, STATE_COLOR } from "./theme";
|
|
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
|
import type { DaemonHealth } from "./socketBridge";
|
|
|
|
function fmtUptime(startedMs: number): string {
|
|
const s = Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
|
|
if (s < 60) return `${s}s`;
|
|
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
|
return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
|
|
}
|
|
|
|
function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
|
|
const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"];
|
|
const running = Object.values(w.surfaces).filter((s) => s.running);
|
|
if (running.length === 0) return "stopped";
|
|
for (const st of order) {
|
|
if (running.some((s) => s.state === st)) return st;
|
|
}
|
|
return "idle";
|
|
}
|
|
|
|
export function Sidebar({
|
|
groups, workspaces, activeId, onSelect, onNew, health, connected,
|
|
}: {
|
|
groups: Group[];
|
|
workspaces: WorkspaceView[];
|
|
activeId: string | null;
|
|
onSelect: (id: string) => void;
|
|
onNew: () => void;
|
|
health: DaemonHealth | null;
|
|
connected: boolean;
|
|
}) {
|
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
const [, setTick] = useState(0);
|
|
useEffect(() => {
|
|
const t = setInterval(() => setTick((n) => n + 1), 30000);
|
|
return () => clearInterval(t);
|
|
}, []);
|
|
const byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order);
|
|
const ungrouped = byGroup(null);
|
|
|
|
const row = (w: WorkspaceView) => {
|
|
const isActive = w.id === activeId;
|
|
return (
|
|
<div key={w.id} onClick={() => onSelect(w.id)}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 10, height: 34, padding: "0 8px", borderRadius: 6, cursor: "pointer",
|
|
background: isActive ? COLORS.bgElevated : "transparent", fontFamily: FONT.ui, fontSize: 13,
|
|
color: isActive ? COLORS.textPrimary : COLORS.textSecondary,
|
|
}}>
|
|
<span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${STATE_COLOR[aggregate(w)]}`, boxSizing: "border-box", flex: "0 0 10px" }} />
|
|
<span style={{ flex: 1, fontWeight: isActive ? 600 : 400, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.name}</span>
|
|
{w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.accent, flex: "0 0 7px" }} />}
|
|
<span style={{ display: "flex", alignItems: "center", justifyContent: "center", height: 18, minWidth: 18, padding: "0 6px", borderRadius: 9, background: COLORS.bgApp, fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>
|
|
{Object.keys(w.surfaces).length}
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box" }}>
|
|
<button onClick={onNew}
|
|
style={{
|
|
display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", height: 34, marginBottom: 16,
|
|
background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7,
|
|
fontFamily: FONT.ui, fontSize: 13, fontWeight: 600,
|
|
}}>
|
|
<Plus size={15} />
|
|
New workspace
|
|
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>⌘N</span>
|
|
</button>
|
|
|
|
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2, minHeight: 0 }}>
|
|
{groups.sort((a, b) => a.order - b.order).map((g) => {
|
|
const open = !collapsed[g.id];
|
|
return (
|
|
<div key={g.id} style={{ marginBottom: 8 }}>
|
|
<div onClick={() => setCollapsed((c) => ({ ...c, [g.id]: open }))}
|
|
style={{ display: "flex", alignItems: "center", gap: 7, height: 24, padding: "0 4px", cursor: "pointer" }}>
|
|
{open ? <ChevronDown size={13} color={COLORS.textMuted} /> : <ChevronRight size={13} color={COLORS.textMuted} />}
|
|
<span style={{ width: 8, height: 8, borderRadius: 2, background: g.color }} />
|
|
<span style={{ fontFamily: FONT.ui, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: COLORS.textSecondary }}>{g.name.toUpperCase()}</span>
|
|
</div>
|
|
{open && byGroup(g.id).map(row)}
|
|
</div>
|
|
);
|
|
})}
|
|
{ungrouped.length > 0 && <div style={{ marginTop: 4, display: "flex", flexDirection: "column", gap: 2 }}>{ungrouped.map(row)}</div>}
|
|
</div>
|
|
|
|
<div title={health ? `spaceshd v${health.version} · pid ${health.pid}` : "daemon offline"}
|
|
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}>
|
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: connected ? COLORS.stDone : COLORS.textMuted, flex: "0 0 7px" }} />
|
|
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>{connected ? "spaceshd · live" : "spaceshd · offline"}</span>
|
|
<span style={{ flex: 1 }} />
|
|
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>{health ? fmtUptime(health.started_at_ms) : ""}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|