feat: Decisions page — aggregated view of all decision points across traces

Adds /dashboard/decisions page with colored type badges, search,
filters (by type), sort (newest/oldest/costliest), pagination,
and links to parent traces. New /api/decisions endpoint with
Prisma queries. Removes 'Soon' badge from sidebar nav.
This commit is contained in:
Vectry
2026-02-10 02:24:00 +00:00
parent 145b1669e7
commit 92b98f2d6f
3 changed files with 530 additions and 2 deletions

View File

@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@agentlens/database";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
const type = searchParams.get("type");
const search = searchParams.get("search");
const sort = searchParams.get("sort") ?? "newest";
// Validate pagination
if (isNaN(page) || page < 1) {
return NextResponse.json(
{ error: "Invalid page parameter. Must be a positive integer." },
{ status: 400 }
);
}
if (isNaN(limit) || limit < 1 || limit > 100) {
return NextResponse.json(
{ error: "Invalid limit parameter. Must be between 1 and 100." },
{ status: 400 }
);
}
// Validate type
const validTypes = [
"TOOL_SELECTION",
"ROUTING",
"RETRY",
"ESCALATION",
"MEMORY_RETRIEVAL",
"PLANNING",
"CUSTOM",
];
if (type && !validTypes.includes(type)) {
return NextResponse.json(
{ error: `Invalid type. Must be one of: ${validTypes.join(", ")}` },
{ status: 400 }
);
}
// Validate sort
const validSorts = ["newest", "oldest", "costliest"];
if (!validSorts.includes(sort)) {
return NextResponse.json(
{ error: `Invalid sort. Must be one of: ${validSorts.join(", ")}` },
{ status: 400 }
);
}
// Build where clause
const where: Prisma.DecisionPointWhereInput = {};
if (type) {
where.type = type as Prisma.EnumDecisionTypeFilter["equals"];
}
if (search) {
where.reasoning = {
contains: search,
mode: "insensitive",
};
}
// Build order by
let orderBy: Prisma.DecisionPointOrderByWithRelationInput;
switch (sort) {
case "oldest":
orderBy = { timestamp: "asc" };
break;
case "costliest":
orderBy = { costUsd: "desc" };
break;
case "newest":
default:
orderBy = { timestamp: "desc" };
break;
}
// Count total
const total = await prisma.decisionPoint.count({ where });
// Pagination
const skip = (page - 1) * limit;
const totalPages = Math.ceil(total / limit);
// Fetch decisions with parent trace and span
const decisions = await prisma.decisionPoint.findMany({
where,
include: {
trace: {
select: {
id: true,
name: true,
},
},
span: {
select: {
id: true,
name: true,
},
},
},
orderBy,
skip,
take: limit,
});
return NextResponse.json(
{
decisions,
total,
page,
limit,
totalPages,
},
{ status: 200 }
);
} catch (error) {
console.error("Error listing decisions:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,402 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import {
Search,
ChevronLeft,
ChevronRight,
GitBranch,
DollarSign,
Clock,
ArrowRight,
Layers,
} from "lucide-react";
import { cn, formatRelativeTime } from "@/lib/utils";
type DecisionType =
| "TOOL_SELECTION"
| "ROUTING"
| "RETRY"
| "ESCALATION"
| "MEMORY_RETRIEVAL"
| "PLANNING"
| "CUSTOM";
type SortOption = "newest" | "oldest" | "costliest";
interface Decision {
id: string;
traceId: string;
type: DecisionType;
reasoning: string | null;
chosen: Record<string, unknown>;
alternatives: Record<string, unknown>[];
contextSnapshot: Record<string, unknown> | null;
durationMs: number | null;
costUsd: number | null;
parentSpanId: string | null;
timestamp: string;
trace: {
id: string;
name: string;
};
span: {
id: string;
name: string;
} | null;
}
interface DecisionsResponse {
decisions: Decision[];
total: number;
page: number;
limit: number;
totalPages: number;
}
const typeConfig: Record<
DecisionType,
{ label: string; bg: string; text: string }
> = {
TOOL_SELECTION: {
label: "Tool Selection",
bg: "bg-blue-500/10",
text: "text-blue-400",
},
ROUTING: {
label: "Routing",
bg: "bg-purple-500/10",
text: "text-purple-400",
},
RETRY: {
label: "Retry",
bg: "bg-amber-500/10",
text: "text-amber-400",
},
ESCALATION: {
label: "Escalation",
bg: "bg-red-500/10",
text: "text-red-400",
},
MEMORY_RETRIEVAL: {
label: "Memory Retrieval",
bg: "bg-cyan-500/10",
text: "text-cyan-400",
},
PLANNING: {
label: "Planning",
bg: "bg-emerald-500/10",
text: "text-emerald-400",
},
CUSTOM: {
label: "Custom",
bg: "bg-neutral-500/10",
text: "text-neutral-400",
},
};
const sortOptions: { value: SortOption; label: string }[] = [
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
{ value: "costliest", label: "Most Expensive" },
];
const allTypes: DecisionType[] = [
"TOOL_SELECTION",
"ROUTING",
"RETRY",
"ESCALATION",
"MEMORY_RETRIEVAL",
"PLANNING",
"CUSTOM",
];
function extractChosenName(chosen: Record<string, unknown>): string {
if (chosen && typeof chosen === "object") {
if (typeof chosen.name === "string") return chosen.name;
if (typeof chosen.action === "string") return chosen.action;
if (typeof chosen.tool === "string") return chosen.tool;
// Fallback: first string value
for (const val of Object.values(chosen)) {
if (typeof val === "string") return val;
}
}
return "Unknown";
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max) + "...";
}
export default function DecisionsPage() {
const [decisions, setDecisions] = useState<Decision[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<DecisionType | "ALL">("ALL");
const [sortFilter, setSortFilter] = useState<SortOption>("newest");
const fetchDecisions = useCallback(async () => {
setIsLoading(true);
try {
const params = new URLSearchParams();
params.set("page", currentPage.toString());
params.set("limit", "30");
if (typeFilter !== "ALL") {
params.set("type", typeFilter);
}
if (searchQuery) {
params.set("search", searchQuery);
}
if (sortFilter !== "newest") {
params.set("sort", sortFilter);
}
const res = await fetch(`/api/decisions?${params.toString()}`, {
cache: "no-store",
});
if (!res.ok) {
throw new Error(`Failed to fetch decisions: ${res.status}`);
}
const data: DecisionsResponse = await res.json();
setDecisions(data.decisions);
setTotal(data.total);
setTotalPages(data.totalPages);
} catch (error) {
console.error("Error fetching decisions:", error);
} finally {
setIsLoading(false);
}
}, [currentPage, typeFilter, searchQuery, sortFilter]);
useEffect(() => {
fetchDecisions();
}, [fetchDecisions]);
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, typeFilter, sortFilter]);
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-neutral-100">Decisions</h1>
<p className="text-neutral-400 mt-1">
{total} decision point{total !== 1 ? "s" : ""} across all traces
</p>
</div>
{/* Search and Filters */}
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" />
<input
type="text"
placeholder="Search by reasoning..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-neutral-900 border border-neutral-800 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
/>
</div>
{/* Type Filter */}
<div className="flex items-center gap-3">
<select
value={typeFilter}
onChange={(e) =>
setTypeFilter(e.target.value as DecisionType | "ALL")
}
className="bg-neutral-900 border border-neutral-800 rounded-lg px-3 py-3 text-sm text-neutral-100 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
>
<option value="ALL">All Types</option>
{allTypes.map((t) => (
<option key={t} value={t}>
{typeConfig[t].label}
</option>
))}
</select>
{/* Sort */}
<select
value={sortFilter}
onChange={(e) => setSortFilter(e.target.value as SortOption)}
className="bg-neutral-900 border border-neutral-800 rounded-lg px-3 py-3 text-sm text-neutral-100 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Loading State */}
{isLoading && decisions.length === 0 && (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="p-5 bg-neutral-900 border border-neutral-800 rounded-xl animate-pulse"
>
<div className="flex items-center gap-4">
<div className="h-6 w-24 bg-neutral-800 rounded-md" />
<div className="flex-1">
<div className="h-5 w-48 bg-neutral-800 rounded-md mb-2" />
<div className="h-4 w-96 bg-neutral-800/60 rounded-md" />
</div>
<div className="h-4 w-16 bg-neutral-800 rounded-md" />
</div>
</div>
))}
</div>
)}
{/* Empty State */}
{!isLoading && decisions.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl bg-neutral-900 border border-neutral-800 flex items-center justify-center mb-6">
<GitBranch className="w-10 h-10 text-neutral-600" />
</div>
<h2 className="text-xl font-semibold text-neutral-100 mb-2">
No decision points yet
</h2>
<p className="text-neutral-400 max-w-md">
Decision points will appear here once your agents start making
decisions. Send traces with decision data to see them aggregated.
</p>
</div>
)}
{/* Decision List */}
{decisions.length > 0 && (
<div className="space-y-3">
{decisions.map((decision) => (
<DecisionCard key={decision.id} decision={decision} />
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-6 border-t border-neutral-800">
<p className="text-sm text-neutral-500">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<button
disabled={currentPage <= 1}
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
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" />
</button>
<button
disabled={currentPage >= totalPages}
onClick={() =>
handlePageChange(Math.min(totalPages, currentPage + 1))
}
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" />
</button>
</div>
</div>
)}
</div>
);
}
function DecisionCard({ decision }: { decision: Decision }) {
const config = typeConfig[decision.type] || typeConfig.CUSTOM;
const chosenName = extractChosenName(
decision.chosen as Record<string, unknown>
);
return (
<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">
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
{/* Left: Type badge + chosen + reasoning */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<span
className={cn(
"inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium uppercase tracking-wide",
config.bg,
config.text
)}
>
{config.label}
</span>
<h3 className="font-semibold text-neutral-100 truncate">
{chosenName}
</h3>
</div>
{decision.reasoning && (
<p className="text-sm text-neutral-400 leading-relaxed">
{truncate(decision.reasoning, 120)}
</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-500">
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{formatRelativeTime(decision.timestamp)}
</span>
{decision.alternatives.length > 0 && (
<span className="flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5" />
{decision.alternatives.length} alternative
{decision.alternatives.length !== 1 ? "s" : ""}
</span>
)}
{decision.costUsd !== null && decision.costUsd > 0 && (
<span className="flex items-center gap-1.5">
<DollarSign className="w-3.5 h-3.5" />$
{decision.costUsd.toFixed(4)}
</span>
)}
{decision.span && (
<span className="flex items-center gap-1.5 text-neutral-500">
<GitBranch className="w-3.5 h-3.5" />
{decision.span.name}
</span>
)}
</div>
</div>
{/* Right: Trace link */}
<div className="flex items-center gap-3 shrink-0">
<Link
href={`/dashboard/traces/${decision.trace.id}`}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-neutral-800/50 border border-neutral-700/50 text-sm text-neutral-300 hover:text-emerald-400 hover:border-emerald-500/30 transition-all"
>
<span className="truncate max-w-[180px]">
{decision.trace.name}
</span>
<ArrowRight className="w-4 h-4 shrink-0" />
</Link>
</div>
</div>
</div>
);
}

View File

@@ -8,7 +8,6 @@ import {
GitBranch,
Settings,
Menu,
X,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -22,7 +21,7 @@ interface NavItem {
const navItems: NavItem[] = [
{ href: "/dashboard", label: "Traces", icon: Activity },
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch, comingSoon: true },
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch },
{ href: "/dashboard/settings", label: "Settings", icon: Settings, comingSoon: true },
];