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:
127
apps/web/src/app/api/decisions/route.ts
Normal file
127
apps/web/src/app/api/decisions/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
402
apps/web/src/app/dashboard/decisions/page.tsx
Normal file
402
apps/web/src/app/dashboard/decisions/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
Settings,
|
Settings,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -22,7 +21,7 @@ interface NavItem {
|
|||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ href: "/dashboard", label: "Traces", icon: Activity },
|
{ 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 },
|
{ href: "/dashboard/settings", label: "Settings", icon: Settings, comingSoon: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user