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:
Vectry
2026-02-10 18:06:36 +00:00
parent f9e7956e6f
commit 64c827ee84
18 changed files with 2047 additions and 40 deletions

View File

@@ -17,6 +17,7 @@
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ioredis": "^5.9.2", "ioredis": "^5.9.2",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"next": "^15.1.0", "next": "^15.1.0",

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { seedDemoData } from "@/lib/demo-data";
export async function POST() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { demoSeeded: true },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (user.demoSeeded) {
return NextResponse.json({ error: "Demo data already seeded" }, { status: 409 });
}
await seedDemoData(session.user.id);
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Error seeding demo data:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -12,6 +12,7 @@ import {
Shield, Shield,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
interface ApiKey { interface ApiKey {
id: string; id: string;
@@ -38,6 +39,21 @@ export default function ApiKeysPage() {
const [revokingId, setRevokingId] = useState<string | null>(null); const [revokingId, setRevokingId] = useState<string | null>(null);
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null); const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
const handleKeySelect = useCallback(
(index: number) => {
const key = keys[index];
if (key) {
setConfirmRevokeId(key.id);
}
},
[keys]
);
const { selectedIndex } = useKeyboardNav({
itemCount: keys.length,
onSelect: handleKeySelect,
});
const fetchKeys = useCallback(async () => { const fetchKeys = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -157,6 +173,7 @@ export default function ApiKeysPage() {
onClick={() => onClick={() =>
copyToClipboard(newlyCreatedKey.key, "new-key") copyToClipboard(newlyCreatedKey.key, "new-key")
} }
aria-label="Copy API key to clipboard"
className={cn( className={cn(
"p-3 rounded-lg border transition-all shrink-0", "p-3 rounded-lg border transition-all shrink-0",
copiedField === "new-key" copiedField === "new-key"
@@ -196,10 +213,11 @@ export default function ApiKeysPage() {
<h2 className="text-sm font-semibold">Create New API Key</h2> <h2 className="text-sm font-semibold">Create New API Key</h2>
</div> </div>
<div> <div>
<label className="text-xs text-neutral-500 font-medium block mb-1.5"> <label htmlFor="key-name" className="text-xs text-neutral-500 font-medium block mb-1.5">
Key Name (optional) Key Name (optional)
</label> </label>
<input <input
id="key-name"
type="text" type="text"
value={newKeyName} value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)} onChange={(e) => setNewKeyName(e.target.value)}
@@ -271,10 +289,16 @@ export default function ApiKeysPage() {
</div> </div>
) : ( ) : (
<div className="divide-y divide-neutral-800"> <div className="divide-y divide-neutral-800">
{keys.map((apiKey) => ( {keys.map((apiKey, index) => (
<div <div
key={apiKey.id} key={apiKey.id}
className="flex items-center gap-4 px-6 py-4 group" data-keyboard-index={index}
className={cn(
"flex items-center gap-4 px-6 py-4 group transition-colors",
index === selectedIndex
? "bg-emerald-500/5 ring-1 ring-inset ring-emerald-500/20"
: ""
)}
> >
<div className="w-9 h-9 rounded-lg bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center shrink-0"> <div className="w-9 h-9 rounded-lg bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center shrink-0">
<Key className="w-4 h-4 text-neutral-500" /> <Key className="w-4 h-4 text-neutral-500" />

View File

@@ -16,6 +16,8 @@ import {
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CommandPalette } from "@/components/command-palette";
import { KeyboardShortcutsHelp, ShortcutsHint } from "@/components/keyboard-shortcuts-help";
interface NavItem { interface NavItem {
href: string; href: string;
@@ -151,6 +153,7 @@ function VerificationBanner() {
</div> </div>
<button <button
onClick={() => setDismissed(true)} onClick={() => setDismissed(true)}
aria-label="Dismiss verification banner"
className="p-1 rounded text-amber-400/60 hover:text-amber-300 transition-colors shrink-0" className="p-1 rounded text-amber-400/60 hover:text-amber-300 transition-colors shrink-0"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -165,6 +168,9 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
return ( return (
<div className="min-h-screen bg-neutral-950 flex"> <div className="min-h-screen bg-neutral-950 flex">
<CommandPalette />
<KeyboardShortcutsHelp />
<ShortcutsHint />
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<aside className="hidden lg:block w-64 h-screen sticky top-0"> <aside className="hidden lg:block w-64 h-screen sticky top-0">
<Sidebar /> <Sidebar />
@@ -189,13 +195,14 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
</aside> </aside>
{/* Main Content */} {/* Main Content */}
<main className="flex-1 min-w-0"> <main id="main-content" className="flex-1 min-w-0">
<VerificationBanner /> <VerificationBanner />
{/* Mobile Header */} {/* Mobile Header */}
<header className="lg:hidden sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-4 py-3"> <header className="lg:hidden sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-4 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <button
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
aria-label="Open navigation menu"
className="p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-neutral-100 transition-colors" className="p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-neutral-100 transition-colors"
> >
<Menu className="w-5 h-5" /> <Menu className="w-5 h-5" />

View File

@@ -1,24 +1,29 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { TraceList } from "@/components/trace-list"; import { TraceList } from "@/components/trace-list";
import { DemoSeedTrigger } from "@/components/demo-seed-trigger";
import { DemoBanner } from "@/components/demo-banner";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
interface TraceItem {
id: string;
name: string;
status: "RUNNING" | "COMPLETED" | "ERROR";
startedAt: string;
endedAt: string | null;
durationMs: number | null;
tags: string[];
metadata: Record<string, unknown>;
isDemo?: boolean;
_count: {
decisionPoints: number;
spans: number;
events: number;
};
}
interface TracesResponse { interface TracesResponse {
traces: Array<{ traces: TraceItem[];
id: string;
name: string;
status: "RUNNING" | "COMPLETED" | "ERROR";
startedAt: string;
endedAt: string | null;
durationMs: number | null;
tags: string[];
metadata: Record<string, unknown>;
_count: {
decisionPoints: number;
spans: number;
events: number;
};
}>;
total: number; total: number;
page: number; page: number;
limit: number; limit: number;
@@ -55,14 +60,21 @@ async function getTraces(
export default async function DashboardPage() { export default async function DashboardPage() {
const data = await getTraces(50, 1); const data = await getTraces(50, 1);
const hasTraces = data.traces.length > 0;
const allTracesAreDemo =
hasTraces && data.traces.every((t) => t.isDemo === true);
return ( return (
<Suspense fallback={<div className="p-8 text-zinc-400">Loading traces...</div>}> <DemoSeedTrigger hasTraces={hasTraces}>
<TraceList {allTracesAreDemo && <DemoBanner allTracesAreDemo={allTracesAreDemo} />}
initialTraces={data.traces} <Suspense fallback={<div className="p-8 text-zinc-400">Loading traces...</div>}>
initialTotal={data.total} <TraceList
initialTotalPages={data.totalPages} initialTraces={data.traces}
initialPage={data.page} initialTotal={data.total}
/> initialTotalPages={data.totalPages}
</Suspense> initialPage={data.page}
/>
</Suspense>
</DemoSeedTrigger>
); );
} }

View File

@@ -36,3 +36,39 @@
--font-mono: var(--font-jetbrains), 'JetBrains Mono', 'Fira Code', monospace; --font-mono: var(--font-jetbrains), 'JetBrains Mono', 'Fira Code', monospace;
} }
} }
[data-animate="hidden"] {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
[data-animate="visible"] {
opacity: 1;
transform: translateY(0);
}
[data-animate="hidden"][style*="animation-delay"] {
transition-delay: inherit;
}
@media (prefers-reduced-motion: reduce) {
[data-animate="hidden"] {
opacity: 1;
transform: none;
transition: none;
}
}
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[role="button"]:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}

View File

@@ -74,6 +74,12 @@ export default function RootLayout({
return ( return (
<html lang="en" className="dark"> <html lang="en" className="dark">
<body className={`${inter.variable} ${jetbrainsMono.variable} bg-neutral-950 text-neutral-100 antialiased`}> <body className={`${inter.variable} ${jetbrainsMono.variable} bg-neutral-950 text-neutral-100 antialiased`}>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[200] focus:px-4 focus:py-2 focus:rounded-lg focus:bg-emerald-500 focus:text-neutral-950 focus:font-semibold focus:text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2 focus:ring-offset-neutral-950"
>
Skip to content
</a>
<SessionProvider>{children}</SessionProvider> <SessionProvider>{children}</SessionProvider>
</body> </body>
</html> </html>

View File

@@ -17,10 +17,11 @@ import {
Clipboard, Clipboard,
Shield, Shield,
} from "lucide-react"; } from "lucide-react";
import { AnimateOnScroll } from "@/components/animate-on-scroll";
export default function HomePage() { export default function HomePage() {
return ( return (
<div className="min-h-screen bg-neutral-950"> <main id="main-content" className="min-h-screen bg-neutral-950">
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -161,6 +162,7 @@ export default function HomePage() {
{/* Features Section */} {/* Features Section */}
<section className="py-24 border-b border-neutral-800/50"> <section className="py-24 border-b border-neutral-800/50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<AnimateOnScroll>
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold mb-4"> <h2 className="text-3xl sm:text-4xl font-bold mb-4">
Everything you need to understand your agents Everything you need to understand your agents
@@ -169,9 +171,11 @@ export default function HomePage() {
From decision trees to cost intelligence, get complete visibility into how your AI systems operate From decision trees to cost intelligence, get complete visibility into how your AI systems operate
</p> </p>
</div> </div>
</AnimateOnScroll>
<div className="grid md:grid-cols-3 gap-8"> <div className="grid md:grid-cols-3 gap-8">
{/* Feature 1: Decision Trees */} {/* Feature 1: Decision Trees */}
<AnimateOnScroll delay={0}>
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300"> <div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"> <div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
<GitBranch className="w-7 h-7 text-emerald-400" /> <GitBranch className="w-7 h-7 text-emerald-400" />
@@ -181,8 +185,10 @@ export default function HomePage() {
Visualize the complete reasoning behind every agent choice. See the branching logic, alternatives considered, and the path chosen. Visualize the complete reasoning behind every agent choice. See the branching logic, alternatives considered, and the path chosen.
</p> </p>
</div> </div>
</AnimateOnScroll>
{/* Feature 2: Context Awareness */} {/* Feature 2: Context Awareness */}
<AnimateOnScroll delay={100}>
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300"> <div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"> <div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
<Brain className="w-7 h-7 text-emerald-400" /> <Brain className="w-7 h-7 text-emerald-400" />
@@ -192,8 +198,10 @@ export default function HomePage() {
Monitor context window utilization in real-time. Track what&apos;s being fed into your agents and what&apos;s being left behind. Monitor context window utilization in real-time. Track what&apos;s being fed into your agents and what&apos;s being left behind.
</p> </p>
</div> </div>
</AnimateOnScroll>
{/* Feature 3: Cost Intelligence */} {/* Feature 3: Cost Intelligence */}
<AnimateOnScroll delay={200}>
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300"> <div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"> <div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
<DollarSign className="w-7 h-7 text-emerald-400" /> <DollarSign className="w-7 h-7 text-emerald-400" />
@@ -203,6 +211,7 @@ export default function HomePage() {
Track spending per decision, per agent, per trace. Get granular insights into where every dollar goes in your AI operations. Track spending per decision, per agent, per trace. Get granular insights into where every dollar goes in your AI operations.
</p> </p>
</div> </div>
</AnimateOnScroll>
</div> </div>
</div> </div>
</section> </section>
@@ -211,6 +220,7 @@ export default function HomePage() {
<section className="py-24 border-b border-neutral-800/50 relative"> <section className="py-24 border-b border-neutral-800/50 relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_60%_40%_at_50%_50%,rgba(16,185,129,0.04),transparent)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_60%_40%_at_50%_50%,rgba(16,185,129,0.04),transparent)]" />
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<AnimateOnScroll>
<div className="text-center mb-16"> <div className="text-center mb-16">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
<Zap className="w-4 h-4" /> <Zap className="w-4 h-4" />
@@ -223,9 +233,11 @@ export default function HomePage() {
Go from zero to full agent observability in under five minutes Go from zero to full agent observability in under five minutes
</p> </p>
</div> </div>
</AnimateOnScroll>
<div className="grid md:grid-cols-3 gap-6 lg:gap-8"> <div className="grid md:grid-cols-3 gap-6 lg:gap-8">
{/* Step 1: Install */} {/* Step 1: Install */}
<AnimateOnScroll delay={0}>
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30"> <div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
<div className="absolute -top-4 left-8"> <div className="absolute -top-4 left-8">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25"> <span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
@@ -243,8 +255,10 @@ export default function HomePage() {
<code className="text-sm font-mono text-emerald-400">pip install vectry-agentlens</code> <code className="text-sm font-mono text-emerald-400">pip install vectry-agentlens</code>
</div> </div>
</div> </div>
</AnimateOnScroll>
{/* Step 2: Instrument */} {/* Step 2: Instrument */}
<AnimateOnScroll delay={100}>
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30"> <div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
<div className="absolute -top-4 left-8"> <div className="absolute -top-4 left-8">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25"> <span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
@@ -264,8 +278,10 @@ export default function HomePage() {
<code className="text-sm font-mono text-emerald-400">wrap_openai()</code> <code className="text-sm font-mono text-emerald-400">wrap_openai()</code>
</div> </div>
</div> </div>
</AnimateOnScroll>
{/* Step 3: Observe */} {/* Step 3: Observe */}
<AnimateOnScroll delay={200}>
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30"> <div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
<div className="absolute -top-4 left-8"> <div className="absolute -top-4 left-8">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25"> <span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
@@ -283,6 +299,7 @@ export default function HomePage() {
<code className="text-sm font-mono text-emerald-400">agentlens.vectry.tech</code> <code className="text-sm font-mono text-emerald-400">agentlens.vectry.tech</code>
</div> </div>
</div> </div>
</AnimateOnScroll>
</div> </div>
{/* Connecting arrows decoration */} {/* Connecting arrows decoration */}
@@ -302,6 +319,7 @@ export default function HomePage() {
<section className="py-24 border-b border-neutral-800/50"> <section className="py-24 border-b border-neutral-800/50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-12 items-start"> <div className="grid lg:grid-cols-2 gap-12 items-start">
<AnimateOnScroll>
<div className="lg:sticky lg:top-8"> <div className="lg:sticky lg:top-8">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
<Cpu className="w-4 h-4" /> <Cpu className="w-4 h-4" />
@@ -331,8 +349,10 @@ export default function HomePage() {
))} ))}
</ul> </ul>
</div> </div>
</AnimateOnScroll>
{/* Code Blocks - Two patterns stacked */} {/* Code Blocks - Two patterns stacked */}
<AnimateOnScroll delay={150}>
<div className="space-y-6"> <div className="space-y-6">
{/* Decorator Pattern */} {/* Decorator Pattern */}
<div className="rounded-xl overflow-hidden border border-neutral-800 bg-neutral-900/50 backdrop-blur-sm"> <div className="rounded-xl overflow-hidden border border-neutral-800 bg-neutral-900/50 backdrop-blur-sm">
@@ -480,6 +500,7 @@ export default function HomePage() {
</pre> </pre>
</div> </div>
</div> </div>
</AnimateOnScroll>
</div> </div>
</div> </div>
</section> </section>
@@ -488,6 +509,7 @@ export default function HomePage() {
<section className="py-24 border-b border-neutral-800/50 relative"> <section className="py-24 border-b border-neutral-800/50 relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_50%_50%_at_50%_50%,rgba(16,185,129,0.03),transparent)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_50%_50%_at_50%_50%,rgba(16,185,129,0.03),transparent)]" />
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<AnimateOnScroll>
<div className="text-center mb-16"> <div className="text-center mb-16">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
<Link2 className="w-4 h-4" /> <Link2 className="w-4 h-4" />
@@ -500,7 +522,9 @@ export default function HomePage() {
First-class support for the most popular AI frameworks. Drop in and start tracing. First-class support for the most popular AI frameworks. Drop in and start tracing.
</p> </p>
</div> </div>
</AnimateOnScroll>
<AnimateOnScroll>
<div className="grid sm:grid-cols-3 gap-6 max-w-3xl mx-auto"> <div className="grid sm:grid-cols-3 gap-6 max-w-3xl mx-auto">
{/* OpenAI */} {/* OpenAI */}
<div className="group flex flex-col items-center p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30 hover:border-emerald-500/20 transition-all duration-300"> <div className="group flex flex-col items-center p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30 hover:border-emerald-500/20 transition-all duration-300">
@@ -538,6 +562,7 @@ export default function HomePage() {
</span> </span>
</div> </div>
</div> </div>
</AnimateOnScroll>
</div> </div>
</section> </section>
@@ -545,6 +570,7 @@ export default function HomePage() {
<section className="py-24 border-b border-neutral-800/50 relative"> <section className="py-24 border-b border-neutral-800/50 relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_50%,rgba(16,185,129,0.04),transparent)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_50%,rgba(16,185,129,0.04),transparent)]" />
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<AnimateOnScroll>
<div className="text-center mb-16"> <div className="text-center mb-16">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
<Shield className="w-4 h-4" /> <Shield className="w-4 h-4" />
@@ -557,7 +583,9 @@ export default function HomePage() {
No hidden fees. Start free, scale as you grow. Every plan includes the full dashboard experience. No hidden fees. Start free, scale as you grow. Every plan includes the full dashboard experience.
</p> </p>
</div> </div>
</AnimateOnScroll>
<AnimateOnScroll>
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto"> <div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
{/* Free Tier */} {/* Free Tier */}
<div className="relative flex flex-col p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30 transition-all duration-300 hover:border-neutral-700"> <div className="relative flex flex-col p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30 transition-all duration-300 hover:border-neutral-700">
@@ -657,6 +685,7 @@ export default function HomePage() {
</a> </a>
</div> </div>
</div> </div>
</AnimateOnScroll>
</div> </div>
</section> </section>
@@ -689,6 +718,6 @@ export default function HomePage() {
</div> </div>
</div> </div>
</footer> </footer>
</div> </main>
); );
} }

View File

@@ -0,0 +1,60 @@
"use client";
import { useEffect, useRef, ReactNode } from "react";
interface AnimateOnScrollProps {
children: ReactNode;
className?: string;
delay?: number;
threshold?: number;
}
export function AnimateOnScroll({
children,
className = "",
delay = 0,
threshold = 0.15,
}: AnimateOnScrollProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReduced) {
el.setAttribute("data-animate", "visible");
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.setAttribute("data-animate", "visible");
observer.unobserve(entry.target);
}
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return (
<div
ref={ref}
data-animate="hidden"
className={className}
style={{ animationDelay: delay ? `${delay}ms` : undefined }}
>
{children}
</div>
);
}

View 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">
&uarr;&darr;
</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">
&crarr;
</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>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Beaker, ArrowRight, X } from "lucide-react";
import { cn } from "@/lib/utils";
const DISMISS_KEY = "agentlens-demo-banner-dismissed";
interface DemoBannerProps {
allTracesAreDemo: boolean;
}
export function DemoBanner({ allTracesAreDemo }: DemoBannerProps) {
const [dismissed, setDismissed] = useState(true);
useEffect(() => {
setDismissed(localStorage.getItem(DISMISS_KEY) === "true");
}, []);
if (dismissed || !allTracesAreDemo) return null;
function handleDismiss() {
setDismissed(true);
localStorage.setItem(DISMISS_KEY, "true");
}
return (
<div
className={cn(
"relative mb-6 rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4",
"flex items-center gap-4"
)}
>
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-500/10 border border-emerald-500/20 shrink-0">
<Beaker className="w-5 h-5 text-emerald-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-emerald-200 font-medium">
You are viewing sample data.
</p>
<p className="text-xs text-emerald-400/60 mt-0.5">
Connect your agent to start collecting real traces.
</p>
</div>
<Link
href="/docs/getting-started"
className="hidden sm:flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors shrink-0"
>
View Setup Guide
<ArrowRight className="w-3.5 h-3.5" />
</Link>
<button
onClick={handleDismiss}
aria-label="Dismiss demo banner"
className="p-1.5 rounded-lg text-emerald-400/40 hover:text-emerald-400/80 hover:bg-emerald-500/10 transition-colors shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useEffect, useState } from "react";
interface DemoSeedTriggerProps {
hasTraces: boolean;
children: React.ReactNode;
}
export function DemoSeedTrigger({ hasTraces, children }: DemoSeedTriggerProps) {
const [seeding, setSeeding] = useState(false);
useEffect(() => {
if (hasTraces || seeding) return;
async function seedIfNeeded() {
setSeeding(true);
try {
const res = await fetch("/api/demo/seed", { method: "POST" });
if (res.ok) {
window.location.reload();
}
} catch {
// Seed failed, continue showing empty state
} finally {
setSeeding(false);
}
}
seedIfNeeded();
}, [hasTraces, seeding]);
if (!hasTraces && seeding) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-10 h-10 rounded-xl border-2 border-emerald-500/30 border-t-emerald-500 animate-spin mb-4" />
<p className="text-sm text-neutral-400">Setting up your workspace with sample data...</p>
</div>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,113 @@
"use client";
import { useState, useEffect } from "react";
import { X } from "lucide-react";
const shortcuts = [
{ keys: ["j"], description: "Move selection down" },
{ keys: ["k"], description: "Move selection up" },
{ keys: ["Enter"], description: "Open selected item" },
{ keys: ["Escape"], description: "Clear selection / go back" },
{ keys: ["g", "h"], description: "Go to Dashboard" },
{ keys: ["g", "s"], description: "Go to Settings" },
{ keys: ["g", "k"], description: "Go to API Keys" },
{ keys: ["g", "d"], description: "Go to Decisions" },
{ keys: ["Cmd", "K"], description: "Open command palette" },
{ keys: ["?"], description: "Show this help" },
];
export function KeyboardShortcutsHelp() {
const [open, setOpen] = useState(false);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const el = document.activeElement;
if (el) {
const tag = el.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return;
if ((el as HTMLElement).isContentEditable) return;
}
if (e.key === "?" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setOpen((prev) => !prev);
}
if (e.key === "Escape" && open) {
setOpen(false);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[90]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<div className="absolute inset-0 flex items-center justify-center px-4">
<div className="w-full max-w-md rounded-xl border border-neutral-800 bg-neutral-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
<h2 className="text-sm font-semibold text-neutral-100">
Keyboard Shortcuts
</h2>
<button
onClick={() => setOpen(false)}
aria-label="Close shortcuts help"
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-2 max-h-[60vh] overflow-y-auto">
{shortcuts.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-neutral-800/50"
>
<span className="text-sm text-neutral-400">
{shortcut.description}
</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, j) => (
<span key={j}>
{j > 0 && (
<span className="text-neutral-600 text-xs mx-0.5">
then
</span>
)}
<kbd className="inline-flex items-center justify-center min-w-[24px] px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-xs font-mono text-neutral-300">
{key}
</kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export function ShortcutsHint() {
return (
<div className="fixed bottom-4 right-4 z-30">
<span className="text-xs text-neutral-600 flex items-center gap-1.5">
Press
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-500">
?
</kbd>
for shortcuts
</span>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { import {
Search, Search,
Filter, Filter,
@@ -23,6 +23,7 @@ import {
WifiOff, WifiOff,
} from "lucide-react"; } from "lucide-react";
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils"; import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR"; type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
@@ -87,6 +88,7 @@ export function TraceList({
initialTotalPages, initialTotalPages,
initialPage, initialPage,
}: TraceListProps) { }: TraceListProps) {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [traces, setTraces] = useState<Trace[]>(initialTraces); const [traces, setTraces] = useState<Trace[]>(initialTraces);
const [total, setTotal] = useState(initialTotal); const [total, setTotal] = useState(initialTotal);
@@ -283,6 +285,19 @@ export function TraceList({
return matchesSearch && matchesStatus; return matchesSearch && matchesStatus;
}); });
const { selectedIndex } = useKeyboardNav({
itemCount: filteredTraces.length,
onSelect: useCallback(
(index: number) => {
const trace = filteredTraces[index];
if (trace) {
router.push(`/dashboard/traces/${trace.id}`);
}
},
[filteredTraces, router]
),
});
const filterChips: { value: FilterStatus; label: string }[] = [ const filterChips: { value: FilterStatus; label: string }[] = [
{ value: "ALL", label: "All" }, { value: "ALL", label: "All" },
{ value: "RUNNING", label: "Running" }, { value: "RUNNING", label: "Running" },
@@ -376,7 +391,9 @@ export function TraceList({
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" />
<label htmlFor="trace-search" className="sr-only">Search traces</label>
<input <input
id="trace-search"
type="text" type="text"
placeholder="Search traces..." placeholder="Search traces..."
value={searchQuery} value={searchQuery}
@@ -422,8 +439,9 @@ export function TraceList({
{showAdvancedFilters && ( {showAdvancedFilters && (
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs text-neutral-500 font-medium">Sort by</label> <label htmlFor="sort-filter" className="text-xs text-neutral-500 font-medium">Sort by</label>
<select <select
id="sort-filter"
value={sortFilter} value={sortFilter}
onChange={(e) => setSortFilter(e.target.value as SortOption)} onChange={(e) => setSortFilter(e.target.value as SortOption)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 transition-all" className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 transition-all"
@@ -437,8 +455,9 @@ export function TraceList({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs text-neutral-500 font-medium">Date from</label> <label htmlFor="date-from" className="text-xs text-neutral-500 font-medium">Date from</label>
<input <input
id="date-from"
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)} onChange={(e) => setDateFrom(e.target.value)}
@@ -447,8 +466,9 @@ export function TraceList({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs text-neutral-500 font-medium">Date to</label> <label htmlFor="date-to" className="text-xs text-neutral-500 font-medium">Date to</label>
<input <input
id="date-to"
type="date" type="date"
value={dateTo} value={dateTo}
onChange={(e) => setDateTo(e.target.value)} onChange={(e) => setDateTo(e.target.value)}
@@ -457,8 +477,9 @@ export function TraceList({
</div> </div>
<div className="sm:col-span-3 space-y-2"> <div className="sm:col-span-3 space-y-2">
<label className="text-xs text-neutral-500 font-medium">Tags (comma-separated)</label> <label htmlFor="tags-filter" className="text-xs text-neutral-500 font-medium">Tags (comma-separated)</label>
<input <input
id="tags-filter"
type="text" type="text"
placeholder="e.g., production, critical, api" placeholder="e.g., production, critical, api"
value={tagsFilter} value={tagsFilter}
@@ -473,8 +494,13 @@ export function TraceList({
{/* Trace List */} {/* Trace List */}
<div className="space-y-3"> <div className="space-y-3">
{filteredTraces.map((trace) => ( {filteredTraces.map((trace, index) => (
<TraceCard key={trace.id} trace={trace} /> <TraceCard
key={trace.id}
trace={trace}
index={index}
isSelected={index === selectedIndex}
/>
))} ))}
</div> </div>
@@ -497,6 +523,7 @@ export function TraceList({
<button <button
disabled={currentPage <= 1} disabled={currentPage <= 1}
onClick={() => handlePageChange(Math.max(1, currentPage - 1))} onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
aria-label="Previous page"
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
<ChevronLeft className="w-5 h-5" /> <ChevronLeft className="w-5 h-5" />
@@ -504,6 +531,7 @@ export function TraceList({
<button <button
disabled={currentPage >= totalPages} disabled={currentPage >= totalPages}
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))} onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
aria-label="Next page"
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
<ChevronRight className="w-5 h-5" /> <ChevronRight className="w-5 h-5" />
@@ -515,13 +543,29 @@ export function TraceList({
); );
} }
function TraceCard({ trace }: { trace: Trace }) { function TraceCard({
trace,
index,
isSelected,
}: {
trace: Trace;
index: number;
isSelected: boolean;
}) {
const status = statusConfig[trace.status]; const status = statusConfig[trace.status];
const StatusIcon = status.icon; const StatusIcon = status.icon;
return ( return (
<Link href={`/dashboard/traces/${trace.id}`}> <Link href={`/dashboard/traces/${trace.id}`}>
<div className="group p-5 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer"> <div
data-keyboard-index={index}
className={cn(
"group p-5 bg-neutral-900 border rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer",
isSelected
? "border-emerald-500/40 bg-emerald-500/5 ring-1 ring-emerald-500/20"
: "border-neutral-800"
)}
>
<div className="flex flex-col lg:flex-row lg:items-center gap-4"> <div className="flex flex-col lg:flex-row lg:items-center gap-4">
{/* Left: Name and Status */} {/* Left: Name and Status */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View File

@@ -0,0 +1,123 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
function isInputFocused(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return true;
if ((el as HTMLElement).isContentEditable) return true;
return false;
}
interface UseKeyboardNavOptions {
itemCount: number;
onSelect: (index: number) => void;
enabled?: boolean;
}
export function useKeyboardNav({
itemCount,
onSelect,
enabled = true,
}: UseKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState(-1);
const router = useRouter();
const gPressedRef = useRef(false);
const gTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const resetSelection = useCallback(() => {
setSelectedIndex(-1);
}, []);
useEffect(() => {
if (!enabled) return;
function handleKeyDown(e: KeyboardEvent) {
if (isInputFocused()) return;
if (gPressedRef.current) {
gPressedRef.current = false;
clearTimeout(gTimerRef.current);
if (e.key === "h") {
e.preventDefault();
router.push("/dashboard");
return;
}
if (e.key === "s") {
e.preventDefault();
router.push("/dashboard/settings");
return;
}
if (e.key === "k") {
e.preventDefault();
router.push("/dashboard/keys");
return;
}
if (e.key === "d") {
e.preventDefault();
router.push("/dashboard/decisions");
return;
}
return;
}
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
gPressedRef.current = true;
gTimerRef.current = setTimeout(() => {
gPressedRef.current = false;
}, 500);
return;
}
if (e.key === "j" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setSelectedIndex((prev) => {
const next = prev + 1;
return next >= itemCount ? itemCount - 1 : next;
});
return;
}
if (e.key === "k" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setSelectedIndex((prev) => {
const next = prev - 1;
return next < 0 ? 0 : next;
});
return;
}
if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault();
onSelect(selectedIndex);
return;
}
if (e.key === "Escape") {
setSelectedIndex(-1);
return;
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
clearTimeout(gTimerRef.current);
};
}, [enabled, itemCount, selectedIndex, onSelect, router]);
useEffect(() => {
if (selectedIndex < 0) return;
const row = document.querySelector(`[data-keyboard-index="${selectedIndex}"]`);
if (row) {
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedIndex]);
return { selectedIndex, setSelectedIndex, resetSelection };
}

View File

@@ -0,0 +1,554 @@
import { prisma } from "@/lib/prisma";
import type { Prisma, SpanType } from "@agentlens/database";
type EventCreate = Prisma.EventCreateWithoutTraceInput;
type DecisionCreate = Prisma.DecisionPointCreateWithoutTraceInput;
interface DemoSpan {
id: string;
name: string;
type: SpanType;
status: "RUNNING" | "COMPLETED" | "ERROR";
parentSpanId?: string;
input?: Prisma.InputJsonValue;
output?: Prisma.InputJsonValue;
tokenCount?: number;
costUsd?: number;
durationMs?: number;
startedAt: Date;
endedAt?: Date;
metadata?: Prisma.InputJsonValue;
statusMessage?: string;
}
function daysAgo(days: number, offsetMs = 0): Date {
const d = new Date();
d.setDate(d.getDate() - days);
d.setMilliseconds(d.getMilliseconds() + offsetMs);
return d;
}
function endDate(start: Date, durationMs: number): Date {
return new Date(start.getTime() + durationMs);
}
export async function seedDemoData(userId: string) {
const traces = [
createSimpleChatTrace(userId),
createMultiToolAgentTrace(userId),
createRagPipelineTrace(userId),
createErrorHandlingTrace(userId),
createLongRunningWorkflowTrace(userId),
createCodeAnalysisTrace(userId),
createWebSearchTrace(userId),
];
for (const traceFn of traces) {
const { trace, spans, events, decisions } = traceFn;
await prisma.trace.create({
data: {
...trace,
spans: { create: spans },
events: { create: events },
decisionPoints: { create: decisions },
},
});
}
await prisma.user.update({
where: { id: userId },
data: { demoSeeded: true },
});
}
function createSimpleChatTrace(userId: string) {
const start = daysAgo(1);
const duration = 1240;
const spanId = `demo-span-chat-${userId.slice(0, 8)}`;
return {
trace: {
name: "Simple Chat Completion",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["openai", "chat"],
metadata: { model: "gpt-4o", temperature: 0.7 },
totalCost: 0.0032,
totalTokens: 245,
totalDuration: duration,
startedAt: start,
endedAt: endDate(start, duration),
},
spans: [
{
id: spanId,
name: "chat.completions.create",
type: "LLM_CALL" as const,
status: "COMPLETED" as const,
input: { messages: [{ role: "user", content: "Explain quantum computing in simple terms" }] },
output: { content: "Quantum computing uses quantum bits (qubits) that can exist in multiple states simultaneously..." },
tokenCount: 245,
costUsd: 0.0032,
durationMs: duration,
startedAt: start,
endedAt: endDate(start, duration),
metadata: { model: "gpt-4o", provider: "openai" },
},
],
events: [] as EventCreate[],
decisions: [] as DecisionCreate[],
};
}
function createMultiToolAgentTrace(userId: string) {
const start = daysAgo(2);
const parentId = `demo-span-agent-${userId.slice(0, 8)}`;
const toolIds = [
`demo-span-tool1-${userId.slice(0, 8)}`,
`demo-span-tool2-${userId.slice(0, 8)}`,
`demo-span-tool3-${userId.slice(0, 8)}`,
];
const llmId = `demo-span-llm-${userId.slice(0, 8)}`;
return {
trace: {
name: "Multi-Tool Agent Run",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["agent", "tools", "production"],
metadata: { agent: "research-assistant", run_id: "demo-run-001" },
totalCost: 0.0187,
totalTokens: 1823,
totalDuration: 8420,
startedAt: start,
endedAt: endDate(start, 8420),
},
spans: [
{
id: parentId,
name: "research-assistant",
type: "AGENT" as const,
status: "COMPLETED" as const,
durationMs: 8420,
startedAt: start,
endedAt: endDate(start, 8420),
metadata: { max_iterations: 5 },
},
{
id: toolIds[0],
name: "web_search",
type: "TOOL_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: parentId,
input: { query: "latest AI research papers 2026" },
output: { results: [{ title: "Scaling Laws for Neural Language Models", url: "https://arxiv.org/..." }] },
durationMs: 2100,
startedAt: endDate(start, 200),
endedAt: endDate(start, 2300),
},
{
id: toolIds[1],
name: "document_reader",
type: "TOOL_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: parentId,
input: { url: "https://arxiv.org/..." },
output: { content: "Abstract: We study empirical scaling laws for language model performance..." },
durationMs: 1800,
startedAt: endDate(start, 2400),
endedAt: endDate(start, 4200),
},
{
id: toolIds[2],
name: "summarizer",
type: "TOOL_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: parentId,
input: { text: "Abstract: We study empirical scaling laws..." },
output: { summary: "The paper examines how language model performance scales with compute, data, and model size." },
durationMs: 1500,
startedAt: endDate(start, 4300),
endedAt: endDate(start, 5800),
},
{
id: llmId,
name: "gpt-4o-synthesis",
type: "LLM_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: parentId,
input: { messages: [{ role: "system", content: "Synthesize research findings" }] },
output: { content: "Based on the latest research, AI scaling laws suggest..." },
tokenCount: 1823,
costUsd: 0.0187,
durationMs: 2400,
startedAt: endDate(start, 5900),
endedAt: endDate(start, 8300),
metadata: { model: "gpt-4o" },
},
],
events: [] as EventCreate[],
decisions: [
{
type: "TOOL_SELECTION" as const,
reasoning: "User asked about latest AI research, need web search to get current information",
chosen: { tool: "web_search", args: { query: "latest AI research papers 2026" } },
alternatives: [{ tool: "memory_lookup" }, { tool: "knowledge_base" }],
parentSpanId: parentId,
durationMs: 150,
costUsd: 0.001,
timestamp: endDate(start, 100),
},
{
type: "ROUTING" as const,
reasoning: "Search results contain arxiv links, routing to document reader for full content",
chosen: { next_step: "document_reader" },
alternatives: [{ next_step: "direct_response" }, { next_step: "ask_clarification" }],
parentSpanId: parentId,
durationMs: 80,
costUsd: 0.0005,
timestamp: endDate(start, 2350),
},
],
};
}
function createRagPipelineTrace(userId: string) {
const start = daysAgo(3);
const retrievalId = `demo-span-retrieval-${userId.slice(0, 8)}`;
const embeddingId = `demo-span-embed-${userId.slice(0, 8)}`;
const genId = `demo-span-gen-${userId.slice(0, 8)}`;
return {
trace: {
name: "RAG Pipeline",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["rag", "retrieval", "embeddings"],
metadata: { pipeline: "knowledge-qa", version: "2.1" },
totalCost: 0.0091,
totalTokens: 892,
totalDuration: 4350,
startedAt: start,
endedAt: endDate(start, 4350),
},
spans: [
{
id: embeddingId,
name: "embed_query",
type: "LLM_CALL" as const,
status: "COMPLETED" as const,
input: { text: "How does our refund policy work?" },
output: { embedding: [0.023, -0.041, 0.089] },
tokenCount: 12,
costUsd: 0.00001,
durationMs: 320,
startedAt: start,
endedAt: endDate(start, 320),
metadata: { model: "text-embedding-3-small" },
},
{
id: retrievalId,
name: "vector_search",
type: "MEMORY_OP" as const,
status: "COMPLETED" as const,
input: { embedding: [0.023, -0.041, 0.089], top_k: 5 },
output: { documents: [{ id: "doc-1", score: 0.92, title: "Refund Policy v3" }] },
durationMs: 180,
startedAt: endDate(start, 400),
endedAt: endDate(start, 580),
metadata: { index: "company-docs", results_count: 5 },
},
{
id: genId,
name: "generate_answer",
type: "LLM_CALL" as const,
status: "COMPLETED" as const,
input: { messages: [{ role: "system", content: "Answer using the provided context" }] },
output: { content: "Our refund policy allows returns within 30 days of purchase..." },
tokenCount: 880,
costUsd: 0.009,
durationMs: 3600,
startedAt: endDate(start, 650),
endedAt: endDate(start, 4250),
metadata: { model: "gpt-4o-mini" },
},
],
events: [] as EventCreate[],
decisions: [
{
type: "MEMORY_RETRIEVAL" as const,
reasoning: "Query about refund policy matched knowledge base with high confidence",
chosen: { source: "vector_search", confidence: 0.92 },
alternatives: [{ source: "web_search" }, { source: "ask_human" }],
durationMs: 50,
timestamp: endDate(start, 350),
},
],
};
}
function createErrorHandlingTrace(userId: string) {
const start = daysAgo(5);
const spanId = `demo-span-err-${userId.slice(0, 8)}`;
return {
trace: {
name: "Error Handling Example",
userId,
status: "ERROR" as const,
isDemo: true,
tags: ["error", "rate-limit"],
metadata: { error_type: "RateLimitError", retries: 3 },
totalCost: 0.0,
totalTokens: 0,
totalDuration: 15200,
startedAt: start,
endedAt: endDate(start, 15200),
},
spans: [
{
id: spanId,
name: "chat.completions.create",
type: "LLM_CALL" as const,
status: "ERROR" as const,
statusMessage: "RateLimitError: Rate limit exceeded. Retry after 30s.",
input: { messages: [{ role: "user", content: "Analyze this dataset" }] },
durationMs: 15200,
startedAt: start,
endedAt: endDate(start, 15200),
metadata: { model: "gpt-4o", retry_count: 3 },
},
],
events: [
{
type: "ERROR" as const,
name: "RateLimitError",
spanId,
metadata: { message: "Rate limit exceeded", status_code: 429 },
timestamp: endDate(start, 5000),
},
{
type: "RETRY" as const,
name: "Retry attempt 1",
spanId,
metadata: { attempt: 1, backoff_ms: 2000 },
timestamp: endDate(start, 7000),
},
{
type: "RETRY" as const,
name: "Retry attempt 2",
spanId,
metadata: { attempt: 2, backoff_ms: 4000 },
timestamp: endDate(start, 11000),
},
{
type: "ERROR" as const,
name: "Max retries exceeded",
spanId,
metadata: { message: "Giving up after 3 retries", final_status: 429 },
timestamp: endDate(start, 15200),
},
],
decisions: [
{
type: "RETRY" as const,
reasoning: "Received 429 rate limit error, exponential backoff strategy selected",
chosen: { action: "retry", strategy: "exponential_backoff", max_retries: 3 },
alternatives: [{ action: "fail_immediately" }, { action: "switch_model" }],
durationMs: 20,
timestamp: endDate(start, 5100),
},
],
};
}
function createLongRunningWorkflowTrace(userId: string) {
const start = daysAgo(6);
const totalDuration = 34500;
const chainId = `demo-span-chain-${userId.slice(0, 8)}`;
const spanPrefix = `demo-span-wf-${userId.slice(0, 8)}`;
const stepNames = [
"data_ingestion",
"preprocessing",
"feature_extraction",
"model_inference",
"post_processing",
"validation",
"output_formatting",
];
const spans: DemoSpan[] = [
{
id: chainId,
name: "data-processing-pipeline",
type: "CHAIN",
status: "COMPLETED",
durationMs: totalDuration,
startedAt: start,
endedAt: endDate(start, totalDuration),
metadata: { pipeline: "batch-analysis", version: "1.4" },
},
];
let elapsed = 200;
for (let i = 0; i < stepNames.length; i++) {
const stepDuration = 2000 + Math.floor(Math.random() * 5000);
spans.push({
id: `${spanPrefix}-${i}`,
name: stepNames[i],
type: i === 3 ? "LLM_CALL" : "CUSTOM",
status: "COMPLETED",
durationMs: stepDuration,
startedAt: endDate(start, elapsed),
endedAt: endDate(start, elapsed + stepDuration),
metadata: { step: i + 1, total_steps: stepNames.length },
});
elapsed += stepDuration + 100;
}
return {
trace: {
name: "Long-Running Workflow",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["pipeline", "batch", "production"],
metadata: { pipeline: "batch-analysis", records_processed: 1250 },
totalCost: 0.042,
totalTokens: 4200,
totalDuration: totalDuration,
startedAt: start,
endedAt: endDate(start, totalDuration),
},
spans: spans.map((s) => ({
...s,
parentSpanId: s.id === chainId ? undefined : chainId,
})),
events: [] as EventCreate[],
decisions: [
{
type: "PLANNING" as const,
reasoning: "Large dataset detected, selecting batch processing strategy with parallel feature extraction",
chosen: { strategy: "batch_parallel", batch_size: 50 },
alternatives: [{ strategy: "sequential" }, { strategy: "streaming" }],
parentSpanId: chainId,
durationMs: 100,
timestamp: endDate(start, 100),
},
],
};
}
function createCodeAnalysisTrace(userId: string) {
const start = daysAgo(4);
const agentId = `demo-span-codeagent-${userId.slice(0, 8)}`;
const readId = `demo-span-read-${userId.slice(0, 8)}`;
const analyzeId = `demo-span-analyze-${userId.slice(0, 8)}`;
return {
trace: {
name: "Code Review Agent",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["code-review", "agent"],
metadata: { repo: "acme/backend", pr_number: 142 },
totalCost: 0.015,
totalTokens: 1450,
totalDuration: 6200,
startedAt: start,
endedAt: endDate(start, 6200),
},
spans: [
{
id: agentId,
name: "code-review-agent",
type: "AGENT" as const,
status: "COMPLETED" as const,
durationMs: 6200,
startedAt: start,
endedAt: endDate(start, 6200),
},
{
id: readId,
name: "read_diff",
type: "TOOL_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: agentId,
input: { pr_number: 142 },
output: { files_changed: 5, additions: 120, deletions: 30 },
durationMs: 800,
startedAt: endDate(start, 100),
endedAt: endDate(start, 900),
},
{
id: analyzeId,
name: "analyze_code",
type: "LLM_CALL" as const,
status: "COMPLETED" as const,
parentSpanId: agentId,
input: { diff: "...", instructions: "Review for bugs and style issues" },
output: { review: "Found 2 potential issues: 1) Missing null check on line 45, 2) Unused import" },
tokenCount: 1450,
costUsd: 0.015,
durationMs: 5100,
startedAt: endDate(start, 1000),
endedAt: endDate(start, 6100),
metadata: { model: "gpt-4o" },
},
],
events: [] as EventCreate[],
decisions: [
{
type: "TOOL_SELECTION" as const,
reasoning: "Need to read PR diff before analyzing code",
chosen: { tool: "read_diff", args: { pr_number: 142 } },
alternatives: [{ tool: "read_file" }, { tool: "list_files" }],
parentSpanId: agentId,
durationMs: 60,
timestamp: endDate(start, 50),
},
],
};
}
function createWebSearchTrace(userId: string) {
const start = daysAgo(0, -3600000);
const searchId = `demo-span-websearch-${userId.slice(0, 8)}`;
return {
trace: {
name: "Web Search Agent",
userId,
status: "COMPLETED" as const,
isDemo: true,
tags: ["search", "web"],
metadata: { query: "AgentLens observability" },
totalCost: 0.002,
totalTokens: 180,
totalDuration: 2800,
startedAt: start,
endedAt: endDate(start, 2800),
},
spans: [
{
id: searchId,
name: "web_search",
type: "TOOL_CALL" as const,
status: "COMPLETED" as const,
input: { query: "AgentLens observability platform" },
output: { results_count: 10, top_result: "https://agentlens.vectry.tech" },
durationMs: 2800,
startedAt: start,
endedAt: endDate(start, 2800),
},
],
events: [] as EventCreate[],
decisions: [] as DecisionCreate[],
};
}

599
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ioredis": "^5.9.2", "ioredis": "^5.9.2",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"next": "^15.1.0", "next": "^15.1.0",
@@ -1345,6 +1346,447 @@
"@prisma/debug": "6.19.2" "@prisma/debug": "6.19.2"
} }
}, },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1", "version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
@@ -2171,7 +2613,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@@ -2245,6 +2687,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/bcryptjs": { "node_modules/bcryptjs": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -2415,6 +2869,22 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/comma-separated-tokens": { "node_modules/comma-separated-tokens": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -2633,6 +3103,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devlop": { "node_modules/devlop": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -2811,6 +3287,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/giget": { "node_modules/giget": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
@@ -3861,6 +4346,75 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -4479,6 +5033,49 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",

View File

@@ -15,6 +15,7 @@ model User {
passwordHash String passwordHash String
name String? name String?
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
demoSeeded Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -124,7 +125,8 @@ model Trace {
tags String[] @default([]) tags String[] @default([])
metadata Json? metadata Json?
// Owner — nullable for backward compat with existing unowned traces isDemo Boolean @default(false)
userId String? userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)