1. ScheduleTab + ChannelsTab: wrap toggle/delete in try/catch with error feedback (was silently swallowing API failures) 2. MemoryTab: "+Add" button now auto-expands Advanced section 3. SidePanel: keyboard-navigated tabs scroll into view 4. TracesTab: emoji aria-hidden, env-var hint in <details> 5. page.tsx: show Spinner while hydrating instead of flash of EmptyState Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
275 lines
12 KiB
TypeScript
275 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
import { useCanvasStore, type PanelTab } from "@/store/canvas";
|
|
import { showToast } from "@/components/Toaster";
|
|
import { StatusDot } from "./StatusDot";
|
|
import { Tooltip } from "./Tooltip";
|
|
import { DetailsTab } from "./tabs/DetailsTab";
|
|
import { SkillsTab } from "./tabs/SkillsTab";
|
|
import { ChatTab } from "./tabs/ChatTab";
|
|
import { ConfigTab } from "./tabs/ConfigTab";
|
|
import { TerminalTab } from "./tabs/TerminalTab";
|
|
import { FilesTab } from "./tabs/FilesTab";
|
|
import { MemoryTab } from "./tabs/MemoryTab";
|
|
import { TracesTab } from "./tabs/TracesTab";
|
|
import { EventsTab } from "./tabs/EventsTab";
|
|
import { ActivityTab } from "./tabs/ActivityTab";
|
|
import { ScheduleTab } from "./tabs/ScheduleTab";
|
|
import { ChannelsTab } from "./tabs/ChannelsTab";
|
|
import { summarizeWorkspaceCapabilities } from "@/store/canvas";
|
|
|
|
const SIDEPANEL_WIDTH_KEY = "molecule:sidepanel-width";
|
|
const SIDEPANEL_DEFAULT_WIDTH = 480;
|
|
const SIDEPANEL_MIN_WIDTH = 320;
|
|
|
|
const TABS: { id: PanelTab; label: string; icon: string }[] = [
|
|
{ id: "chat", label: "Chat", icon: "◈" },
|
|
{ id: "activity", label: "Activity", icon: "⊙" },
|
|
{ id: "details", label: "Details", icon: "◉" },
|
|
{ id: "skills", label: "Skills", icon: "✦" },
|
|
{ id: "terminal", label: "Terminal", icon: "▸" },
|
|
{ id: "config", label: "Config", icon: "⚙" },
|
|
{ id: "schedule", label: "Schedule", icon: "⏲" },
|
|
{ id: "channels", label: "Channels", icon: "⇌" },
|
|
{ id: "files", label: "Files", icon: "⊞" },
|
|
{ id: "memory", label: "Memory", icon: "◇" },
|
|
{ id: "traces", label: "Traces", icon: "◎" },
|
|
{ id: "events", label: "Events", icon: "◊" },
|
|
];
|
|
|
|
export function SidePanel() {
|
|
const selectedNodeId = useCanvasStore((s) => s.selectedNodeId);
|
|
const panelTab = useCanvasStore((s) => s.panelTab);
|
|
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
|
|
const selectNode = useCanvasStore((s) => s.selectNode);
|
|
const node = useCanvasStore((s) =>
|
|
s.nodes.find((n) => n.id === s.selectedNodeId)
|
|
);
|
|
|
|
// Resizable panel width — persisted across node selections via localStorage
|
|
const [width, setWidth] = useState<number>(() => {
|
|
if (typeof window === "undefined") return SIDEPANEL_DEFAULT_WIDTH;
|
|
const saved = localStorage.getItem(SIDEPANEL_WIDTH_KEY);
|
|
const parsed = saved ? parseInt(saved, 10) : NaN;
|
|
return Number.isFinite(parsed) && parsed >= SIDEPANEL_MIN_WIDTH
|
|
? parsed
|
|
: SIDEPANEL_DEFAULT_WIDTH;
|
|
});
|
|
const widthRef = useRef(width); // tracks live drag value for the mouseup handler
|
|
const dragging = useRef(false);
|
|
const startX = useRef(0);
|
|
const startWidth = useRef(SIDEPANEL_DEFAULT_WIDTH);
|
|
|
|
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
dragging.current = true;
|
|
startX.current = e.clientX;
|
|
startWidth.current = width;
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
}, [width]);
|
|
|
|
useEffect(() => {
|
|
const onMouseMove = (e: MouseEvent) => {
|
|
if (!dragging.current) return;
|
|
const delta = startX.current - e.clientX;
|
|
const newWidth = Math.min(
|
|
Math.max(startWidth.current + delta, SIDEPANEL_MIN_WIDTH),
|
|
window.innerWidth * 0.8,
|
|
);
|
|
setWidth(newWidth);
|
|
widthRef.current = newWidth; // keep ref in sync so mouseUp can persist it
|
|
};
|
|
const onMouseUp = () => {
|
|
if (!dragging.current) return;
|
|
dragging.current = false;
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
// Persist the final dragged width so it survives node re-selection
|
|
localStorage.setItem(SIDEPANEL_WIDTH_KEY, String(widthRef.current));
|
|
};
|
|
window.addEventListener("mousemove", onMouseMove);
|
|
window.addEventListener("mouseup", onMouseUp);
|
|
return () => {
|
|
window.removeEventListener("mousemove", onMouseMove);
|
|
window.removeEventListener("mouseup", onMouseUp);
|
|
};
|
|
}, []);
|
|
|
|
if (!selectedNodeId || !node) return null;
|
|
|
|
const isOnline = node.data.status === "online";
|
|
const capability = summarizeWorkspaceCapabilities(node.data);
|
|
|
|
return (
|
|
<div
|
|
className="fixed top-0 right-0 h-full bg-zinc-950/95 backdrop-blur-xl border-l border-zinc-800/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200"
|
|
style={{ width }}
|
|
>
|
|
{/* Resize handle */}
|
|
<div
|
|
onMouseDown={onMouseDown}
|
|
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-blue-500/30 active:bg-blue-500/50 transition-colors z-10"
|
|
/>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800/40 bg-zinc-900/30">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="relative">
|
|
<StatusDot status={node.data.status} size="md" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h2 className="text-[14px] font-semibold text-zinc-100 truncate leading-tight">
|
|
{node.data.name}
|
|
</h2>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
{node.data.role && (
|
|
<span className="text-[10px] text-zinc-500 truncate">
|
|
{node.data.role}
|
|
</span>
|
|
)}
|
|
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${
|
|
isOnline ? "text-emerald-400 bg-emerald-950/30" : "text-zinc-500 bg-zinc-800/50"
|
|
}`}>
|
|
T{node.data.tier}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => selectNode(null)}
|
|
className="w-7 h-7 flex items-center justify-center rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/60 transition-colors"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Capability summary */}
|
|
<div className="px-5 py-3 border-b border-zinc-800/40 bg-zinc-900/20">
|
|
<div className="flex flex-wrap gap-2">
|
|
<MetaPill label="Tier" value={`T${node.data.tier}`} />
|
|
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
|
|
<MetaPill label="Skills" value={capability.skillCount > 0 ? `${capability.skillCount}` : "none"} />
|
|
<MetaPill label="Status" value={node.data.status} tone={isOnline ? "emerald" : "zinc"} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs — relative wrapper lets the fade gradient position against the scroll container */}
|
|
<div className="relative border-b border-zinc-800/40">
|
|
{/* Right-edge fade: signals more tabs are hidden off-screen when the bar overflows */}
|
|
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-zinc-950 to-transparent z-10" aria-hidden="true" />
|
|
<div
|
|
role="tablist"
|
|
aria-label="Workspace panel tabs"
|
|
className="flex overflow-x-auto bg-zinc-900/20 px-1"
|
|
onKeyDown={(e) => {
|
|
const idx = TABS.findIndex((t) => t.id === panelTab);
|
|
let next: number | null = null;
|
|
if (e.key === "ArrowRight") { e.preventDefault(); next = (idx + 1) % TABS.length; }
|
|
else if (e.key === "ArrowLeft") { e.preventDefault(); next = (idx - 1 + TABS.length) % TABS.length; }
|
|
else if (e.key === "Home") { e.preventDefault(); next = 0; }
|
|
else if (e.key === "End") { e.preventDefault(); next = TABS.length - 1; }
|
|
if (next !== null) {
|
|
setPanelTab(TABS[next].id);
|
|
requestAnimationFrame(() => { const el = document.getElementById(`tab-${TABS[next!].id}`); el?.focus(); el?.scrollIntoView({ block: "nearest", inline: "nearest" }); });
|
|
}
|
|
}}
|
|
>
|
|
{TABS.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
id={`tab-${tab.id}`}
|
|
role="tab"
|
|
aria-selected={panelTab === tab.id}
|
|
aria-controls={`panel-${tab.id}`}
|
|
tabIndex={panelTab === tab.id ? 0 : -1}
|
|
onClick={() => setPanelTab(tab.id)}
|
|
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${
|
|
panelTab === tab.id
|
|
? "text-zinc-100 bg-zinc-800/40 border-b-2 border-blue-500"
|
|
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40"
|
|
}`}
|
|
>
|
|
<span className="mr-1 opacity-50" aria-hidden="true">{tab.icon}</span>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Needs Restart Banner */}
|
|
{node.data.needsRestart && !node.data.currentTask && selectedNodeId && (
|
|
<div className="px-4 py-2 bg-sky-950/20 border-b border-sky-800/20 flex items-center justify-between">
|
|
<span className="text-[10px] text-sky-300/90">Config changed — restart to apply</span>
|
|
<button
|
|
onClick={() => {
|
|
useCanvasStore.getState().restartWorkspace(selectedNodeId).catch(() => showToast("Restart failed", "error"));
|
|
}}
|
|
className="text-[11px] px-2 py-1 bg-sky-800/40 hover:bg-sky-700/50 text-sky-200 rounded transition-colors"
|
|
>
|
|
Restart Now
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Current Task Banner */}
|
|
{node.data.currentTask && (
|
|
<Tooltip text={node.data.currentTask as string}>
|
|
<div className="px-4 py-2 bg-amber-950/20 border-b border-amber-800/20 flex items-center gap-2 cursor-default">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
|
<span className="text-[10px] text-amber-300/90 truncate">
|
|
{node.data.currentTask}
|
|
</span>
|
|
</div>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{/* Tab Content */}
|
|
<div
|
|
role="tabpanel"
|
|
id={`panel-${panelTab}`}
|
|
aria-labelledby={`tab-${panelTab}`}
|
|
tabIndex={0}
|
|
className="flex-1 overflow-y-auto focus:outline-none"
|
|
>
|
|
{panelTab === "details" && <DetailsTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
|
{panelTab === "skills" && <SkillsTab key={selectedNodeId} data={node.data} />}
|
|
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
|
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
|
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
|
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
|
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
|
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
|
{panelTab === "files" && <FilesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
|
{panelTab === "memory" && <MemoryTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
|
{panelTab === "traces" && <TracesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
|
{panelTab === "events" && <EventsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
|
</div>
|
|
|
|
{/* Footer — workspace ID */}
|
|
<div className="px-5 py-2 border-t border-zinc-800/40 bg-zinc-900/20">
|
|
<span className="text-[9px] font-mono text-zinc-500 select-all">
|
|
{selectedNodeId}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetaPill({ label, value, tone = "zinc" }: { label: string; value: string; tone?: "zinc" | "emerald" | "amber" }) {
|
|
const toneClasses = {
|
|
zinc: "border-zinc-700/50 bg-zinc-900/70 text-zinc-400",
|
|
emerald: "border-emerald-500/20 bg-emerald-950/20 text-emerald-300",
|
|
amber: "border-amber-500/20 bg-amber-950/20 text-amber-300",
|
|
}[tone];
|
|
|
|
return (
|
|
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[9px] ${toneClasses}`}>
|
|
<span className="uppercase tracking-[0.18em] text-[8px] opacity-70">{label}</span>
|
|
<span className="font-medium">{value}</span>
|
|
</span>
|
|
);
|
|
}
|