feat: add command palette, accessibility, scroll animations, demo workspace, and keyboard navigation
- COMP-139: Command palette for quick navigation - COMP-140: Accessibility improvements - COMP-141: Scroll animations with animate-on-scroll component - COMP-143: Demo workspace with seed data and demo banner - COMP-145: Keyboard navigation and shortcuts help Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
258
apps/web/src/components/command-palette.tsx
Normal file
258
apps/web/src/components/command-palette.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Command } from "cmdk";
|
||||
import {
|
||||
Activity,
|
||||
GitBranch,
|
||||
Key,
|
||||
Settings,
|
||||
LogOut,
|
||||
Plus,
|
||||
Search,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RecentTrace {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [recentTraces, setRecentTraces] = useState<RecentTrace[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchRecentTraces = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/traces?limit=5", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRecentTraces(data.traces ?? []);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail -- palette still works for navigation
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchRecentTraces();
|
||||
}
|
||||
}, [open, fetchRecentTraces]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
function runCommand(command: () => void) {
|
||||
setOpen(false);
|
||||
command();
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Palette */}
|
||||
<div className="absolute inset-0 flex items-start justify-center pt-[20vh] px-4">
|
||||
<Command
|
||||
className="w-full max-w-xl rounded-xl border border-neutral-800 bg-neutral-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
loop
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-neutral-800 px-4">
|
||||
<Search className="w-4 h-4 text-neutral-500 shrink-0" />
|
||||
<Command.Input
|
||||
placeholder="Search traces, navigate, or run actions..."
|
||||
className="w-full py-4 bg-transparent text-sm text-neutral-100 placeholder-neutral-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-500">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<Command.List className="max-h-80 overflow-y-auto p-2">
|
||||
<Command.Empty className="py-8 text-center text-sm text-neutral-500">
|
||||
No results found.
|
||||
</Command.Empty>
|
||||
|
||||
{/* Recent Traces */}
|
||||
{recentTraces.length > 0 && (
|
||||
<Command.Group
|
||||
heading="Recent Traces"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="px-2 py-3 text-xs text-neutral-500">
|
||||
Loading traces...
|
||||
</div>
|
||||
) : (
|
||||
recentTraces.map((trace) => (
|
||||
<Command.Item
|
||||
key={trace.id}
|
||||
value={`trace ${trace.name} ${trace.id}`}
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
router.push(`/dashboard/traces/${trace.id}`)
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer transition-colors",
|
||||
"text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400"
|
||||
)}
|
||||
>
|
||||
<Activity className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1 truncate">{trace.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded",
|
||||
trace.status === "COMPLETED" &&
|
||||
"bg-emerald-500/10 text-emerald-400",
|
||||
trace.status === "ERROR" &&
|
||||
"bg-red-500/10 text-red-400",
|
||||
trace.status === "RUNNING" &&
|
||||
"bg-amber-500/10 text-amber-400"
|
||||
)}
|
||||
>
|
||||
{trace.status.toLowerCase()}
|
||||
</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 shrink-0 opacity-0 group-data-[selected=true]:opacity-100" />
|
||||
</Command.Item>
|
||||
))
|
||||
)}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Command.Group
|
||||
heading="Navigation"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
<Command.Item
|
||||
value="Dashboard Traces"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Activity className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Dashboard</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Decisions"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/decisions"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Decisions</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="API Keys"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/keys"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Key className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">API Keys</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Settings"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/settings"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Settings</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
{/* Actions */}
|
||||
<Command.Group
|
||||
heading="Actions"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
<Command.Item
|
||||
value="Create New API Key"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/keys"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">New API Key</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Sign Out Logout"
|
||||
onSelect={() =>
|
||||
runCommand(() => signOut({ callbackUrl: "/" }))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-red-500/10 data-[selected=true]:text-red-400 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Logout</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-neutral-800 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3 text-[11px] text-neutral-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
↑↓
|
||||
</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
↵
|
||||
</kbd>
|
||||
Select
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
esc
|
||||
</kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user