From 92b98f2d6f5a0f08efe159a19ef57832a7ce8592 Mon Sep 17 00:00:00 2001 From: Vectry Date: Tue, 10 Feb 2026 02:24:00 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Decisions=20page=20=E2=80=94=20aggregat?= =?UTF-8?q?ed=20view=20of=20all=20decision=20points=20across=20traces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/src/app/api/decisions/route.ts | 127 ++++++ apps/web/src/app/dashboard/decisions/page.tsx | 402 ++++++++++++++++++ apps/web/src/app/dashboard/layout.tsx | 3 +- 3 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/api/decisions/route.ts create mode 100644 apps/web/src/app/dashboard/decisions/page.tsx diff --git a/apps/web/src/app/api/decisions/route.ts b/apps/web/src/app/api/decisions/route.ts new file mode 100644 index 0000000..dd77e88 --- /dev/null +++ b/apps/web/src/app/api/decisions/route.ts @@ -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 } + ); + } +} diff --git a/apps/web/src/app/dashboard/decisions/page.tsx b/apps/web/src/app/dashboard/decisions/page.tsx new file mode 100644 index 0000000..8793cd9 --- /dev/null +++ b/apps/web/src/app/dashboard/decisions/page.tsx @@ -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; + alternatives: Record[]; + contextSnapshot: Record | 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 { + 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([]); + 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("ALL"); + const [sortFilter, setSortFilter] = useState("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 ( +
+ {/* Header */} +
+

Decisions

+

+ {total} decision point{total !== 1 ? "s" : ""} across all traces +

+
+ + {/* Search and Filters */} +
+
+ {/* Search */} +
+ + 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" + /> +
+ + {/* Type Filter */} +
+ + + {/* Sort */} + +
+
+
+ + {/* Loading State */} + {isLoading && decisions.length === 0 && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Empty State */} + {!isLoading && decisions.length === 0 && ( +
+
+ +
+

+ No decision points yet +

+

+ Decision points will appear here once your agents start making + decisions. Send traces with decision data to see them aggregated. +

+
+ )} + + {/* Decision List */} + {decisions.length > 0 && ( +
+ {decisions.map((decision) => ( + + ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {currentPage} of {totalPages} +

+
+ + +
+
+ )} +
+ ); +} + +function DecisionCard({ decision }: { decision: Decision }) { + const config = typeConfig[decision.type] || typeConfig.CUSTOM; + const chosenName = extractChosenName( + decision.chosen as Record + ); + + return ( +
+
+ {/* Left: Type badge + chosen + reasoning */} +
+
+ + {config.label} + +

+ {chosenName} +

+
+ + {decision.reasoning && ( +

+ {truncate(decision.reasoning, 120)} +

+ )} + +
+ + + {formatRelativeTime(decision.timestamp)} + + {decision.alternatives.length > 0 && ( + + + {decision.alternatives.length} alternative + {decision.alternatives.length !== 1 ? "s" : ""} + + )} + {decision.costUsd !== null && decision.costUsd > 0 && ( + + $ + {decision.costUsd.toFixed(4)} + + )} + {decision.span && ( + + + {decision.span.name} + + )} +
+
+ + {/* Right: Trace link */} +
+ 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" + > + + {decision.trace.name} + + + +
+
+
+ ); +} diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx index b71924d..4460adb 100644 --- a/apps/web/src/app/dashboard/layout.tsx +++ b/apps/web/src/app/dashboard/layout.tsx @@ -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 }, ];