Zayne Rea Sprague
Initial deploy: aggregate trace visualizer
8b41737
import { useState } from "react";
import type { Preset } from "../types";
interface Props {
store: {
state: {
datasets: { id: string; repo: string; name: string; split: string; n_instances: number }[];
presets: Preset[];
loading: boolean;
};
loadDataset: (repo: string, split?: string) => Promise<void>;
unloadDataset: (dsId: string) => Promise<void>;
createPreset: (name: string, repo: string, split?: string) => Promise<void>;
deletePreset: (id: string) => Promise<void>;
loadPreset: (preset: Preset) => Promise<void>;
};
}
export function Sidebar({ store }: Props) {
const [repoInput, setRepoInput] = useState("");
const [splitInput, setSplitInput] = useState("train");
const [showAddForm, setShowAddForm] = useState(false);
// Per-dataset save-as-preset state
const [savingForDsId, setSavingForDsId] = useState<string | null>(null);
const [presetName, setPresetName] = useState("");
const [presetSearch, setPresetSearch] = useState("");
const handleLoad = async () => {
const repo = repoInput.trim();
if (!repo) return;
await store.loadDataset(repo, splitInput.trim() || "train");
setRepoInput("");
setShowAddForm(false);
};
const handleSavePreset = async (ds: { repo: string; split: string }) => {
if (!presetName.trim()) return;
await store.createPreset(presetName.trim(), ds.repo, ds.split);
setPresetName("");
setSavingForDsId(null);
};
return (
<div className="w-72 bg-gray-900 border-r border-gray-800 flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-gray-800">
<h1 className="text-lg font-semibold text-gray-100">Harbor Trace Viz</h1>
<p className="text-xs text-gray-500 mt-1">Harbor agent trajectory viewer</p>
</div>
{/* Presets */}
<div className="p-3 border-b border-gray-800">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">
Presets
</span>
</div>
{store.state.presets.length === 0 ? (
<div className="text-xs text-gray-600 italic">No saved presets</div>
) : (
<>
{store.state.presets.length > 6 && (
<input
type="text"
value={presetSearch}
onChange={(e) => setPresetSearch(e.target.value)}
placeholder="Search presets..."
className="w-full px-2 py-1 mb-2 text-xs bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none"
/>
)}
<div className="flex flex-wrap gap-1 max-h-32 overflow-y-auto">
{store.state.presets
.filter((p) =>
!presetSearch ||
p.name.toLowerCase().includes(presetSearch.toLowerCase()) ||
p.repo.toLowerCase().includes(presetSearch.toLowerCase())
)
.map((p) => (
<div key={p.id} className="group relative">
<button
onClick={() => store.loadPreset(p)}
className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 rounded border border-gray-600 text-gray-300 transition-colors"
title={`${p.repo} (${p.split})`}
>
{p.name}
</button>
<div className="hidden group-hover:flex absolute top-full left-0 mt-1 z-10 gap-1">
<button
onClick={() => store.deletePreset(p.id)}
className="px-1.5 py-0.5 text-[10px] bg-red-900 hover:bg-red-800 rounded text-red-300"
>
Delete
</button>
</div>
</div>
))}
</div>
</>
)}
</div>
{/* Loaded datasets */}
<div className="flex-1 overflow-y-auto p-3">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">
Loaded ({store.state.datasets.length})
</div>
{store.state.datasets.map((ds) => (
<div key={ds.id}>
<div className="group flex items-start justify-between py-2 border-b border-gray-800/50">
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-200 truncate font-medium" title={ds.repo}>
{ds.name}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{ds.n_instances} instances
</div>
</div>
{/* Save as preset */}
<button
onClick={() => {
setSavingForDsId(savingForDsId === ds.id ? null : ds.id);
setPresetName("");
}}
className={`text-xs ml-2 mt-0.5 transition-colors shrink-0 ${
savingForDsId === ds.id
? "text-teal-400"
: "text-gray-600 hover:text-teal-400 opacity-0 group-hover:opacity-100"
}`}
title="Save as preset"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
{/* Remove */}
<button
onClick={() => store.unloadDataset(ds.id)}
className="text-gray-600 hover:text-red-400 text-xs opacity-0 group-hover:opacity-100 ml-1 mt-0.5 shrink-0"
title="Remove"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Inline save-as-preset form */}
{savingForDsId === ds.id && (
<div className="flex gap-1 mt-1 mb-2">
<input
type="text"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSavePreset(ds);
if (e.key === "Escape") setSavingForDsId(null);
}}
placeholder="Preset name..."
className="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none"
autoFocus
/>
<button
onClick={() => handleSavePreset(ds)}
className="px-2 py-1 text-xs bg-teal-600 hover:bg-teal-500 rounded text-white"
>
Save
</button>
</div>
)}
</div>
))}
</div>
{/* Add repo */}
<div className="p-3 border-t border-gray-800">
{!showAddForm ? (
<button
onClick={() => {
setShowAddForm(true);
setRepoInput("");
setSplitInput("train");
}}
className="w-full px-3 py-2 text-sm bg-teal-600 hover:bg-teal-500 rounded text-white font-medium transition-colors"
>
+ Add Repo
</button>
) : (
<div className="space-y-2">
<input
type="text"
value={repoInput}
onChange={(e) => setRepoInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLoad()}
placeholder="org/repo-name"
className="w-full px-2 py-1.5 text-sm bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none"
autoFocus
/>
<input
type="text"
value={splitInput}
onChange={(e) => setSplitInput(e.target.value)}
placeholder="Split"
className="w-full px-2 py-1 text-xs bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none"
/>
<div className="flex gap-2">
<button
onClick={handleLoad}
disabled={store.state.loading || !repoInput.trim()}
className="flex-1 px-2 py-1.5 text-sm bg-teal-600 hover:bg-teal-500 disabled:bg-gray-700 disabled:text-gray-500 rounded text-white transition-colors"
>
{store.state.loading ? "Loading..." : "Load"}
</button>
<button
onClick={() => setShowAddForm(false)}
className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-300 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
);
}