first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import { Card } from "@/components/ui/card";
import { ImageMessageData } from "@/lib/types/logs";
import React from "react";
interface ImageMessageProps {
image: ImageMessageData | null;
}
export const ImageMessage: React.FC<ImageMessageProps> = ({ image }) => {
// No usable image data
if (!image || (!image.url && !image.b64_json)) {
return null;
}
// Convert output_format to MIME type for data URLs
const getMimeType = (format?: string): string => {
switch (format?.toLowerCase()) {
case "png":
return "image/png";
case "jpeg":
case "jpg":
return "image/jpeg";
case "webp":
return "image/webp";
default:
// Default to PNG for backward compatibility
return "image/png";
}
};
const dataUrl = image.url ? image.url : `data:${getMimeType(image.output_format)};base64,${image.b64_json}`;
return (
<div className="my-4">
<Card className="p-0">
<div className="border-border overflow-auto border">
<img src={dataUrl} alt={image.prompt || `image-${image.index ?? 0}`} className="h-auto w-auto" loading="lazy" />
</div>
</Card>
</div>
);
};

View File

@@ -0,0 +1,728 @@
import { useGetDevGoroutinesQuery, useGetDevPprofQuery } from "@/lib/store";
import type { GoroutineGroup } from "@/lib/store/apis/devApi";
import { isDevelopmentMode } from "@/lib/utils/port";
import {
Activity,
AlertTriangle,
ChevronDown,
ChevronRight,
ChevronUp,
Cpu,
EyeOff,
HardDrive,
RotateCcw,
TrendingUp,
X,
} from "lucide-react";
import { formatBytes } from "@/lib/utils/strings";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
// Format nanoseconds to human-readable string
function formatNs(ns: number): string {
if (ns < 1000) return `${ns}ns`;
if (ns < 1000000) return `${(ns / 1000).toFixed(1)}µs`;
if (ns < 1000000000) return `${(ns / 1000000).toFixed(1)}ms`;
return `${(ns / 1000000000).toFixed(2)}s`;
}
// Format timestamp to HH:MM:SS
function formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// Truncate function name for display
function truncateFunction(fn: string): string {
const parts = fn.split("/");
const last = parts[parts.length - 1];
if (last.length > 40) {
return "..." + last.slice(-37);
}
return last;
}
// Get category badge color
function getCategoryColor(category: string): string {
switch (category) {
case "per-request":
return "text-amber-400 bg-amber-400/10";
case "background":
return "text-blue-400 bg-blue-400/10";
default:
return "text-zinc-400 bg-zinc-400/10";
}
}
// Extract file path from stack (first line containing .go:)
function getStackFilePath(stack: string[]): string {
for (const line of stack) {
// Match file path like "/path/to/file.go:123" and extract just the path
const match = line.match(/^\s*([^\s]+\.go):\d+/);
if (match) {
return match[1];
}
}
return "";
}
// Generate a stable ID for a goroutine group
function getGoroutineId(g: GoroutineGroup): string {
return `${g.top_func}::${g.state}::${g.count}::${g.wait_minutes ?? 0}`;
}
// localStorage key for skipped goroutine file paths
const SKIPPED_GOROUTINE_FILES_KEY = "devProfiler.skippedGoroutineFiles";
const PROFILER_VISIBLE_KEY = "devProfiler.isVisible";
const PROFILER_EXPANDED_KEY = "devProfiler.isExpanded";
// Load skipped goroutine file paths from localStorage
function loadSkippedGoroutineFiles(): Set<string> {
if (typeof window === "undefined") return new Set();
try {
const stored = localStorage.getItem(SKIPPED_GOROUTINE_FILES_KEY);
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
}
// Save skipped goroutine file paths to localStorage
function saveSkippedGoroutineFiles(skipped: Set<string>): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(SKIPPED_GOROUTINE_FILES_KEY, JSON.stringify([...skipped]));
} catch {
// Ignore storage errors
}
}
function loadBooleanFromStorage(key: string, defaultValue: boolean): boolean {
if (typeof window === "undefined") return defaultValue;
try {
const stored = localStorage.getItem(key);
if (stored === null) return defaultValue;
if (stored === "true") return true;
if (stored === "false") return false;
return defaultValue;
} catch {
return defaultValue;
}
}
function saveBooleanToStorage(key: string, value: boolean): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(key, value ? "true" : "false");
} catch {
// Ignore storage errors
}
}
// Goroutine Health Section subcomponent
interface GoroutineHealthProps {
goroutineData:
| {
summary: {
background: number;
per_request: number;
long_waiting: number;
potentially_stuck: number;
};
total_goroutines: number;
}
| undefined;
goroutineHealth: "healthy" | "warning" | "critical";
goroutineTrend: {
isGrowing: boolean;
growthPercent: number;
avg: number;
} | null;
problemGoroutines: GoroutineGroup[];
expandedGoroutines: Set<string>;
toggleGoroutineExpand: (id: string) => void;
skippedGoroutines: Set<string>;
onSkipGoroutine: (topFunc: string) => void;
onClearSkipped: () => void;
}
function GoroutineHealthSection({
goroutineData,
goroutineHealth,
goroutineTrend,
problemGoroutines,
expandedGoroutines,
toggleGoroutineExpand,
skippedGoroutines,
onSkipGoroutine,
onClearSkipped,
}: GoroutineHealthProps): React.ReactNode {
if (!goroutineData) return null;
const { summary, total_goroutines } = goroutineData;
return (
<div className="p-3">
{/* Header with health status */}
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="h-3 w-3 text-emerald-400" />
<span className="text-zinc-400">Goroutine Health</span>
</div>
<div className="flex items-center gap-2">
{goroutineTrend?.isGrowing && (
<span className="flex items-center gap-1 text-amber-400" title="Goroutine count growing">
<TrendingUp className="h-3 w-3" />
<span className="text-[10px]">+{goroutineTrend.growthPercent.toFixed(0)}%</span>
</span>
)}
{goroutineHealth === "critical" && (
<span className="flex items-center gap-1 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400">
<AlertTriangle className="h-3 w-3" />
Stuck
</span>
)}
{goroutineHealth === "warning" && (
<span className="flex items-center gap-1 rounded bg-amber-500/20 px-1.5 py-0.5 text-[10px] text-amber-400">
<AlertTriangle className="h-3 w-3" />
Long Wait
</span>
)}
{goroutineHealth === "healthy" && (
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] text-emerald-400">Healthy</span>
)}
</div>
</div>
{/* Summary stats */}
<div className="mb-2 grid grid-cols-4 gap-2 rounded bg-zinc-800/50 p-2">
<div className="flex flex-col items-center">
<span className="text-[10px] text-zinc-500">Total</span>
<span className="font-semibold text-emerald-400">{total_goroutines}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-[10px] text-zinc-500">Background</span>
<span className="font-semibold text-blue-400">{summary.background}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-[10px] text-zinc-500">Per-Request</span>
<span className="font-semibold text-amber-400">{summary.per_request}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-[10px] text-zinc-500">Stuck</span>
<span className={`font-semibold ${summary.potentially_stuck > 0 ? "text-red-400" : "text-zinc-500"}`}>
{summary.potentially_stuck}
</span>
</div>
</div>
{/* Problem goroutines list */}
{(problemGoroutines.length > 0 || skippedGoroutines.size > 0) && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-500">Potential Leaks</span>
{skippedGoroutines.size > 0 && (
<button
onClick={onClearSkipped}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
title="Clear all hidden goroutines"
>
<RotateCcw className="h-2.5 w-2.5" />
{skippedGoroutines.size} hidden
</button>
)}
</div>
{problemGoroutines.map((g) => {
const gid = getGoroutineId(g);
return (
<div key={gid} className="group relative rounded bg-zinc-800">
<div
role="button"
tabIndex={0}
onClick={() => toggleGoroutineExpand(gid)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleGoroutineExpand(gid);
}
}}
className="flex w-full cursor-pointer flex-col gap-1 px-2 py-1.5 pr-8 text-left hover:bg-zinc-700/50"
>
<div className="flex w-full items-center gap-2">
{expandedGoroutines.has(gid) ? (
<ChevronDown className="h-3 w-3 shrink-0 text-zinc-500" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-zinc-500" />
)}
<span className="min-w-0 flex-1 break-all text-zinc-300" title={g.top_func}>
{truncateFunction(g.top_func)}
</span>
</div>
<div className="flex items-center gap-2 pl-5 text-[10px]">
<span className={`rounded px-1 py-0.5 ${getCategoryColor(g.category)}`}>{g.category}</span>
<span className="text-zinc-500">{g.count}x</span>
{g.wait_minutes != null && <span className="text-amber-400">{g.wait_minutes}m waiting</span>}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
const filePath = getStackFilePath(g.stack);
if (filePath) onSkipGoroutine(filePath);
}}
className="absolute top-1.5 right-1 shrink-0 rounded p-1 text-zinc-500 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-zinc-600 hover:text-zinc-300"
title="Hide goroutines from this file"
>
<EyeOff className="h-3 w-3" />
</button>
{expandedGoroutines.has(gid) && (
<div className="border-t border-zinc-700 bg-zinc-900/50 px-2 py-1.5">
<div className="mb-1 text-[10px] text-zinc-500">
State: <span className="text-zinc-400">{g.state}</span>
{g.wait_reason && (
<span className="ml-2">
Wait: <span className="text-amber-400">{g.wait_reason}</span>
</span>
)}
</div>
<div className="max-h-32 overflow-x-hidden overflow-y-auto">
{g.stack.slice(0, 10).map((line, j) => (
<div key={j} className="text-[9px] break-all text-zinc-500">
{line}
</div>
))}
{g.stack.length > 10 && <div className="text-[9px] text-zinc-600">... {g.stack.length - 10} more frames</div>}
</div>
</div>
)}
</div>
);
})}
{problemGoroutines.length === 0 && skippedGoroutines.size > 0 && (
<div className="rounded bg-zinc-800/30 py-2 text-center text-[10px] text-zinc-500">All potential leaks hidden</div>
)}
{problemGoroutines.length === 0 &&
skippedGoroutines.size === 0 &&
(summary.long_waiting > 0 || summary.potentially_stuck > 0) && (
<div className="rounded bg-zinc-800/30 px-2 py-2 text-center text-[10px] text-zinc-500">
{summary.long_waiting > 0 && summary.potentially_stuck > 0
? `${summary.long_waiting} long-waiting and ${summary.potentially_stuck} stuck goroutines (background workers filtered)`
: summary.long_waiting > 0
? `${summary.long_waiting} long-waiting goroutines (background workers filtered)`
: `${summary.potentially_stuck} stuck goroutines (background workers filtered)`}
</div>
)}
</div>
)}
{/* No problems message */}
{problemGoroutines.length === 0 && summary.long_waiting === 0 && summary.potentially_stuck === 0 && (
<div className="rounded bg-zinc-800/30 py-2 text-center text-[10px] text-zinc-500">No goroutine leaks detected</div>
)}
</div>
);
}
export function DevProfiler(): React.ReactNode {
const [isVisible, setIsVisible] = useState<boolean>(() => loadBooleanFromStorage(PROFILER_VISIBLE_KEY, true));
const [isExpanded, setIsExpanded] = useState<boolean>(() => loadBooleanFromStorage(PROFILER_EXPANDED_KEY, true));
const [isDismissed, setIsDismissed] = useState(false);
const [expandedGoroutines, setExpandedGoroutines] = useState<Set<string>>(new Set());
const [skippedGoroutines, setSkippedGoroutines] = useState<Set<string>>(() => loadSkippedGoroutineFiles());
// Sync skipped goroutines to localStorage
useEffect(() => {
saveSkippedGoroutineFiles(skippedGoroutines);
}, [skippedGoroutines]);
useEffect(() => {
saveBooleanToStorage(PROFILER_VISIBLE_KEY, isVisible);
}, [isVisible]);
useEffect(() => {
saveBooleanToStorage(PROFILER_EXPANDED_KEY, isExpanded);
}, [isExpanded]);
// Only fetch in development mode and when not dismissed
const shouldFetch = isDevelopmentMode() && !isDismissed;
const { data, isLoading, error } = useGetDevPprofQuery(undefined, {
pollingInterval: shouldFetch ? 10000 : 0, // Poll every 10 seconds
skip: !shouldFetch,
});
const { data: goroutineData } = useGetDevGoroutinesQuery(undefined, {
pollingInterval: shouldFetch ? 10000 : 0, // Poll every 10 seconds
skip: !shouldFetch,
});
// Memoize chart data transformation
const memoryChartData = useMemo(() => {
if (!data?.history) return [];
return data.history.map((point) => ({
time: formatTime(point.timestamp),
alloc: point.alloc / (1024 * 1024), // Convert to MB
heapInuse: point.heap_inuse / (1024 * 1024),
}));
}, [data?.history]);
const cpuChartData = useMemo(() => {
if (!data?.history) return [];
return data.history.map((point) => ({
time: formatTime(point.timestamp),
cpuPercent: point.cpu_percent,
goroutines: point.goroutines,
}));
}, [data?.history]);
// Detect goroutine count trend (growing = potential leak)
const goroutineTrend = useMemo(() => {
if (!data?.history || data.history.length < 5 || !data?.runtime) return null;
const recent = data.history.slice(-5);
const avg = recent.reduce((sum, p) => sum + p.goroutines, 0) / recent.length;
const current = data.runtime.num_goroutine;
const isGrowing = current > avg * 1.1; // 10% above average
const growthPercent = avg > 0 ? ((current - avg) / avg) * 100 : 0;
return { isGrowing, growthPercent, avg };
}, [data?.history, data?.runtime?.num_goroutine]);
// Filter problem goroutines (stuck or long-waiting, excluding expected background workers and skipped)
const problemGoroutines = useMemo(() => {
if (!goroutineData?.groups) return [];
return goroutineData.groups
.filter((g) => {
if (!g.wait_minutes || g.wait_minutes < 1) return false;
if (g.category === "background") return false;
const filePath = getStackFilePath(g.stack);
if (filePath && skippedGoroutines.has(filePath)) return false;
return true;
})
.slice(0, 5);
}, [goroutineData?.groups, skippedGoroutines]);
// Get goroutine health status
const goroutineHealth = useMemo(() => {
if (!goroutineData?.summary) return "healthy";
const { potentially_stuck, long_waiting } = goroutineData.summary;
if (potentially_stuck > 0) return "critical";
if (long_waiting > 0) return "warning";
return "healthy";
}, [goroutineData?.summary]);
const handleDismiss = useCallback(() => {
setIsDismissed(true);
saveBooleanToStorage(PROFILER_VISIBLE_KEY, false);
}, []);
const toggleGoroutineExpand = useCallback((id: string) => {
setExpandedGoroutines((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleSkipGoroutine = useCallback((filePath: string) => {
setSkippedGoroutines((prev) => {
const next = new Set(prev);
next.add(filePath);
return next;
});
}, []);
const handleClearSkipped = useCallback(() => {
setSkippedGoroutines(new Set());
}, []);
const handleToggleExpand = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const handleToggleVisible = useCallback(() => {
setIsVisible((prev) => !prev);
}, []);
// Don't render in production mode or if dismissed
if (!isDevelopmentMode() || isDismissed) {
return null;
}
// Minimized state - just show a small button
if (!isVisible) {
return (
<button
onClick={handleToggleVisible}
className="fixed right-4 bottom-4 z-50 flex h-10 w-10 items-center justify-center rounded-full bg-zinc-900 text-white shadow-lg transition-all hover:bg-zinc-800"
title="Show Dev Profiler"
>
<Activity className="h-5 w-5" />
</button>
);
}
return (
<div className="fixed right-4 bottom-4 z-50 w-[420px] overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 font-mono text-xs text-zinc-100 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-700 bg-zinc-800 px-3 py-2">
<div className="flex items-center gap-2">
<span className="font-semibold text-emerald-400">Dev Profiler</span>
{isLoading && <span className="ml-2 h-2 w-2 animate-pulse rounded-full bg-amber-400" />}
</div>
<div className="flex items-center gap-1">
<button
onClick={handleToggleExpand}
className="rounded p-1 transition-colors hover:bg-zinc-700"
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
<button onClick={handleToggleVisible} className="rounded p-1 transition-colors hover:bg-zinc-700" title="Minimize">
<ChevronDown className="h-4 w-4" />
</button>
<button onClick={handleDismiss} className="rounded p-1 transition-colors hover:bg-zinc-700" title="Dismiss">
<X className="h-4 w-4" />
</button>
</div>
</div>
{Boolean(error) && <div className="border-b border-zinc-700 bg-red-900/30 px-3 py-2 text-red-300">Failed to load profiling data</div>}
{isExpanded && data && (
<div className="custom-scrollbar max-h-[70vh] overflow-x-hidden overflow-y-auto">
{/* Current Stats */}
<div className="grid grid-cols-3 gap-2 border-b border-zinc-700 p-3">
<div className="flex flex-col">
<span className="text-zinc-500">CPU Usage</span>
<span className="font-semibold text-orange-400">{data.cpu.usage_percent.toFixed(1)}%</span>
</div>
<div className="flex flex-col">
<span className="text-zinc-500">Heap Alloc</span>
<span className="font-semibold text-cyan-400">{formatBytes(data.memory.alloc)}</span>
</div>
<div className="flex flex-col">
<span className="text-zinc-500">Heap In-Use</span>
<span className="font-semibold text-blue-400">{formatBytes(data.memory.heap_inuse)}</span>
</div>
<div className="flex flex-col">
<span className="text-zinc-500">System</span>
<span className="font-semibold text-purple-400">{formatBytes(data.memory.sys)}</span>
</div>
<div className="flex flex-col">
<span className="text-zinc-500">Goroutines</span>
<span className="font-semibold text-emerald-400">{data.runtime.num_goroutine}</span>
</div>
<div className="flex flex-col">
<span className="text-zinc-500">GC Pause</span>
<span className="font-semibold text-amber-400">{formatNs(data.runtime.gc_pause_ns)}</span>
</div>
</div>
{/* CPU Chart */}
<div className="border-b border-zinc-700 p-3">
<div className="mb-2 flex items-center gap-2">
<Cpu className="h-3 w-3 text-orange-400" />
<span className="text-zinc-400">CPU Usage (last 5 min)</span>
</div>
<div className="h-24">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={cpuChartData}>
<defs>
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f97316" stopOpacity={0.3} />
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
</linearGradient>
<linearGradient id="goroutineGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#3f3f46" />
<XAxis dataKey="time" tick={{ fill: "#71717a", fontSize: 9 }} tickLine={false} axisLine={false} />
<YAxis
yAxisId="left"
tick={{ fill: "#71717a", fontSize: 9 }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${Number(v).toFixed(0)}%`}
width={35}
domain={[0, "auto"]}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: "#71717a", fontSize: 9 }}
tickLine={false}
axisLine={false}
width={30}
/>
<Tooltip
contentStyle={{
backgroundColor: "#18181b",
border: "1px solid #3f3f46",
borderRadius: "6px",
fontSize: "10px",
}}
labelStyle={{ color: "#a1a1aa" }}
/>
<Area
type="monotone"
dataKey="cpuPercent"
stroke="#f97316"
strokeWidth={1.5}
fill="url(#cpuGradient)"
yAxisId="left"
name="CPU %"
/>
<Area
type="monotone"
dataKey="goroutines"
stroke="#34d399"
strokeWidth={1.5}
fill="url(#goroutineGradient)"
yAxisId="right"
name="Goroutines"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mt-1 flex gap-4 text-[10px]">
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-orange-500" />
CPU %
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
Goroutines
</span>
</div>
</div>
{/* Memory Chart */}
<div className="border-b border-zinc-700 p-3">
<div className="mb-2 flex items-center gap-2">
<HardDrive className="h-3 w-3 text-cyan-400" />
<span className="text-zinc-400">Memory (last 5 min)</span>
</div>
<div className="h-24">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={memoryChartData}>
<defs>
<linearGradient id="allocGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0} />
</linearGradient>
<linearGradient id="heapGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#3f3f46" />
<XAxis dataKey="time" tick={{ fill: "#71717a", fontSize: 9 }} tickLine={false} axisLine={false} />
<YAxis
tick={{ fill: "#71717a", fontSize: 9 }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${Number(v).toFixed(0)}MB`}
width={45}
/>
<Tooltip
contentStyle={{
backgroundColor: "#18181b",
border: "1px solid #3f3f46",
borderRadius: "6px",
fontSize: "10px",
}}
labelStyle={{ color: "#a1a1aa" }}
/>
<Area type="monotone" dataKey="alloc" stroke="#22d3ee" strokeWidth={1.5} fill="url(#allocGradient)" name="Alloc" />
<Area
type="monotone"
dataKey="heapInuse"
stroke="#3b82f6"
strokeWidth={1.5}
fill="url(#heapGradient)"
name="Heap In-Use"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mt-1 flex gap-4 text-[10px]">
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-cyan-400" />
Alloc
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-blue-500" />
Heap In-Use
</span>
</div>
</div>
{/* Top Allocations */}
<div className="border-b border-zinc-700 p-3">
<div className="mb-2 flex items-center gap-2">
<HardDrive className="h-3 w-3 text-rose-400" />
<span className="text-zinc-400">Top Allocations</span>
</div>
<div className="space-y-1">
{(data.top_allocations ?? []).map((alloc, i) => (
<div key={i} className="flex items-center justify-between rounded bg-zinc-800 px-2 py-1">
<div className="flex flex-col overflow-hidden">
<span className="truncate text-zinc-300" title={alloc.function}>
{truncateFunction(alloc.function)}
</span>
<span className="text-[10px] text-zinc-500">
{alloc.file}:{alloc.line}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-rose-400">{formatBytes(alloc.bytes)}</span>
<span className="text-[10px] text-zinc-500">{alloc.count.toLocaleString()} allocs</span>
</div>
</div>
))}
</div>
</div>
{/* Goroutine Health */}
<GoroutineHealthSection
goroutineData={goroutineData}
goroutineHealth={goroutineHealth}
goroutineTrend={goroutineTrend}
problemGoroutines={problemGoroutines}
expandedGoroutines={expandedGoroutines}
toggleGoroutineExpand={toggleGoroutineExpand}
skippedGoroutines={skippedGoroutines}
onSkipGoroutine={handleSkipGoroutine}
onClearSkipped={handleClearSkipped}
/>
{/* Footer with info */}
<div className="border-t border-zinc-700 bg-zinc-800 px-3 py-2 text-[10px] text-zinc-500">
CPUs: {data.runtime.num_cpu} | GOMAXPROCS: {data.runtime.gomaxprocs} | GC: {data.runtime.num_gc} | Objects:{" "}
{data.memory.heap_objects.toLocaleString()}
</div>
</div>
)}
{/* Collapsed state */}
{!isExpanded && data && (
<div className="flex items-center justify-between bg-zinc-800/50 px-3 py-2">
<span className="text-orange-400">CPU: {data.cpu.usage_percent.toFixed(1)}%</span>
<span className="text-zinc-400">Heap: {formatBytes(data.memory.heap_inuse)}</span>
<span className="text-zinc-400">Goroutines: {data.runtime.num_goroutine}</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,795 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scrollArea";
import { Skeleton } from "@/components/ui/skeleton";
import { RequestTypeLabels, RequestTypes, RoutingEngineUsedLabels, Statuses } from "@/lib/constants/logs";
import { useGetAvailableFilterDataQuery, useGetProvidersQuery } from "@/lib/store";
import type { LogFilters } from "@/lib/types/logs";
import { cn } from "@/lib/utils";
import { ChevronDown, PanelLeftClose, PanelLeftOpen, RotateCcw } from "lucide-react";
import { Ref, useCallback, useEffect, useMemo, useRef, useState } from "react";
const COLLAPSE_STORAGE_KEY = "logs-filter-sidebar-collapsed";
// ---------------------------------------------------------------------------
// LogsSidebar orchestrator
// ---------------------------------------------------------------------------
interface LogsSidebarProps {
filters: LogFilters;
onFiltersChange: (filters: LogFilters) => void;
}
export function LogsFilterSidebar({ filters, onFiltersChange }: LogsSidebarProps) {
const [collapsed, setCollapsed] = useState(false);
// Load persisted collapsed state on mount
useEffect(() => {
if (typeof window === "undefined") return;
const stored = window.localStorage.getItem(COLLAPSE_STORAGE_KEY);
if (stored === "true") setCollapsed(true);
}, []);
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
if (typeof window !== "undefined") {
window.localStorage.setItem(COLLAPSE_STORAGE_KEY, String(next));
}
return next;
});
}, []);
const activeFilterCount = useMemo(() => {
const excludedKeys = ["start_time", "end_time", "content_search", "metadata_filters"];
let count = Object.entries(filters).reduce((c, [key, value]) => {
if (excludedKeys.includes(key)) return c;
if (Array.isArray(value)) return c + value.length;
return c + (value ? 1 : 0);
}, 0);
if (filters.metadata_filters) {
count += Object.keys(filters.metadata_filters).length;
}
return count;
}, [filters]);
const handleReset = useCallback(() => {
onFiltersChange({
start_time: filters.start_time,
end_time: filters.end_time,
});
}, [filters.start_time, filters.end_time, onFiltersChange]);
// Collapsed: thin rail with vertical "Filters" label — whole rail is clickable to expand
if (collapsed) {
return (
<button
type="button"
onClick={toggleCollapsed}
className="bg-card group flex h-full w-10 shrink-0 cursor-pointer flex-col items-center gap-3 rounded-r-md py-3 text-sm font-medium"
title="Show filters"
aria-label="Show filters"
>
<PanelLeftOpen className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" />
<span className="rotate-180 select-none [writing-mode:vertical-rl]">Filters</span>
{activeFilterCount > 0 && (
<span className="bg-primary/10 text-primary flex size-6 items-center justify-center rounded-full text-xs font-medium">
{activeFilterCount}
</span>
)}
</button>
);
}
return (
<div className="bg-card flex h-full w-64 shrink-0 flex-col rounded-r-md">
{/* Header */}
<div className="flex h-11 items-center justify-between border-b pr-2 pl-5">
<span className="text-sm font-semibold">Filters</span>
<div className="flex items-center gap-1">
{activeFilterCount > 0 && (
<Button variant="outline" size="sm" className="text-muted-foreground h-7 px-2 text-xs" onClick={handleReset}>
<RotateCcw className="size-3" />
Reset
</Button>
)}
<Button variant="ghost" size="icon" className="size-7" onClick={toggleCollapsed} title="Hide filters" aria-label="Hide filters">
<PanelLeftClose className="size-4" />
</Button>
</div>
</div>
{/* Scrollable filter sections */}
<ScrollArea className="flex flex-1 overflow-y-auto p-2 pb-0" viewportClassName="no-table">
<div className="flex grow flex-col gap-1">
{/* First 2 open by default */}
<StatusFilter filters={filters} onFiltersChange={onFiltersChange} defaultOpen />
<ModelsFilter filters={filters} onFiltersChange={onFiltersChange} defaultOpen />
{/* Rest closed unless they have active filters */}
<SelectedKeysFilter filters={filters} onFiltersChange={onFiltersChange} />
<VirtualKeysFilter filters={filters} onFiltersChange={onFiltersChange} />
<ProvidersFilter filters={filters} onFiltersChange={onFiltersChange} />
<TypeFilter filters={filters} onFiltersChange={onFiltersChange} />
<AliasesFilter filters={filters} onFiltersChange={onFiltersChange} />
<RoutingEnginesFilter filters={filters} onFiltersChange={onFiltersChange} />
<RoutingRulesFilter filters={filters} onFiltersChange={onFiltersChange} />
<UserFilter filters={filters} onFiltersChange={onFiltersChange} />
<SessionFilter filters={filters} onFiltersChange={onFiltersChange} />
<CostFilter filters={filters} onFiltersChange={onFiltersChange} />
<MetadataFilters filters={filters} onFiltersChange={onFiltersChange} />
</div>
</ScrollArea>
</div>
);
}
// ---------------------------------------------------------------------------
// Shared helpers & primitives
// ---------------------------------------------------------------------------
function groupByName(items: { name: string; id: string }[]) {
const map = new Map<string, string[]>();
for (const item of items) {
const ids = map.get(item.name) || [];
ids.push(item.id);
map.set(item.name, ids);
}
return map;
}
function dedup(items: { name: string }[]) {
return [...new Map(items.map((i) => [i.name, i])).values()].map((i) => i.name);
}
/** Shared props every individual filter component receives. */
interface FilterComponentProps {
filters: LogFilters;
onFiltersChange: (filters: LogFilters) => void;
defaultOpen?: boolean;
}
// ---------------------------------------------------------------------------
// FilterSection collapsible wrapper
// ---------------------------------------------------------------------------
function FilterSectionSkeleton({ rows = 3 }: { rows?: number }) {
return (
<>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-2.5 px-3 py-2">
<Skeleton className="size-4 shrink-0 rounded-[4px]" />
<Skeleton className="h-3.5 w-full rounded" />
</div>
))}
</>
);
}
function FilterSection({
title,
children,
defaultOpen = false,
loading = false,
onOpenChange,
testId,
}: {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
loading?: boolean;
onOpenChange?: (open: boolean) => void;
testId?: string;
}) {
const [open, setOpen] = useState(defaultOpen);
// Force open when defaultOpen flips to true (e.g. a filter in this section becomes active)
useEffect(() => {
if (defaultOpen) setOpen(true);
}, [defaultOpen]);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
return (
<Collapsible open={open} onOpenChange={handleOpenChange} className="last:pb-2">
<CollapsibleTrigger
className="flex h-8 w-full cursor-pointer items-center gap-1.5 px-2 py-2 text-sm font-medium hover:opacity-80"
data-testid={testId}
>
<ChevronDown className={cn("size-3.5 transition-transform", open ? "rotate-0" : "-rotate-90")} />
<span>{title}</span>
</CollapsibleTrigger>
<CollapsibleContent className="pt-1">
<div className="divide-border divide-y overflow-hidden rounded-sm border">{loading ? <FilterSectionSkeleton /> : children}</div>
</CollapsibleContent>
</Collapsible>
);
}
// ---------------------------------------------------------------------------
// CheckboxFilterItem single checkbox row
// ---------------------------------------------------------------------------
function CheckboxFilterItem({
label,
checked,
onCheckedChange,
labelClassName,
testId,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
labelClassName?: string;
testId?: string;
}) {
return (
<label className="hover:bg-muted/50 flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm" data-testid={testId}>
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
<span className={cn("truncate", labelClassName)}>{label}</span>
</label>
);
}
// ---------------------------------------------------------------------------
// SearchableCheckboxList list of checkbox rows with a search input.
// Caller passes `inputRef` to control focus (see `useAutoFocusOnOpen`).
// ---------------------------------------------------------------------------
function useAutoFocusOnOpen(isOpen: boolean) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) ref.current?.focus({ preventScroll: true });
}, [isOpen]);
return ref;
}
function SearchableCheckboxList({
items,
isSelected,
onToggle,
placeholder = "Search...",
inputRef,
testIdPrefix,
}: {
items: { key: string; label: string }[];
isSelected: (key: string) => boolean;
onToggle: (key: string) => void;
placeholder?: string;
inputRef?: Ref<HTMLInputElement>;
testIdPrefix?: string;
}) {
const [query, setQuery] = useState("");
const normalized = query.trim().toLowerCase();
const filtered = normalized ? items.filter((item) => item.label.toLowerCase().includes(normalized)) : items;
return (
<>
<div className="border-b">
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="h-8 border-0 text-xs"
data-testid={testIdPrefix ? `${testIdPrefix}-search` : undefined}
/>
</div>
{filtered.map((item) => (
<CheckboxFilterItem
key={item.key}
label={item.label}
checked={isSelected(item.key)}
onCheckedChange={() => onToggle(item.key)}
testId={testIdPrefix ? `${testIdPrefix}-checkbox-${item.key}` : undefined}
/>
))}
{filtered.length === 0 && <div className="text-muted-foreground flex h-9 items-center px-3 text-xs">No results</div>}
</>
);
}
// ---------------------------------------------------------------------------
// StatusFilter
// ---------------------------------------------------------------------------
function StatusFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.status || []).length > 0;
return (
<FilterSection title="Status" defaultOpen={defaultOpen || hasActive} testId="status-filter-toggle">
{Statuses.map((status) => (
<CheckboxFilterItem
key={status}
labelClassName="capitalize"
label={status}
checked={(filters.status || []).includes(status)}
onCheckedChange={() => {
const current = filters.status || [];
const next = current.includes(status) ? current.filter((s) => s !== status) : [...current, status];
onFiltersChange({ ...filters, status: next });
}}
testId={`status-filter-checkbox-${status}`}
/>
))}
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// ProvidersFilter fetches providers internally
// ---------------------------------------------------------------------------
function ProvidersFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.providers || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
const { data: providersData, isUninitialized, isLoading } = useGetProvidersQuery(undefined, { skip: !opened && !hasActive });
const availableProviders = providersData || [];
// Hide only if data was fetched (not loading) and came back empty
if (!isUninitialized && !isLoading && availableProviders.length === 0 && !hasActive) return null;
return (
<FilterSection
title="Providers"
defaultOpen={defaultOpen || hasActive}
loading={isLoading}
onOpenChange={setOpened}
testId="providers-filter-toggle"
>
<SearchableCheckboxList
inputRef={searchInputRef}
placeholder="Search providers"
items={availableProviders.map((p) => ({ key: p.name, label: p.name }))}
isSelected={(name) => (filters.providers || []).includes(name)}
onToggle={(name) => {
const current = filters.providers || [];
const next = current.includes(name) ? current.filter((p) => p !== name) : [...current, name];
onFiltersChange({ ...filters, providers: next });
}}
testIdPrefix="providers-filter"
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// TypeFilter
// ---------------------------------------------------------------------------
function TypeFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.objects || []).length > 0;
return (
<FilterSection title="Type" defaultOpen={defaultOpen || hasActive} testId="type-filter-toggle">
{RequestTypes.map((type) => {
const label = RequestTypeLabels[type as keyof typeof RequestTypeLabels] ?? type;
return (
<CheckboxFilterItem
key={type}
label={label}
checked={(filters.objects || []).includes(type)}
onCheckedChange={() => {
const current = filters.objects || [];
const next = current.includes(type) ? current.filter((t) => t !== type) : [...current, type];
onFiltersChange({ ...filters, objects: next });
}}
testId={`type-filter-checkbox-${type}`}
/>
);
})}
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// ModelsFilter fetches available models internally
// ---------------------------------------------------------------------------
function ModelsFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.models || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive });
const availableModels = filterData?.models || [];
if (!isUninitialized && !isLoading && availableModels.length === 0 && !hasActive) return null;
return (
<FilterSection
title="Models"
defaultOpen={defaultOpen || hasActive}
loading={isLoading}
onOpenChange={setOpened}
testId="models-filter-toggle"
>
<SearchableCheckboxList
inputRef={searchInputRef}
placeholder="Search models"
items={availableModels.map((m) => ({ key: m, label: m }))}
isSelected={(model) => (filters.models || []).includes(model)}
onToggle={(model) => {
const current = filters.models || [];
const next = current.includes(model) ? current.filter((m) => m !== model) : [...current, model];
onFiltersChange({ ...filters, models: next });
}}
testIdPrefix="models-filter"
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// AliasesFilter fetches available aliases internally
// ---------------------------------------------------------------------------
function AliasesFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.aliases || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive });
const availableAliases = filterData?.aliases || [];
if (!isUninitialized && !isLoading && availableAliases.length === 0 && !hasActive) return null;
return (
<FilterSection
title="Aliases"
defaultOpen={defaultOpen || hasActive}
loading={isLoading}
onOpenChange={setOpened}
testId="aliases-filter-toggle"
>
<SearchableCheckboxList
inputRef={searchInputRef}
placeholder="Search aliases"
items={availableAliases.map((a) => ({ key: a, label: a }))}
isSelected={(alias) => (filters.aliases || []).includes(alias)}
onToggle={(alias) => {
const current = filters.aliases || [];
const next = current.includes(alias) ? current.filter((a) => a !== alias) : [...current, alias];
onFiltersChange({ ...filters, aliases: next });
}}
testIdPrefix="aliases-filter"
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// SelectedKeysFilter fetches keys, resolves name→IDs for deduplication
// ---------------------------------------------------------------------------
function SelectedKeysFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.selected_key_ids || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive });
const availableSelectedKeys = filterData?.selected_keys || [];
const nameToIds = useMemo(() => groupByName(availableSelectedKeys), [availableSelectedKeys]);
if (!isUninitialized && !isLoading && availableSelectedKeys.length === 0 && !hasActive) return null;
const toggle = (name: string) => {
const resolvedIds = nameToIds.get(name) || [name];
const current = filters.selected_key_ids || [];
const allSelected = resolvedIds.every((id) => current.includes(id));
const next = allSelected
? current.filter((v) => !resolvedIds.includes(v))
: [...current, ...resolvedIds.filter((id) => !current.includes(id))];
onFiltersChange({ ...filters, selected_key_ids: next });
};
const isSelected = (name: string) => {
const resolvedIds = nameToIds.get(name) || [name];
const current = filters.selected_key_ids || [];
return resolvedIds.every((id) => current.includes(id));
};
return (
<FilterSection
title="Selected Keys"
defaultOpen={defaultOpen || hasActive}
loading={isLoading}
onOpenChange={setOpened}
testId="selected-keys-filter-toggle"
>
<SearchableCheckboxList
inputRef={searchInputRef}
placeholder="Search keys"
items={dedup(availableSelectedKeys).map((name) => ({ key: name, label: name }))}
isSelected={isSelected}
onToggle={toggle}
testIdPrefix="selected-keys-filter"
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// VirtualKeysFilter
// ---------------------------------------------------------------------------
function VirtualKeysFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.virtual_key_ids || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive });
const availableVirtualKeys = filterData?.virtual_keys || [];
const nameToIds = useMemo(() => groupByName(availableVirtualKeys), [availableVirtualKeys]);
if (!isUninitialized && !isLoading && availableVirtualKeys.length === 0 && !hasActive) return null;
const toggle = (name: string) => {
const resolvedIds = nameToIds.get(name) || [name];
const current = filters.virtual_key_ids || [];
const allSelected = resolvedIds.every((id) => current.includes(id));
const next = allSelected
? current.filter((v) => !resolvedIds.includes(v))
: [...current, ...resolvedIds.filter((id) => !current.includes(id))];
onFiltersChange({ ...filters, virtual_key_ids: next });
};
const isSelected = (name: string) => {
const resolvedIds = nameToIds.get(name) || [name];
const current = filters.virtual_key_ids || [];
return resolvedIds.every((id) => current.includes(id));
};
return (
<FilterSection
title="Virtual Keys"
defaultOpen={defaultOpen || hasActive}
loading={isLoading}
onOpenChange={setOpened}
testId="virtual-keys-filter-toggle"
>
<SearchableCheckboxList
inputRef={searchInputRef}
placeholder="Search virtual keys"
items={dedup(availableVirtualKeys).map((name) => ({ key: name, label: name }))}
isSelected={isSelected}
onToggle={toggle}
testIdPrefix="virtual-keys-filter"
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// RoutingEnginesFilter
// ---------------------------------------------------------------------------
function RoutingEnginesFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.routing_engine_used || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive });
const availableRoutingEngines = filterData?.routing_engines || [];
if (!isUninitialized && !isLoading && availableRoutingEngines.length === 0 && !hasActive) return null;
return (
<FilterSection
title="Routing Engines"
defaultOpen={defaultOpen || hasActive}
loading={isLoading}
onOpenChange={setOpened}
testId="routing-engines-filter-toggle"
>
<SearchableCheckboxList
inputRef={searchInputRef}
placeholder="Search engines"
items={availableRoutingEngines.map((engine) => ({
key: engine,
label: RoutingEngineUsedLabels[engine as keyof typeof RoutingEngineUsedLabels] ?? engine,
}))}
isSelected={(engine) => (filters.routing_engine_used || []).includes(engine)}
onToggle={(engine) => {
const current = filters.routing_engine_used || [];
const next = current.includes(engine) ? current.filter((e) => e !== engine) : [...current, engine];
onFiltersChange({ ...filters, routing_engine_used: next });
}}
testIdPrefix="routing-engines-filter"
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// RoutingRulesFilter
// ---------------------------------------------------------------------------
function RoutingRulesFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.routing_rule_ids || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive });
const availableRoutingRules = filterData?.routing_rules || [];
const nameToIds = useMemo(() => groupByName(availableRoutingRules), [availableRoutingRules]);
if (!isUninitialized && !isLoading && availableRoutingRules.length === 0 && !hasActive) return null;
const toggle = (name: string) => {
const resolvedIds = nameToIds.get(name) || [name];
const current = filters.routing_rule_ids || [];
const allSelected = resolvedIds.every((id) => current.includes(id));
const next = allSelected
? current.filter((v) => !resolvedIds.includes(v))
: [...current, ...resolvedIds.filter((id) => !current.includes(id))];
onFiltersChange({ ...filters, routing_rule_ids: next });
};
const isSelected = (name: string) => {
const resolvedIds = nameToIds.get(name) || [name];
const current = filters.routing_rule_ids || [];
return resolvedIds.every((id) => current.includes(id));
};
return (
<FilterSection
title="Routing Rules"
defaultOpen={defaultOpen || hasActive}
loading={isLoading}
onOpenChange={setOpened}
testId="routing-rules-filter-toggle"
>
<SearchableCheckboxList
inputRef={searchInputRef}
placeholder="Search rules"
items={dedup(availableRoutingRules).map((name) => ({ key: name, label: name }))}
isSelected={isSelected}
onToggle={toggle}
testIdPrefix="routing-rules-filter"
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// SessionFilter
// ---------------------------------------------------------------------------
function SessionFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = !!filters.parent_request_id;
return (
<FilterSection title="Session" defaultOpen={defaultOpen || hasActive} testId="session-filter-toggle">
<Input
value={filters.parent_request_id || ""}
onChange={(e) => onFiltersChange({ ...filters, parent_request_id: e.target.value })}
placeholder="Parent request ID"
className="h-8 border-0 text-sm"
data-testid="session-filter-input"
autoFocus
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// UserFilter
// ---------------------------------------------------------------------------
function UserFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = !!filters.user_ids?.length;
return (
<FilterSection title="User" defaultOpen={defaultOpen || hasActive} testId="user-filter-toggle">
<Input
value={filters.user_ids?.[0] || ""}
onChange={(e) => onFiltersChange({ ...filters, user_ids: e.target.value ? [e.target.value] : [] })}
placeholder="User ID"
className="h-8 border-0 text-sm"
data-testid="user-id-filter-input"
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// CostFilter
// ---------------------------------------------------------------------------
function CostFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = !!filters.missing_cost_only;
return (
<FilterSection title="Cost" defaultOpen={defaultOpen || hasActive} testId="cost-filter-toggle">
<CheckboxFilterItem
label="Show missing cost only"
checked={!!filters.missing_cost_only}
onCheckedChange={(checked) => onFiltersChange({ ...filters, missing_cost_only: !!checked })}
testId="cost-filter-missing-only-checkbox"
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// MetadataFilters fetches metadata keys internally
// ---------------------------------------------------------------------------
function MetadataFilters({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = !!filters.metadata_filters && Object.keys(filters.metadata_filters).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const { data: filterData, isUninitialized, isLoading } = useGetAvailableFilterDataQuery(undefined, { skip: !opened && !hasActive });
const availableMetadataKeys = filterData?.metadata_keys || {};
const [customInputs, setCustomInputs] = useState<Record<string, string>>({});
const handleChange = useCallback(
(metadataKey: string, value: string | undefined) => {
const current = { ...(filters.metadata_filters || {}) };
if (value === undefined) {
delete current[metadataKey];
} else {
current[metadataKey] = value;
}
onFiltersChange({
...filters,
metadata_filters: Object.keys(current).length > 0 ? current : undefined,
});
},
[filters, onFiltersChange],
);
const entries = Object.entries(availableMetadataKeys);
const isEmpty = !isUninitialized && !isLoading && entries.length === 0 && !hasActive;
return (
<FilterSection
title="Metadata"
defaultOpen={defaultOpen || hasActive}
loading={isLoading}
onOpenChange={setOpened}
testId="metadata-filter-toggle"
>
{isEmpty ? (
<div className="text-muted-foreground px-3 py-2 text-xs">No metadata keys</div>
) : (
entries.map(([metadataKey, values]) => (
<div key={metadataKey} data-testid={`metadata-${metadataKey}-filter-group`}>
<div className="text-muted-foreground px-3 pt-2 pb-1 text-xs font-medium">{metadataKey}</div>
{values.map((value: string) => (
<CheckboxFilterItem
key={value}
label={value}
checked={filters.metadata_filters?.[metadataKey] === value}
onCheckedChange={() => {
const currentValue = filters.metadata_filters?.[metadataKey];
handleChange(metadataKey, currentValue === value ? undefined : value);
}}
testId={`metadata-${metadataKey}-filter-checkbox-${value}`}
/>
))}
<div className="px-3 py-2.5">
<Input
className="placeholder:text-muted-foreground h-7 w-full rounded border bg-transparent px-2 text-sm"
placeholder="Custom value..."
value={
customInputs[metadataKey] ??
(filters.metadata_filters?.[metadataKey] && !values.includes(filters.metadata_filters[metadataKey])
? filters.metadata_filters[metadataKey]
: "")
}
onChange={(e) => {
const newVal = e.target.value;
setCustomInputs((prev) => ({ ...prev, [metadataKey]: newVal }));
if (newVal === "" && filters.metadata_filters?.[metadataKey]) {
handleChange(metadataKey, undefined);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && customInputs[metadataKey]?.trim()) {
handleChange(metadataKey, customInputs[metadataKey].trim());
}
}}
data-testid={`metadata-${metadataKey}-filter-custom-input`}
/>
</div>
</div>
))
)}
</FilterSection>
);
}

View File

@@ -0,0 +1,375 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scrollArea";
import { Skeleton } from "@/components/ui/skeleton";
import { Statuses } from "@/lib/constants/logs";
import { useGetMCPLogsFilterDataQuery } from "@/lib/store";
import type { MCPToolLogFilters } from "@/lib/types/logs";
import { cn } from "@/lib/utils";
import { ChevronDown, PanelLeftClose, PanelLeftOpen, RotateCcw } from "lucide-react";
import { Ref, useCallback, useEffect, useMemo, useRef, useState } from "react";
const COLLAPSE_STORAGE_KEY = "mcp-filter-sidebar-collapsed";
// ---------------------------------------------------------------------------
// MCPFilterSidebar orchestrator
// ---------------------------------------------------------------------------
interface MCPFilterSidebarProps {
filters: MCPToolLogFilters;
onFiltersChange: (filters: MCPToolLogFilters) => void;
}
export function MCPFilterSidebar({ filters, onFiltersChange }: MCPFilterSidebarProps) {
const [collapsed, setCollapsed] = useState(false);
// Load persisted collapsed state on mount
useEffect(() => {
if (typeof window === "undefined") return;
const stored = window.localStorage.getItem(COLLAPSE_STORAGE_KEY);
if (stored === "true") setCollapsed(true);
}, []);
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
if (typeof window !== "undefined") {
window.localStorage.setItem(COLLAPSE_STORAGE_KEY, String(next));
}
return next;
});
}, []);
const activeFilterCount = useMemo(() => {
const excludedKeys = ["start_time", "end_time", "content_search"];
let count = Object.entries(filters).reduce((c, [key, value]) => {
if (excludedKeys.includes(key)) return c;
if (Array.isArray(value)) return c + value.length;
return c + (value ? 1 : 0);
}, 0);
return count;
}, [filters]);
const handleReset = useCallback(() => {
onFiltersChange({
start_time: filters.start_time,
end_time: filters.end_time,
});
}, [filters.start_time, filters.end_time, onFiltersChange]);
// Collapsed: thin rail with vertical "Filters" label — whole rail is clickable to expand
if (collapsed) {
return (
<button
type="button"
onClick={toggleCollapsed}
className="bg-card group flex h-full w-10 shrink-0 cursor-pointer flex-col items-center gap-3 rounded-r-md py-3 text-sm font-medium"
title="Show filters"
aria-label="Show filters"
>
<PanelLeftOpen className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" />
<span className="rotate-180 select-none [writing-mode:vertical-rl]">Filters</span>
{activeFilterCount > 0 && (
<span className="bg-primary/10 text-primary flex size-6 items-center justify-center rounded-full text-xs font-medium">
{activeFilterCount}
</span>
)}
</button>
);
}
return (
<div className="bg-card flex h-full w-64 shrink-0 flex-col rounded-r-md">
{/* Header */}
<div className="flex h-11 items-center justify-between border-b pr-2 pl-5">
<span className="text-sm font-semibold">Filters</span>
<div className="flex items-center gap-1">
{activeFilterCount > 0 && (
<Button variant="outline" size="sm" className="text-muted-foreground h-7 px-2 text-xs" onClick={handleReset}>
<RotateCcw className="size-3" />
Reset
</Button>
)}
<Button variant="ghost" size="icon" className="size-7" onClick={toggleCollapsed} title="Hide filters" aria-label="Hide filters">
<PanelLeftClose className="size-4" />
</Button>
</div>
</div>
{/* Scrollable filter sections */}
<ScrollArea className="flex flex-1 overflow-y-auto p-2 pb-0" viewportClassName="no-table">
<div className="flex grow flex-col gap-1">
{/* First 2 open by default */}
<StatusFilter filters={filters} onFiltersChange={onFiltersChange} defaultOpen />
<ToolNamesFilter filters={filters} onFiltersChange={onFiltersChange} defaultOpen />
{/* Rest closed unless they have active filters */}
<ServersFilter filters={filters} onFiltersChange={onFiltersChange} />
<VirtualKeysFilter filters={filters} onFiltersChange={onFiltersChange} />
</div>
</ScrollArea>
</div>
);
}
// ---------------------------------------------------------------------------
// Shared helpers & primitives
// ---------------------------------------------------------------------------
interface FilterComponentProps {
filters: MCPToolLogFilters;
onFiltersChange: (filters: MCPToolLogFilters) => void;
defaultOpen?: boolean;
}
// ---------------------------------------------------------------------------
// FilterSection collapsible wrapper
// ---------------------------------------------------------------------------
function FilterSectionSkeleton({ rows = 3 }: { rows?: number }) {
return (
<>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-2.5 px-3 py-2">
<Skeleton className="size-4 shrink-0 rounded-[4px]" />
<Skeleton className="h-3.5 w-full rounded" />
</div>
))}
</>
);
}
function FilterSection({
title,
children,
defaultOpen = false,
loading = false,
onOpenChange,
}: {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
loading?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
if (defaultOpen) setOpen(true);
}, [defaultOpen]);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
return (
<Collapsible open={open} onOpenChange={handleOpenChange} className="last:pb-2">
<CollapsibleTrigger className="flex h-8 w-full cursor-pointer items-center gap-1.5 px-2 py-2 text-sm font-medium hover:opacity-80">
<ChevronDown className={cn("size-3.5 transition-transform", open ? "rotate-0" : "-rotate-90")} />
<span>{title}</span>
</CollapsibleTrigger>
<CollapsibleContent className="pt-1">
<div className="divide-border divide-y overflow-hidden rounded-sm border">{loading ? <FilterSectionSkeleton /> : children}</div>
</CollapsibleContent>
</Collapsible>
);
}
// ---------------------------------------------------------------------------
// CheckboxFilterItem
// ---------------------------------------------------------------------------
function CheckboxFilterItem({
label,
checked,
onCheckedChange,
labelClassName,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
labelClassName?: string;
}) {
return (
<label className="hover:bg-muted/50 flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm">
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
<span className={cn("truncate", labelClassName)}>{label}</span>
</label>
);
}
// ---------------------------------------------------------------------------
// SearchableCheckboxList list of checkbox rows with a search input.
// Caller passes `inputRef` to control focus (see `useAutoFocusOnOpen`).
// ---------------------------------------------------------------------------
function useAutoFocusOnOpen(isOpen: boolean) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) ref.current?.focus({ preventScroll: true });
}, [isOpen]);
return ref;
}
function SearchableCheckboxList({
items,
isSelected,
onToggle,
placeholder = "Search...",
inputRef,
}: {
items: { key: string; label: string }[];
isSelected: (key: string) => boolean;
onToggle: (key: string) => void;
placeholder?: string;
inputRef?: Ref<HTMLInputElement>;
}) {
const [query, setQuery] = useState("");
const normalized = query.trim().toLowerCase();
const filtered = normalized ? items.filter((item) => item.label.toLowerCase().includes(normalized)) : items;
return (
<>
<div className="border-b">
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="h-8 border-0 text-xs"
/>
</div>
{filtered.map((item) => (
<CheckboxFilterItem key={item.key} label={item.label} checked={isSelected(item.key)} onCheckedChange={() => onToggle(item.key)} />
))}
{filtered.length === 0 && <div className="text-muted-foreground flex h-9 items-center px-3 text-xs">No results</div>}
</>
);
}
// ---------------------------------------------------------------------------
// StatusFilter
// ---------------------------------------------------------------------------
function StatusFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.status || []).length > 0;
return (
<FilterSection title="Status" defaultOpen={defaultOpen || hasActive}>
{Statuses.map((status) => (
<CheckboxFilterItem
key={status}
labelClassName="capitalize"
label={status}
checked={(filters.status || []).includes(status)}
onCheckedChange={() => {
const current = filters.status || [];
const next = current.includes(status) ? current.filter((s) => s !== status) : [...current, status];
onFiltersChange({ ...filters, status: next });
}}
/>
))}
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// ToolNamesFilter fetches tool names; skips while closed & inactive
// ---------------------------------------------------------------------------
function ToolNamesFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.tool_names || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
const { data: filterData, isUninitialized, isLoading } = useGetMCPLogsFilterDataQuery(undefined, { skip: !opened && !hasActive });
const availableToolNames = filterData?.tool_names || [];
if (!isUninitialized && !isLoading && availableToolNames.length === 0 && !hasActive) return null;
return (
<FilterSection title="Tool Names" defaultOpen={defaultOpen || hasActive} loading={isLoading} onOpenChange={setOpened}>
<SearchableCheckboxList
inputRef={searchInputRef}
placeholder="Search tools"
items={availableToolNames.map((name) => ({ key: name, label: name }))}
isSelected={(name) => (filters.tool_names || []).includes(name)}
onToggle={(name) => {
const current = filters.tool_names || [];
const next = current.includes(name) ? current.filter((n) => n !== name) : [...current, name];
onFiltersChange({ ...filters, tool_names: next });
}}
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// ServersFilter fetches server labels; skips while closed & inactive
// ---------------------------------------------------------------------------
function ServersFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.server_labels || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
const { data: filterData, isUninitialized, isLoading } = useGetMCPLogsFilterDataQuery(undefined, { skip: !opened && !hasActive });
const availableServerLabels = filterData?.server_labels || [];
if (!isUninitialized && !isLoading && availableServerLabels.length === 0 && !hasActive) return null;
return (
<FilterSection title="Servers" defaultOpen={defaultOpen || hasActive} loading={isLoading} onOpenChange={setOpened}>
<SearchableCheckboxList
inputRef={searchInputRef}
placeholder="Search servers"
items={availableServerLabels.map((label) => ({ key: label, label }))}
isSelected={(label) => (filters.server_labels || []).includes(label)}
onToggle={(label) => {
const current = filters.server_labels || [];
const next = current.includes(label) ? current.filter((l) => l !== label) : [...current, label];
onFiltersChange({ ...filters, server_labels: next });
}}
/>
</FilterSection>
);
}
// ---------------------------------------------------------------------------
// VirtualKeysFilter fetches virtual keys; maps name→ID
// ---------------------------------------------------------------------------
function VirtualKeysFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) {
const hasActive = (filters.virtual_key_ids || []).length > 0;
const [opened, setOpened] = useState(defaultOpen || hasActive);
const searchInputRef = useAutoFocusOnOpen(opened);
const { data: filterData, isUninitialized, isLoading } = useGetMCPLogsFilterDataQuery(undefined, { skip: !opened && !hasActive });
const availableVirtualKeys = filterData?.virtual_keys || [];
const nameToId = useMemo(() => new Map(availableVirtualKeys.map((key) => [key.name, key.id])), [availableVirtualKeys]);
if (!isUninitialized && !isLoading && availableVirtualKeys.length === 0 && !hasActive) return null;
const isSelected = (name: string) => {
const id = nameToId.get(name) || name;
return (filters.virtual_key_ids || []).includes(id);
};
const toggle = (name: string) => {
const id = nameToId.get(name) || name;
const current = filters.virtual_key_ids || [];
const next = current.includes(id) ? current.filter((v) => v !== id) : [...current, id];
onFiltersChange({ ...filters, virtual_key_ids: next });
};
return (
<FilterSection title="Virtual Keys" defaultOpen={defaultOpen || hasActive} loading={isLoading} onOpenChange={setOpened}>
<SearchableCheckboxList
inputRef={searchInputRef}
placeholder="Search virtual keys"
items={availableVirtualKeys.map((key) => ({ key: key.name, label: key.name }))}
isSelected={isSelected}
onToggle={toggle}
/>
</FilterSection>
);
}

View File

@@ -0,0 +1,49 @@
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Validator } from "@/lib/utils/validation";
import { Save } from "lucide-react";
interface FormFooterProps {
validator: Validator;
label: string;
onCancel: () => void;
isLoading: boolean;
isEditing: boolean;
hasPermission?: boolean;
}
export default function FormFooter({ validator, label, onCancel, isLoading, isEditing, hasPermission = true }: FormFooterProps) {
const isDisabled = isLoading || !validator.isValid() || !hasPermission;
const getTooltipMessage = () => {
if (!hasPermission) return "You don't have permission to perform this action";
if (isLoading) return "Saving...";
return validator.getFirstError() || "Please fix validation errors";
};
return (
<DialogFooter className="mt-4">
<Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button type="submit" disabled={isDisabled}>
<Save className="h-4 w-4" />
{isLoading ? "Saving..." : isEditing ? `Update ${label}` : `Create ${label}`}
</Button>
</span>
</TooltipTrigger>
{isDisabled && (
<TooltipContent>
<p>{getTooltipMessage()}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</DialogFooter>
);
}

View File

@@ -0,0 +1,11 @@
import { Loader2 } from "lucide-react";
function FullPageLoader() {
return (
<div className="h-base pb-1/2 flex items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
export default FullPageLoader;

14
ui/components/header.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { ThemeToggle } from "./themeToggle";
import { Separator } from "./ui/separator";
export default function Header({ title }: { title: string }) {
return (
<div className="bg-background fixed top-0 right-0 left-(--sidebar-width) z-10">
<div className="flex items-center justify-between px-3">
<div className="p-3 font-semibold">{title}</div>
<ThemeToggle />
</div>
<Separator className="w-full" />
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Button } from "@/components/ui/button";
import { getErrorMessage, useGetCoreConfigQuery, useUpdateCoreConfigMutation } from "@/lib/store";
import { cn } from "@/lib/utils";
import { ScrollText } from "lucide-react";
import { useCallback } from "react";
import { toast } from "sonner";
export function LoggingDisabledView() {
const { data: bifrostConfig } = useGetCoreConfigQuery({ fromDB: true });
const [updateCoreConfig, { isLoading }] = useUpdateCoreConfigMutation();
const handleEnable = useCallback(async () => {
if (!bifrostConfig?.client_config) {
toast.error("Configuration not loaded");
return;
}
try {
await updateCoreConfig({
...bifrostConfig,
client_config: { ...bifrostConfig.client_config, enable_logging: true },
}).unwrap();
toast.success("Logging enabled.");
} catch (error) {
toast.error(getErrorMessage(error));
}
}, [bifrostConfig, updateCoreConfig]);
return (
<div className={cn("flex flex-col items-center justify-center gap-4 text-center mx-auto w-full max-w-7xl min-h-[80vh]")}>
<div className="text-muted-foreground">
<ScrollText className="h-10 w-10" />
</div>
<div className="flex flex-col gap-1">
<h1 className="text-muted-foreground text-xl font-medium">Logging is disabled</h1>
<div className="text-muted-foreground mt-2 max-w-[600px] text-sm font-normal">
Enable logging to view LLM and MCP request logs, traces, and observability data.
</div>
</div>
<Button onClick={handleEnable} disabled={isLoading}>
{isLoading ? "Enabling…" : "Enable logging"}
</Button>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { cn } from "@/lib/utils";
import { ShieldX } from "lucide-react";
interface NoPermissionViewProps {
entity: string;
className?: string;
align?: "middle" | "top";
}
export function NoPermissionView({ entity, className, align = "middle" }: NoPermissionViewProps) {
return (
<div
className={cn(
"flex min-h-[calc(100vh-200px)] flex-col items-center gap-4 text-center",
align === "middle" ? "justify-center" : "justify-start",
className,
)}
>
<div className="text-muted-foreground">
<ShieldX className="h-16 w-16" strokeWidth={1} />
</div>
<div className="flex flex-col items-center gap-1">
<h1 className="text-muted-foreground text-xl font-medium">You don't have permission to view {entity}</h1>
<p className="text-muted-foreground mt-2 max-w-[400px] text-sm font-normal">
Contact your administrator to request access to this resource.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Database } from "lucide-react";
const NotAvailableBanner = () => {
return (
<div className="h-base flex items-center justify-center p-4">
<div className="w-full max-w-md">
<Alert className="border-destructive/50 text-destructive/50 dark:text-destructive/70 dark:border-destructive/70 [&>svg]:text-destructive dark:bg-card bg-red-50">
<AlertTitle className="flex items-center gap-2">
<Database className="dark:text-destructive/70 text-destructive/50 h-4 w-4" />
Config store setup is missing.
</AlertTitle>
<AlertDescription className="mt-2 space-y-2 text-xs">
<div>The UI requires a database connection to store configuration data, but no database is currently configured.</div>
<div className="text-muted-foreground">
To enable the UI, please add the database settings to your config.json (see{" "}
<a
href="https://www.getmaxim.ai/bifrost/docs/quickstart/gateway/setting-up#two-configuration-modes"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-2"
data-testid="config-store-documentation-link"
>
documentation
</a>
).
</div>
</AlertDescription>
</Alert>
</div>
</div>
);
};
export default NotAvailableBanner;

View File

@@ -0,0 +1,34 @@
import { useRouter } from "@tanstack/react-router";
import { BProgress } from "@bprogress/core";
import { useEffect } from "react";
/**
* App-wide top progress bar driven by TanStack Router navigation events.
* Replaces @bprogress/next/app, which only worked with the Next.js router.
*
* Subscribes to the router's pending state via subscribe() and toggles the
* @bprogress/core bar accordingly.
*/
const AppProgressProvider = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
useEffect(() => {
BProgress.configure({ showSpinner: false, minimum: 0.1 });
const unsubBefore = router.subscribe("onBeforeLoad", () => {
BProgress.start();
});
const unsubLoad = router.subscribe("onLoad", () => {
BProgress.done();
});
return () => {
unsubBefore();
unsubLoad();
};
}, [router]);
return <>{children}</>;
};
export default AppProgressProvider;

View File

@@ -0,0 +1,71 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alertDialog";
import { usePromptContext } from "../context";
export function DeleteFolderDialog() {
const { deleteFolderDialog, setDeleteFolderDialog, isDeletingFolder, handleDeleteFolder } = usePromptContext();
return (
<AlertDialog open={deleteFolderDialog.open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Folder</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deleteFolderDialog.folder?.name}&quot;? This will also delete all prompts, versions, and
sessions in this folder. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
data-testid="delete-folder-cancel"
onClick={() => setDeleteFolderDialog({ open: false })}
disabled={isDeletingFolder}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction data-testid="delete-folder-confirm" onClick={handleDeleteFolder} disabled={isDeletingFolder}>
{isDeletingFolder ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function DeletePromptDialog() {
const { deletePromptDialog, setDeletePromptDialog, isDeletingPrompt, handleDeletePrompt } = usePromptContext();
return (
<AlertDialog open={deletePromptDialog.open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Prompt</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deletePromptDialog.prompt?.name}&quot;? This will also delete all versions and sessions.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
data-testid="delete-prompt-cancel"
onClick={() => setDeletePromptDialog({ open: false })}
disabled={isDeletingPrompt}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction data-testid="delete-prompt-confirm" onClick={handleDeletePrompt} disabled={isDeletingPrompt}>
{isDeletingPrompt ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,92 @@
import {
Combobox,
ComboboxContent,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
} from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import type { DBKey, VirtualKey } from "@/lib/types/governance";
import { useCallback, useMemo, useState } from "react";
export function ApiKeySelectorView({
providerKeys,
virtualKeys,
value,
onValueChange,
disabled,
placeholder,
}: {
providerKeys: DBKey[];
virtualKeys: VirtualKey[];
value: string;
onValueChange: (v: string | null) => void;
disabled?: boolean;
placeholder?: string;
}) {
const [query, setQuery] = useState("");
const allOptions = useMemo(() => {
const apiKeyOpts = providerKeys.map((k) => ({ label: k.name, value: k.key_id, group: "api" as const }));
const vkOpts = virtualKeys.map((vk) => ({ label: vk.name, value: vk.id, group: "virtual" as const }));
return [{ label: "Auto (default)", value: "__auto__", group: "api" as const }, ...apiKeyOpts, ...vkOpts];
}, [providerKeys, virtualKeys]);
const filtered = useMemo(() => {
if (!query) return allOptions;
const q = query.toLowerCase();
return allOptions.filter((o) => o.label.toLowerCase().includes(q));
}, [allOptions, query]);
const filteredApiKeys = useMemo(() => filtered.filter((o) => o.group === "api"), [filtered]);
const filteredVirtualKeys = useMemo(() => filtered.filter((o) => o.group === "virtual"), [filtered]);
const getLabel = useCallback((val: string | null) => allOptions.find((o) => o.value === val)?.label ?? val ?? "", [allOptions]);
return (
<div className="flex flex-col gap-2">
<Label className="text-muted-foreground text-xs font-medium uppercase">Virtual key/ API Key</Label>
<Combobox
value={value}
onValueChange={(v) => onValueChange(v)}
onOpenChange={(open) => {
if (open) setQuery("");
}}
onInputValueChange={(v) => setQuery(v)}
filter={null}
itemToStringLabel={getLabel}
>
<ComboboxInput placeholder={placeholder ?? "Select API key"} showClear={value !== "__auto__"} showTrigger disabled={disabled} />
<ComboboxContent>
<ComboboxList>
{filteredApiKeys.length > 0 && (
<ComboboxGroup>
<ComboboxLabel>API Keys</ComboboxLabel>
{filteredApiKeys.map((o) => (
<ComboboxItem key={o.value} value={o.value}>
{o.label}
</ComboboxItem>
))}
</ComboboxGroup>
)}
{filteredApiKeys.length > 0 && filteredVirtualKeys.length > 0 && <ComboboxSeparator />}
{filteredVirtualKeys.length > 0 && (
<ComboboxGroup>
<ComboboxLabel>Virtual Keys</ComboboxLabel>
{filteredVirtualKeys.map((o) => (
<ComboboxItem key={o.value} value={o.value}>
{o.label}
</ComboboxItem>
))}
</ComboboxGroup>
)}
{filtered.length === 0 && <div className="text-muted-foreground py-6 text-center text-sm">No results found.</div>}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { Button } from "@/components/ui/button";
import { ArrowUpRight, SquareTerminal } from "lucide-react";
import { usePromptContext } from "../context";
export function EmptyState() {
const { setPromptSheet, canCreate } = usePromptContext();
return (
<div className="text-muted-foreground flex h-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-medium">No prompt selected</p>
<p className="text-sm">
{canCreate ? (
<>
Select a prompt from the sidebar or{" "}
<Button
variant="link"
className="h-auto p-0 text-sm"
data-testid="empty-state-create-prompt-link"
onClick={() => setPromptSheet({ open: true })}
>
create a new one
</Button>
</>
) : (
"Select a prompt from the sidebar"
)}
</p>
</div>
</div>
);
}
export function PromptsEmptyState() {
const { setPromptSheet, canCreate } = usePromptContext();
return (
<div className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center">
<div className="text-muted-foreground">
<SquareTerminal className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />
</div>
<div className="flex flex-col gap-1">
<h1 className="text-muted-foreground text-xl font-medium">Build, test, and version your prompts</h1>
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
{canCreate
? "Create prompts, test them with different models and parameters in the playground, and version your changes for deployment."
: "View prompts and test them with different models and parameters in the playground."}
</div>
<div className="mx-auto mt-6 flex flex-row flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
aria-label="Read more about prompt repository (opens in new tab)"
data-testid="empty-state-read-more"
onClick={() => {
window.open(`https://docs.getbifrost.ai/features/prompt-repository?utm_source=bfd`, "_blank", "noopener,noreferrer");
}}
>
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
</Button>
{canCreate && (
<Button
aria-label="Create your first prompt"
data-testid="empty-state-create-prompt"
onClick={() => setPromptSheet({ open: true })}
>
Create Prompt
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Message, SerializedMessage } from "@/lib/message";
import { InfoIcon, PencilIcon, XIcon } from "lucide-react";
import { Markdown } from "@/components/ui/markdown";
import { useEffect, useMemo, useRef, useState } from "react";
import MessageRoleSwitcher from "./messageRoleSwitcher";
import { isJson } from "@/lib/utils/validation";
import { CodeEditor } from "@/components/ui/codeEditor";
/**
* Renders the assistant message UI including role switcher, usage tooltip, edit/delete controls, and editable or view-only content.
*
* The component allows inline editing of plain text or JSON content (JSON edits are buffered and committed on blur), toggling role, and removing the message. Clicking outside the component exits edit mode.
*
* @param message - The message model to display and edit; updates are emitted via `onChange` as the message's serialized form.
* @param disabled - When true, disables editing, role changes, and delete action.
* @param isStreaming - When true, shows streaming state (loading indicator) and prevents entering edit mode.
* @param onChange - Called when the message is modified; receives the message's serialized representation.
* @param onRemove - Optional callback invoked when the delete action is triggered.
* @returns The rendered assistant message element.
*/
export function AssistantMessageView({
message,
disabled,
isStreaming,
onChange,
onRemove,
}: {
message: Message;
disabled?: boolean;
isStreaming?: boolean;
onChange: (serialized: SerializedMessage) => void;
onRemove?: () => void;
}) {
const [editMode, setEditMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const content = message.content;
const isEmpty = !content;
const usage = message.usage;
const jsonBufferRef = useRef<string | null>(null);
const contentIsJson = useMemo(() => !isEmpty && !isStreaming && isJson(content), [content, isEmpty, isStreaming]);
const formattedJson = useMemo(() => {
if (!contentIsJson) return "";
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}, [content, contentIsJson]);
const flushJsonBuffer = () => {
if (jsonBufferRef.current !== null) {
const clone = message.clone();
clone.content = jsonBufferRef.current;
onChange(clone.serialized);
jsonBufferRef.current = null;
}
};
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setEditMode(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleRoleChange = (role: string) => {
const clone = message.clone();
clone.role = role as any;
onChange(clone.serialized);
};
return (
<div
className="group hover:border-border focus-within:border-border rounded-sm border border-transparent px-3 py-2 transition-colors"
ref={containerRef}
>
<div className="mb-1 flex items-center">
<MessageRoleSwitcher role={message.role ?? ""} disabled={disabled} onRoleChange={handleRoleChange} />
<div className="ml-auto flex h-5 items-center gap-0.5">
{usage && (
<Tooltip>
<TooltipTrigger className="hover:bg-muted focus:bg-muted rounded-sm p-1 focus:opacity-100">
<InfoIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100" />
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="flex flex-col gap-0.5 text-xs tabular-nums">
<span>
<span className="inline-block w-12">Input:</span> {usage.prompt_tokens} tokens
</span>
<span>
<span className="inline-block w-12">Output:</span> {usage.completion_tokens} tokens
</span>
<span>
<span className="inline-block w-12">Total:</span> {usage.total_tokens} tokens
</span>
</div>
</TooltipContent>
</Tooltip>
)}
{!disabled && !isStreaming && (
<button
type="button"
aria-label="Edit message"
data-testid="assistant-msg-edit"
onClick={() => setEditMode(true)}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<PencilIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="assistant-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div>
{isStreaming && isEmpty ? (
<div className="flex items-center gap-1 py-1">
<span className="bg-muted-foreground h-1.5 w-1.5 animate-bounce rounded-full opacity-60" style={{ animationDelay: "0ms" }} />
<span className="bg-muted-foreground h-1.5 w-1.5 animate-bounce rounded-full opacity-60" style={{ animationDelay: "150ms" }} />
<span className="bg-muted-foreground h-1.5 w-1.5 animate-bounce rounded-full opacity-60" style={{ animationDelay: "300ms" }} />
</div>
) : editMode ? (
<Textarea
autoFocus
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
disabled={disabled}
onChange={(e) => {
const clone = message.clone();
clone.content = e.target.value;
onChange(clone.serialized);
}}
onFocus={(e) => {
const target = e.target;
requestAnimationFrame(() => {
target.selectionStart = target.value.length;
target.selectionEnd = target.value.length;
});
}}
onBlur={() => {
if (content.trim().length > 0) setEditMode(false);
}}
/>
) : isEmpty ? (
<div className="text-muted-foreground min-h-[20px] text-sm italic">Enter assistant message...</div>
) : contentIsJson ? (
<CodeEditor
wrap
code={formattedJson}
lang="json"
readonly={disabled}
autoResize
onChange={(value) => {
jsonBufferRef.current = value ?? "";
}}
onBlur={flushJsonBuffer}
options={{
showIndentLines: false,
disableHover: true,
}}
/>
) : (
<div
className={!disabled && !isStreaming ? "cursor-text" : undefined}
onClick={(e) => {
if (disabled || isStreaming || editMode) return;
if ((e.target as HTMLElement).closest("button, a, [role='button']")) return;
setEditMode(true);
}}
>
<Markdown content={content} isStreaming={isStreaming} className="text-muted-foreground" />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { MessageContent } from "@/lib/message";
import { Mic, FileIcon, XIcon } from "lucide-react";
/**
* Renders a compact badge for a single attachment with an inline remove control.
*
* Displays different visuals based on the attachment type:
* - image_url with a valid URL: a thumbnail and the label "Image"
* - input_audio: a microphone icon and the audio format in uppercase or "Audio"
* - other: a file icon and the filename or "File"
*
* @param attachment - The attachment to display; its `type` determines the badge content.
* @param onRemove - Callback invoked when the badge's remove button is clicked.
* @returns The rendered attachment badge element.
*/
export function AttachmentBadge({ attachment, onRemove }: { attachment: MessageContent; onRemove: () => void }) {
const isImage = attachment.type === "image_url";
const isAudio = attachment.type === "input_audio";
return (
<div className="group/att bg-muted/50 relative flex items-center gap-1.5 rounded-sm border px-2 py-1 text-xs">
{isImage && attachment.image_url?.url ? (
<>
<img src={attachment.image_url.url} alt="attachment" className="h-8 w-8 rounded object-cover" />
<span className="text-muted-foreground max-w-[100px] truncate">Image</span>
</>
) : isAudio ? (
<>
<Mic className="text-muted-foreground size-3" />
<span className="text-muted-foreground max-w-[100px] truncate">{attachment.input_audio?.format?.toUpperCase() || "Audio"}</span>
</>
) : (
<>
<FileIcon className="text-muted-foreground size-3" />
<span className="text-muted-foreground max-w-[120px] truncate">{attachment.file?.filename || "File"}</span>
</>
)}
<button
onClick={onRemove}
className="text-muted-foreground hover:bg-card hover:text-destructive ml-0.5 cursor-pointer rounded-full p-0.5"
type="button"
>
<XIcon className="size-3" />
</button>
</div>
);
}
/**
* Renders a compact list of attachment previews (images, audio controls, or file rows).
*
* Renders each attachment according to its type:
* - image_url with a URL: an image thumbnail
* - input_audio: an HTML audio control built from base64 data
* - file: a row with a file icon, filename, and optional file type
*
* When `editable` is true and `onRemoveAttachment` is provided, a remove button is shown for each attachment and invokes the callback with the attachment's index when clicked.
*
* @param attachments - The attachments to render.
* @param editable - If true, show per-attachment remove controls.
* @param onRemoveAttachment - Callback invoked with the attachment index when a remove control is clicked.
* @returns A JSX element containing the rendered attachments, or `null` when `attachments` is empty.
*/
export function AttachmentDisplay({
attachments,
editable,
onRemoveAttachment,
}: {
attachments: MessageContent[];
editable?: boolean;
onRemoveAttachment?: (index: number) => void;
}) {
if (attachments.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{attachments.map((att, i) => {
if (att.type === "image_url" && att.image_url?.url) {
return (
<div key={i} className="group/att relative max-w-full">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img
src={att.image_url.url}
alt="attached image"
className="max-h-48 max-w-full rounded-sm border object-contain sm:max-w-xs"
/>
{editable && onRemoveAttachment && (
<button
onClick={() => onRemoveAttachment(i)}
className="bg-background/80 text-muted-foreground hover:bg-card hover:text-destructive absolute -top-1.5 -right-1.5 cursor-pointer rounded-full border p-0.5 opacity-0 transition-opacity group-hover/att:opacity-100"
>
<XIcon className="size-3" />
</button>
)}
</div>
);
}
if (att.type === "input_audio") {
const format = att.input_audio?.format || "wav";
const dataUrl = `data:audio/${format};base64,${att.input_audio?.data || ""}`;
return (
<div key={i} className="group/att bg-muted/30 relative flex w-full items-center gap-2 rounded-sm border px-3 py-2">
<audio controls className="h-8 w-full min-w-0 grow">
<source src={dataUrl} type={`audio/${format}`} />
</audio>
{editable && onRemoveAttachment && (
<button
onClick={() => onRemoveAttachment(i)}
className="bg-background/80 text-muted-foreground hover:bg-card hover:text-destructive absolute -top-1.5 -right-1.5 cursor-pointer rounded-full border p-0.5 opacity-0 transition-opacity group-hover/att:opacity-100"
>
<XIcon className="size-3" />
</button>
)}
</div>
);
}
if (att.type === "file") {
return (
<div
key={i}
className="group/att bg-muted/30 text-muted-foreground relative flex max-w-full items-center gap-2 rounded-sm border px-3 py-1.5 text-sm"
>
<FileIcon className="size-3 shrink-0" />
<span className="min-w-0 truncate">{att.file?.filename || "File"}</span>
{att.file?.file_type && <span className="shrink-0 text-xs opacity-60">{att.file.file_type}</span>}
{editable && onRemoveAttachment && (
<button
onClick={() => onRemoveAttachment(i)}
className="bg-background/80 text-muted-foreground hover:bg-card hover:text-destructive absolute -top-1.5 -right-1.5 rounded-full border p-0.5 opacity-0 transition-opacity group-hover/att:opacity-100"
>
<XIcon className="size-3" />
</button>
)}
</div>
);
}
return null;
})}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { Message } from "@/lib/message";
import { AlertCircle, XIcon } from "lucide-react";
/**
* Render a styled error message block with an optional delete control.
*
* @param message - The message object whose `content` is displayed inside the error block.
* @param disabled - When true, the remove button is not rendered.
* @param onRemove - Callback invoked when the delete button is clicked.
* @returns The React element that displays the error message view.
*/
export default function ErrorMessageView({ message, disabled, onRemove }: { message: Message; disabled?: boolean; onRemove?: () => void }) {
return (
<div className="group hover:border-destructive/30 focus-within:border-destructive/30 rounded-sm border border-transparent px-3 py-2 transition-colors">
<div className="mb-1 flex h-5 items-center">
<span className="text-destructive flex items-center gap-1 py-0.5 text-xs font-medium uppercase">
<AlertCircle className="size-3" />
Error
</span>
<div className="ml-auto">
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="error-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div className="bg-destructive/10 rounded-sm px-2.5 py-1.5">
<p className="text-muted-foreground text-sm whitespace-pre-wrap">{message.content}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
const AVAILABLE_ROLES = [
{ value: "system", label: "System" },
{ value: "user", label: "User" },
{ value: "assistant", label: "Assistant" },
{ value: "tool", label: "Tool" },
] as const;
/**
* Render a dropdown that lets the user switch the current message role.
*
* @param role - The currently selected role value shown in the trigger
* @param disabled - If true, disables interaction with the trigger
* @param onRoleChange - Callback invoked with the newly selected role value
* @param restrictedRoles - Optional list of role values that should be excluded from the menu
* @returns A JSX element rendering the role selection dropdown
*/
export default function MessageRoleSwitcher({
role,
disabled,
onRoleChange,
restrictedRoles,
}: {
role: string;
disabled?: boolean;
onRoleChange: (role: string) => void;
restrictedRoles?: (typeof AVAILABLE_ROLES)[number]["value"][];
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
className={cn(
"-ml-1.5 flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs font-medium uppercase",
!disabled && "hover:bg-muted cursor-pointer",
)}
>
{role}
<ChevronDown className="size-3 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{AVAILABLE_ROLES.filter((r) => r.value !== role && (!restrictedRoles || !restrictedRoles.includes(r.value))).map((option) => (
<DropdownMenuItem key={option.value} onSelect={() => onRoleChange(option.value)}>
{option.label.toUpperCase()}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,165 @@
import { Message, MessageType, SerializedMessage, extractVariablesFromMessages, mergeVariables } from "@/lib/message";
import { useCallback, useEffect, useRef } from "react";
import { usePromptContext } from "../../context";
import { SystemMessageView } from "./systemMessageView";
import { UserMessageView } from "./userMessageView";
import { AssistantMessageView } from "./assistantMessageView";
import ToolResultMessageView from "./toolCallResultView";
import ToolCallMessageView from "./toolCallView";
import ErrorMessageView from "./errorMessageView";
/**
* Render and manage the chat messages list, mapping each message to its appropriate view, handling edits, removals, variable recomputation, and automatic scrolling during streaming.
*
* @returns A React element that renders the messages list and provides handlers for message changes, removals, tool submissions, and variable updates.
*/
export function MessagesView() {
const { messages, setMessages: onUpdateMessages, setVariables, isStreaming, supportsVision, handleSubmitToolResult } = usePromptContext();
const messagesEndRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length);
const prevLastIdRef = useRef(messages[messages.length - 1]?.id);
useEffect(() => {
const lastId = messages[messages.length - 1]?.id;
const grew = messages.length > prevLengthRef.current;
const lastChanged = lastId !== prevLastIdRef.current;
const shouldScroll = grew || (lastChanged && messages.length >= prevLengthRef.current);
prevLengthRef.current = messages.length;
prevLastIdRef.current = lastId;
if (shouldScroll) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages, isStreaming]);
const recomputeVariables = useCallback(
(msgs: Message[]) => {
const varNames = extractVariablesFromMessages(msgs);
setVariables((prev) => mergeVariables(prev, varNames));
},
[setVariables],
);
const handleMessageChange = useCallback(
(index: number, serialized: SerializedMessage) => {
const newMessages = [...messages];
newMessages[index] = Message.deserialize(serialized);
onUpdateMessages(newMessages);
recomputeVariables(newMessages);
},
[messages, onUpdateMessages, recomputeVariables],
);
const handleRemoveMessage = useCallback(
(index: number) => {
const newMessages = messages.filter((_, i) => i !== index);
const result = newMessages.length > 0 ? newMessages : [Message.system("")];
onUpdateMessages(result);
recomputeVariables(result);
},
[messages, onUpdateMessages, recomputeVariables],
);
const lastMessage = messages[messages.length - 1];
const isLastMessageStreaming = isStreaming && lastMessage?.type === MessageType.CompletionResult;
return (
<div className="space-y-1 px-1 py-4">
{messages.map((msg, index) => {
const isStreamingMsg = isLastMessageStreaming && index === messages.length - 1;
const canRemove = index > 0;
switch (msg.type) {
case MessageType.CompletionError:
return (
<ErrorMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
case MessageType.ToolResult:
return (
<ToolResultMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
case MessageType.CompletionResult:
if (msg.toolCalls) {
const respondedIds = new Set<string>();
for (let i = index + 1; i < messages.length; i++) {
const m = messages[i];
if (m.type === MessageType.ToolResult && m.toolCallId) {
respondedIds.add(m.toolCallId);
} else {
break;
}
}
return (
<ToolCallMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
onSubmitToolResult={(toolCallId, content) => handleSubmitToolResult(index, toolCallId, content)}
respondedToolCallIds={respondedIds}
/>
);
}
return (
<AssistantMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
isStreaming={isStreamingMsg}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
default: {
const role = msg.role;
if (role === "system") {
return (
<SystemMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
}
if (role === "user") {
return (
<UserMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
supportsVision={supportsVision}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
}
return (
<AssistantMessageView
key={msg.id}
message={msg}
disabled={isStreaming}
isStreaming={isStreamingMsg}
onChange={(s) => handleMessageChange(index, s)}
onRemove={canRemove ? () => handleRemoveMessage(index) : undefined}
/>
);
}
}
})}
<div ref={messagesEndRef} />
</div>
);
}

View File

@@ -0,0 +1,202 @@
import { CodeEditor } from "@/components/ui/codeEditor";
import { RichTextarea } from "@/components/ui/custom/richTextarea";
import { Markdown } from "@/components/ui/markdown";
import { Message, SerializedMessage } from "@/lib/message";
import { JINJA_VAR_HIGHLIGHT_PATTERNS, JINJA_VAR_REGEX } from "@/lib/message/constant";
import { isJson } from "@/lib/utils/validation";
import { PencilIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import MessageRoleSwitcher from "./messageRoleSwitcher";
/**
* Renders an editable system message block that supports role switching, rich-text editing, JSON editing with buffered changes, Jinja variable highlighting, and optional removal.
*
* @param message - The message model to display and edit.
* @param disabled - When true, disables interactions and makes the view read-only.
* @param onChange - Called with the message's serialized representation when the message is modified (role or content).
* @param onRemove - Optional callback invoked when the message should be removed.
* @returns The rendered system message JSX element.
*/
export function SystemMessageView({
message,
disabled,
onChange,
onRemove,
}: {
message: Message;
disabled?: boolean;
onChange: (serialized: SerializedMessage) => void;
onRemove?: () => void;
}) {
const [editMode, setEditMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const messageRef = useRef(message);
messageRef.current = message;
const pendingCursorRef = useRef<number | null>(null);
const content = message.content;
const isEmpty = !content;
const hasVariables = JINJA_VAR_REGEX.test(content);
JINJA_VAR_REGEX.lastIndex = 0;
const jsonBufferRef = useRef<string | null>(null);
const contentIsJson = useMemo(() => !isEmpty && isJson(content), [content, isEmpty]);
const formattedJson = useMemo(() => {
if (!contentIsJson) return "";
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}, [content, contentIsJson]);
const applyPendingJsonBuffer = (msg: Message): Message => {
if (jsonBufferRef.current !== null) {
const clone = msg.clone();
clone.content = jsonBufferRef.current;
jsonBufferRef.current = null;
return clone;
}
return msg;
};
const flushJsonBuffer = () => {
const updated = applyPendingJsonBuffer(messageRef.current);
if (updated !== messageRef.current) {
onChange(updated.serialized);
}
};
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setEditMode(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleRoleChange = (role: string) => {
const latest = applyPendingJsonBuffer(messageRef.current);
const clone = latest.clone();
clone.role = role as any;
onChange(clone.serialized);
};
const handleReadOnlyClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
if (disabled) return;
const target = e.target as HTMLTextAreaElement;
pendingCursorRef.current = target.selectionStart ?? 0;
setEditMode(true);
};
const handleEditFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
const pos = pendingCursorRef.current;
pendingCursorRef.current = null;
const target = e.target;
requestAnimationFrame(() => {
const cursorPos = pos ?? target.value.length;
target.selectionStart = cursorPos;
target.selectionEnd = cursorPos;
});
};
return (
<div
className="group hover:border-border focus-within:border-border rounded-sm border border-transparent px-3 py-2 transition-colors"
ref={containerRef}
>
<div className="mb-1 flex items-center">
<MessageRoleSwitcher role={message.role ?? ""} disabled={disabled} onRoleChange={handleRoleChange} />
<div className="ml-auto flex h-5 items-center gap-0.5">
{!disabled && (
<button
type="button"
aria-label="Edit message"
data-testid="system-msg-edit"
onClick={() => setEditMode(true)}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<PencilIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="system-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div
onClick={(e) => {
if (!disabled && !editMode && !(e.target as HTMLElement).closest("button, a, [role='button']")) setEditMode(true);
}}
className={!disabled && !editMode ? "cursor-text" : ""}
>
{editMode ? (
<RichTextarea
autoFocus
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
textAreaClassName="rounded-none p-0 border-none"
disabled={disabled}
onChange={(e) => {
const clone = message.clone();
clone.content = e.target.value;
onChange(clone.serialized);
}}
onFocus={handleEditFocus}
onBlur={() => {
if (content.trim().length > 0) setEditMode(false);
}}
highlightPatterns={JINJA_VAR_HIGHLIGHT_PATTERNS}
/>
) : isEmpty ? (
<div className="text-muted-foreground min-h-[20px] text-sm italic">Enter system message...</div>
) : contentIsJson ? (
<CodeEditor
wrap
code={formattedJson}
lang="json"
readonly={disabled}
autoResize
onChange={(value) => {
jsonBufferRef.current = value ?? "";
}}
options={{
showIndentLines: false,
disableHover: true,
}}
onBlur={flushJsonBuffer}
/>
) : hasVariables ? (
<RichTextarea
readOnly
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
textAreaClassName="rounded-none p-0 border-none cursor-text"
onClick={handleReadOnlyClick}
highlightPatterns={JINJA_VAR_HIGHLIGHT_PATTERNS}
/>
) : (
<div
className={!disabled ? "cursor-text" : undefined}
onClick={(e) => {
if (disabled || editMode) return;
if ((e.target as HTMLElement).closest("button, a, [role='button']")) return;
setEditMode(true);
}}
>
<Markdown content={content} className="text-muted-foreground" />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { Textarea } from "@/components/ui/textarea";
import { Message, MessageRole, SerializedMessage } from "@/lib/message";
import { isJson } from "@/lib/utils/validation";
import { CodeEditor } from "@/components/ui/codeEditor";
import { PencilIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import MessageRoleSwitcher from "./messageRoleSwitcher";
/**
* Renders an editable view for a tool result message that supports role switching, inline text editing, JSON-aware editing, and removal.
*
* The component presents the message role selector, optional tool call id, edit/delete actions, and a content area that:
* - shows a textarea for freeform editing when in edit mode,
* - shows a JSON-aware code editor (with edits buffered and flushed on blur) when the content is valid JSON,
* - shows a read-only monospaced display when content is plain text,
* - shows a placeholder when content is empty.
*
* @param message - The Message instance to display and edit; updates emitted via `onChange` are serialized from clones of this message.
* @param disabled - When true, disables interactive controls and makes editors read-only.
* @param onChange - Called with the message's serialized form whenever the message is modified (role, content edits, or flushed JSON buffer).
* @param onRemove - Optional callback invoked when the user requests deletion of the message.
*/
export default function ToolResultMessageView({
message,
disabled,
onChange,
onRemove,
}: {
message: Message;
disabled?: boolean;
onChange: (serialized: SerializedMessage) => void;
onRemove?: () => void;
}) {
const [editMode, setEditMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const messageRef = useRef(message);
messageRef.current = message;
const content = message.content;
const isEmpty = !content;
const jsonBufferRef = useRef<string | null>(null);
const contentIsJson = useMemo(() => !isEmpty && isJson(content), [content, isEmpty]);
const formattedJson = useMemo(() => {
if (!contentIsJson) return "";
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}, [content, contentIsJson]);
const applyPendingJsonBuffer = (msg: Message): Message => {
if (jsonBufferRef.current !== null) {
const clone = msg.clone();
clone.content = jsonBufferRef.current;
jsonBufferRef.current = null;
return clone;
}
return msg;
};
const flushJsonBuffer = () => {
const updated = applyPendingJsonBuffer(messageRef.current);
if (updated !== messageRef.current) {
onChange(updated.serialized);
}
};
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setEditMode(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleRoleChange = (role: string) => {
const latest = applyPendingJsonBuffer(messageRef.current);
const clone = latest.clone();
clone.role = role as any;
onChange(clone.serialized);
};
return (
<div
className="group hover:border-border focus-within:border-border rounded-sm border border-transparent px-3 py-2 transition-colors"
ref={containerRef}
>
<div className="mb-1 flex items-center">
<MessageRoleSwitcher role={message.role ?? MessageRole.ASSISTANT} disabled={disabled} onRoleChange={handleRoleChange} />
<div className="ml-auto flex h-5 items-center gap-0.5">
{message.toolCallId && (
<span className="text-muted-foreground ml-4 max-w-[200px] truncate font-mono text-xs">{message.toolCallId}</span>
)}
{!disabled && (
<button
type="button"
aria-label="Edit message"
data-testid="tool-result-msg-edit"
onClick={() => setEditMode(true)}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<PencilIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="tool-result-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div>
{editMode ? (
<Textarea
autoFocus
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 font-mono text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
disabled={disabled}
onChange={(e) => {
const clone = message.clone();
clone.content = e.target.value;
onChange(clone.serialized);
}}
onBlur={() => setEditMode(false)}
/>
) : isEmpty ? (
<div className="text-muted-foreground min-h-[20px] font-mono text-sm italic">Enter tool result...</div>
) : contentIsJson ? (
<CodeEditor
wrap
code={formattedJson}
lang="json"
readonly={disabled}
autoResize
onChange={(value) => {
jsonBufferRef.current = value ?? "";
}}
options={{
showIndentLines: false,
disableHover: true,
}}
onBlur={flushJsonBuffer}
/>
) : (
<div
className={!disabled ? "cursor-text" : undefined}
onClick={() => {
if (!disabled) setEditMode(true);
}}
>
<div className="text-muted-foreground min-h-[20px] font-mono text-sm whitespace-pre-wrap">{content}</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Message, SerializedMessage } from "@/lib/message";
import { isJson } from "@/lib/utils/validation";
import { CodeEditor } from "@/components/ui/codeEditor";
import { Wrench, XIcon } from "lucide-react";
import { useRef, useState } from "react";
import MessageRoleSwitcher from "./messageRoleSwitcher";
/**
* Renders a UI for viewing and editing tool-call entries on a message, including optional argument editing and submitting tool responses.
*
* The component displays each tool call's name, id, and arguments (JSON arguments open in an editable code editor). JSON edits are buffered locally and only committed to `onChange` when the editor loses focus or when the message role changes. The component also exposes controls for switching the message role, deleting the message, and entering/submitting a response for individual tool calls.
*
* @param message - Message instance containing zero or more toolCalls to render; edits are serialized via `onChange`.
* @param disabled - When true, disables interactive controls and makes editors read-only.
* @param onChange - Called with the message's serialized form after committed edits (e.g., buffered JSON arguments flushed or role changed).
* @param onRemove - If provided, called when the delete button is clicked.
* @param onSubmitToolResult - If provided, called with (toolCallId, content) when a user submits a response for a tool call.
* @param respondedToolCallIds - Optional set of toolCall ids that have already received responses; tool calls in this set hide the response UI.
*
* @returns The rendered React element for the tool-call message view.
*/
export default function ToolCallMessageView({
message,
disabled,
onChange,
onRemove,
onSubmitToolResult,
respondedToolCallIds,
}: {
message: Message;
disabled?: boolean;
onChange: (serialized: SerializedMessage) => void;
onRemove?: () => void;
onSubmitToolResult?: (toolCallId: string, content: string) => void;
respondedToolCallIds?: Set<string>;
}) {
const toolCalls = message.toolCalls ?? [];
const [responses, setResponses] = useState<Record<string, string>>({});
const messageRef = useRef(message);
messageRef.current = message;
const jsonBufferRef = useRef<Record<string, string>>({});
const applyPendingJsonBuffers = (msg: Message): Message => {
const keys = Object.keys(jsonBufferRef.current);
if (keys.length === 0) return msg;
const clone = msg.clone();
for (const toolCallId of keys) {
const tc = clone.toolCalls?.find((t) => t.id === toolCallId);
if (tc) {
tc.function.arguments = jsonBufferRef.current[toolCallId];
}
}
jsonBufferRef.current = {};
return clone;
};
const flushJsonBuffer = (toolCallId: string) => {
if (jsonBufferRef.current[toolCallId] !== undefined) {
const clone = messageRef.current.clone();
const tc = clone.toolCalls?.find((t) => t.id === toolCallId);
if (tc) {
tc.function.arguments = jsonBufferRef.current[toolCallId];
onChange(clone.serialized);
}
delete jsonBufferRef.current[toolCallId];
}
};
const handleRoleChange = (role: string) => {
const latest = applyPendingJsonBuffers(messageRef.current);
const clone = latest.clone();
clone.role = role as any;
onChange(clone.serialized);
};
const handleResponseChange = (toolCallId: string, value: string) => {
setResponses((prev) => ({ ...prev, [toolCallId]: value }));
};
const handleSubmitResponse = (toolCallId: string) => {
const content = responses[toolCallId]?.trim();
if (!content || !onSubmitToolResult) return;
onSubmitToolResult(toolCallId, content);
setResponses((prev) => {
const next = { ...prev };
delete next[toolCallId];
return next;
});
};
return (
<div className="group hover:border-border focus-within:border-border rounded-sm border border-transparent px-3 py-2 transition-colors">
<div className="mb-1 flex items-center">
<MessageRoleSwitcher role={message.role ?? ""} disabled={disabled} onRoleChange={handleRoleChange} />
<div className="ml-auto h-5">
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="tool-call-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div className="space-y-2">
{toolCalls.map((tc) => {
const argsIsJson = isJson(tc.function.arguments);
let formattedArgs = tc.function.arguments;
if (argsIsJson) {
try {
formattedArgs = JSON.stringify(JSON.parse(tc.function.arguments), null, 2);
} catch {
// keep raw string
}
}
return (
<div key={tc.id} className="bg-muted/50 mt-2 rounded-sm border px-3 py-2">
<div className="flex items-center gap-2">
<Wrench className="text-muted-foreground size-3 shrink-0" />
<span className="mr-4 shrink-0 font-mono text-xs font-medium">{tc.function.name}</span>
<span className="text-muted-foreground ml-auto truncate font-mono text-[10px]">{tc.id}</span>
</div>
{formattedArgs &&
(argsIsJson ? (
<div className="mt-2">
<CodeEditor
wrap
code={formattedArgs}
lang="json"
readonly={disabled}
autoResize
onChange={(value) => {
jsonBufferRef.current[tc.id] = value ?? "";
}}
options={{
showIndentLines: false,
disableHover: true,
}}
onBlur={() => flushJsonBuffer(tc.id)}
/>
</div>
) : (
<pre className="text-muted-foreground bg-card mt-2 overflow-x-auto rounded p-2 text-xs leading-relaxed">
{formattedArgs}
</pre>
))}
{!disabled && onSubmitToolResult && !respondedToolCallIds?.has(tc.id) && (
<div className="mt-2 border-t pt-2">
<div className="text-muted-foreground mb-1 text-[10px] font-semibold tracking-wide uppercase">Response</div>
<div className="flex items-end gap-2">
<Textarea
placeholder="Enter tool response..."
value={responses[tc.id] ?? ""}
onChange={(e) => handleResponseChange(tc.id, e.target.value)}
data-testid="tool-call-response-textarea"
className="min-h-[36px] resize-none font-mono text-xs"
rows={2}
/>
<Button
variant="secondary"
size="sm"
data-testid="tool-call-response-submit"
disabled={!responses[tc.id]?.trim()}
onClick={() => handleSubmitResponse(tc.id)}
>
Submit
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,322 @@
import { CodeEditor } from "@/components/ui/codeEditor";
import { RichTextarea } from "@/components/ui/custom/richTextarea";
import { Markdown } from "@/components/ui/markdown";
import { Message, SerializedMessage, type MessageContent } from "@/lib/message";
import { JINJA_VAR_HIGHLIGHT_PATTERNS, JINJA_VAR_REGEX } from "@/lib/message/constant";
import { isJson } from "@/lib/utils/validation";
import { Paperclip, PencilIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fileToAttachment } from "../../utils/attachment";
import { AttachmentDisplay } from "./attachmentViews";
import MessageRoleSwitcher from "./messageRoleSwitcher";
/**
* Render an interactive user message block that supports viewing and editing content, role switching, file attachments (via picker or drag-and-drop), and special handling for JSON and Jinja-variable content.
*
* @param message - The message model to render and edit; its updates are emitted via `onChange`.
* @param disabled - When true, disables editing and attachment interactions.
* @param supportsVision - When true, enables attaching files (images, audio, documents) and drag-and-drop attachments.
* @param onChange - Called with the message's serialized form whenever the message is modified (content, role, or attachments).
* @param onRemove - Optional callback invoked when the message's delete action is triggered.
* @returns The JSX element that renders the user message view and its interactive controls.
*/
export function UserMessageView({
message,
disabled,
supportsVision,
onChange,
onRemove,
}: {
message: Message;
disabled?: boolean;
supportsVision?: boolean;
onChange: (serialized: SerializedMessage) => void;
onRemove?: () => void;
}) {
const [editMode, setEditMode] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const messageRef = useRef(message);
messageRef.current = message;
const pendingCursorRef = useRef<number | null>(null);
const content = message.content;
const isEmpty = !content;
const messageAttachments = message.attachments;
const canAttach = supportsVision && !disabled;
const hasVariables = JINJA_VAR_REGEX.test(content);
JINJA_VAR_REGEX.lastIndex = 0;
const jsonBufferRef = useRef<string | null>(null);
const contentIsJson = useMemo(() => !isEmpty && isJson(content), [content, isEmpty]);
const formattedJson = useMemo(() => {
if (!contentIsJson) return "";
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}, [content, contentIsJson]);
const applyPendingJsonBuffer = (msg: Message): Message => {
if (jsonBufferRef.current !== null) {
const clone = msg.clone();
clone.content = jsonBufferRef.current;
jsonBufferRef.current = null;
return clone;
}
return msg;
};
const flushJsonBuffer = () => {
const updated = applyPendingJsonBuffer(messageRef.current);
if (updated !== messageRef.current) {
onChange(updated.serialized);
}
};
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setEditMode(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleRoleChange = (role: string) => {
const latest = applyPendingJsonBuffer(messageRef.current);
const clone = latest.clone();
clone.role = role as any;
onChange(clone.serialized);
};
const addAttachments = useCallback(
(newAttachments: MessageContent[]) => {
const latest = applyPendingJsonBuffer(messageRef.current);
const clone = latest.clone();
clone.attachments = [...latest.attachments, ...newAttachments];
onChange(clone.serialized);
},
[onChange],
);
const handleRemoveAttachment = useCallback(
(index: number) => {
const latest = applyPendingJsonBuffer(messageRef.current);
const clone = latest.clone();
clone.attachments = latest.attachments.filter((_, i) => i !== index);
onChange(clone.serialized);
},
[message, onChange],
);
const handleFileSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const attachments: MessageContent[] = [];
for (const file of Array.from(files)) {
const att = await fileToAttachment(file);
if (att) attachments.push(att);
}
if (attachments.length > 0) addAttachments(attachments);
e.target.value = "";
},
[addAttachments],
);
// Drag & drop state
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragging(false);
const files = e.dataTransfer.files;
if (!files || files.length === 0) return;
const attachments: MessageContent[] = [];
for (const file of Array.from(files)) {
const att = await fileToAttachment(file);
if (att) attachments.push(att);
}
if (attachments.length > 0) addAttachments(attachments);
},
[addAttachments],
);
const handleReadOnlyClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
if (disabled) return;
const target = e.target as HTMLTextAreaElement;
pendingCursorRef.current = target.selectionStart ?? 0;
setEditMode(true);
};
const handleEditFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
const pos = pendingCursorRef.current;
pendingCursorRef.current = null;
const target = e.target;
requestAnimationFrame(() => {
const cursorPos = pos ?? target.value.length;
target.selectionStart = cursorPos;
target.selectionEnd = cursorPos;
});
};
return (
<div
className="group hover:border-border focus-within:border-border relative rounded-sm border border-transparent px-3 py-2 transition-colors"
ref={containerRef}
{...(canAttach
? {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
}
: {})}
>
{canAttach && isDragging && (
<div className="bg-background/80 border-primary absolute inset-0 z-50 flex items-center justify-center rounded-sm border-2 border-dashed backdrop-blur-sm">
<div className="text-primary flex flex-col items-center gap-1">
<Paperclip className="h-5 w-5" />
<span className="text-xs font-medium">Drop files to attach</span>
</div>
</div>
)}
<div className="mb-1 flex items-center">
<MessageRoleSwitcher role={message.role ?? ""} disabled={disabled} onRoleChange={handleRoleChange} />
<div className="ml-auto flex h-5 items-center gap-0.5">
{canAttach && (
<>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,audio/*,.pdf,.txt,.csv,.json,.xml,.doc,.docx"
className="hidden"
onChange={handleFileSelect}
/>
<button
type="button"
aria-label="Attach file"
data-testid="user-msg-attach"
onClick={() => fileInputRef.current?.click()}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<Paperclip className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
</>
)}
{!disabled && (
<button
type="button"
aria-label="Edit message"
data-testid="user-msg-edit"
onClick={() => setEditMode(true)}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<PencilIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
{!disabled && onRemove && (
<button
type="button"
aria-label="Delete message"
data-testid="user-msg-delete"
onClick={onRemove}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus:opacity-100"
>
<XIcon className="text-muted-foreground hover:text-foreground size-3 shrink-0 cursor-pointer" />
</button>
)}
</div>
</div>
<div>
{editMode ? (
<RichTextarea
autoFocus
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
textAreaClassName="rounded-none p-0 border-none"
disabled={disabled}
onChange={(e) => {
const clone = message.clone();
clone.content = e.target.value;
onChange(clone.serialized);
}}
onFocus={handleEditFocus}
onBlur={() => {
if (content.trim().length > 0) setEditMode(false);
}}
highlightPatterns={JINJA_VAR_HIGHLIGHT_PATTERNS}
/>
) : isEmpty && messageAttachments.length === 0 ? (
<div className="text-muted-foreground min-h-[20px] text-sm italic">Enter user message...</div>
) : contentIsJson ? (
<CodeEditor
wrap
code={formattedJson}
lang="json"
readonly={disabled}
autoResize
onChange={(value) => {
jsonBufferRef.current = value ?? "";
}}
options={{
showIndentLines: false,
disableHover: true,
}}
onBlur={flushJsonBuffer}
/>
) : hasVariables ? (
<RichTextarea
readOnly
value={content}
className="text-muted-foreground min-h-[20px] resize-none rounded-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
textAreaClassName="rounded-none p-0 border-none cursor-text"
onClick={handleReadOnlyClick}
highlightPatterns={JINJA_VAR_HIGHLIGHT_PATTERNS}
/>
) : (
<div
className={!disabled ? "cursor-text" : undefined}
onClick={(e) => {
if (disabled || editMode) return;
if ((e.target as HTMLElement).closest("button, a, [role='button']")) return;
setEditMode(true);
}}
>
<Markdown content={content} className="text-muted-foreground" />
</div>
)}
</div>
{messageAttachments.length > 0 && (
<AttachmentDisplay attachments={messageAttachments} editable={canAttach} onRemoveAttachment={handleRemoveAttachment} />
)}
</div>
);
}

View File

@@ -0,0 +1,270 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Message, type MessageContent } from "@/lib/message";
import { Loader2, Paperclip, Play, Plus } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { usePromptContext } from "../context";
import { fileToAttachment } from "../utils/attachment";
import { AttachmentBadge } from "./messagesView/attachmentViews";
import MessageRoleSwitcher from "./messagesView/messageRoleSwitcher";
export function NewMessageInputView() {
const {
messages,
setMessages: onUpdateMessages,
handleSendMessage: onSendMessage,
isStreaming,
supportsVision,
provider,
model,
} = usePromptContext();
const [userInput, setUserInput] = useState("");
const [inputRole, setInputRole] = useState<string>("user");
const [attachments, setAttachments] = useState<MessageContent[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const userInputRef = useRef<HTMLTextAreaElement>(null);
const handleAddMessage = useCallback(() => {
if (isStreaming) return;
const input = userInput.trim();
const currentAttachments = attachments.length > 0 ? [...attachments] : undefined;
if (!input && !currentAttachments) return;
setUserInput("");
setAttachments([]);
let msg: Message;
if (inputRole === "user") {
msg = Message.request(input, 0, currentAttachments);
} else if (inputRole === "system") {
msg = Message.system(input);
} else {
msg = Message.response(input);
}
onUpdateMessages([...messages, msg]);
}, [userInput, attachments, isStreaming, inputRole, onUpdateMessages, messages]);
const canRun = !!(provider && model);
const handleRun = useCallback(async () => {
if (isStreaming || !provider || !model) return;
const input = userInput.trim();
const currentAttachments = attachments.length > 0 ? [...attachments] : undefined;
if (input || currentAttachments) {
setUserInput("");
setAttachments([]);
}
let pendingMessage: Message | undefined;
if (input || currentAttachments) {
if (inputRole === "system") {
pendingMessage = Message.system(input);
} else if (inputRole === "user") {
pendingMessage = Message.request(input, 0, currentAttachments);
} else {
pendingMessage = Message.response(input);
}
}
await onSendMessage(pendingMessage);
setTimeout(() => {
userInputRef.current?.focus();
}, 100);
}, [userInput, attachments, isStreaming, inputRole, onSendMessage, provider, model]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleRun();
}
},
[handleAddMessage, handleRun],
);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
for (const file of Array.from(files)) {
const attachment = await fileToAttachment(file);
if (attachment) {
setAttachments((prev) => [...prev, attachment]);
}
}
// Reset input so re-selecting the same file triggers onChange
e.target.value = "";
}, []);
const handleRemoveAttachment = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
}, []);
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
if (!supportsVision) return;
const items = e.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
const attachment = await fileToAttachment(file);
if (attachment) {
setAttachments((prev) => [...prev, attachment]);
}
}
}
}
},
[supportsVision],
);
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes("Files")) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragging(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragging(false);
const files = e.dataTransfer.files;
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
const attachment = await fileToAttachment(file);
if (attachment) {
setAttachments((prev) => [...prev, attachment]);
}
}
}, []);
return (
<div
className="group relative max-h-[500px] shrink-0 overflow-y-auto border-t px-4 py-2"
{...(supportsVision
? {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
}
: {})}
>
{supportsVision && isDragging && (
<div className="bg-background/80 border-primary absolute inset-0 z-50 flex items-center justify-center rounded-sm border-2 border-dashed backdrop-blur-sm">
<div className="text-primary flex flex-col items-center gap-1">
<Paperclip className="h-5 w-5" />
<span className="text-xs font-medium">Drop files to attach</span>
</div>
</div>
)}
<div className="mb-1 flex items-center">
<MessageRoleSwitcher
role={inputRole}
disabled={isStreaming}
onRoleChange={(role) => {
setInputRole(role);
if (role !== "user") setAttachments([]);
}}
restrictedRoles={["system", "tool"]}
/>
{supportsVision && inputRole === "user" && (
<div className="ml-auto">
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,audio/*,.pdf,.txt,.csv,.json,.xml,.doc,.docx"
className="hidden"
onChange={handleFileSelect}
/>
<button
type="button"
aria-label="Attach file"
data-testid="new-message-attach-file"
onClick={() => fileInputRef.current?.click()}
className="hover:bg-muted focus:bg-muted rounded-sm p-1"
>
<Paperclip className="text-muted-foreground hover:text-foreground h-3.5 w-3.5 shrink-0 cursor-pointer" />
</button>
</div>
)}
</div>
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att, index) => (
<AttachmentBadge key={index} attachment={att} onRemove={() => handleRemoveAttachment(index)} />
))}
</div>
)}
<div className="relative">
<Textarea
placeholder="Type a message..."
value={userInput}
ref={userInputRef}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
data-testid="new-message-textarea"
className="text-muted-foreground min-h-[60px] resize-none rounded-none border-0 bg-transparent p-0 pr-16 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent"
disabled={isStreaming}
/>
<div className="absolute right-0 bottom-0 flex items-center gap-1">
<Button
onClick={handleAddMessage}
disabled={isStreaming}
variant={"ghost"}
data-testid="new-message-add"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 rounded px-1.5 py-1 text-xs disabled:pointer-events-none disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" />
Add
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleRun}
disabled={isStreaming || !canRun}
variant={"ghost"}
data-testid="new-message-run"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 rounded px-1.5 py-1 text-xs disabled:pointer-events-none disabled:opacity-50"
>
{isStreaming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
Run
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{!canRun ? <span>Select a provider and model to run</span> : <span>Run prompt</span>}
<kbd className="bg-primary-foreground/20 ml-1.5 rounded px-1 py-0.5 font-mono text-[10px]"></kbd>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,369 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdownMenu";
import { Input } from "@/components/ui/input";
import { SplitButton } from "@/components/ui/splitButton";
import { Message, MessageRole } from "@/lib/message";
import { getErrorMessage } from "@/lib/store";
import { useCreateSessionMutation, useGetSessionsQuery, useGetVersionsQuery, useRenameSessionMutation } from "@/lib/store/apis/promptsApi";
import { ModelParams, PromptSession } from "@/lib/types/prompts";
import { cn } from "@/lib/utils";
import { Check, GitCommit, PencilIcon, Save, Trash2 } from "lucide-react";
import { parseAsInteger, useQueryStates } from "nuqs";
import { useCallback, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { toast } from "sonner";
import { usePromptContext } from "../context";
export default function PromptsViewHeader() {
const {
selectedPrompt,
messages,
setMessages: onMessagesChange,
setCommitSheet,
apiKeyId,
modelParams,
provider,
model,
variables,
hasChanges,
hasVersionChanges,
hasSessionChanges,
isStreaming,
canUpdate,
} = usePromptContext();
const [sessionsOpen, setSessionsOpen] = useState(false);
const onSessionSaved = useCallback(
(session: PromptSession) => {
setCommitSheet({ open: true, session });
},
[setCommitSheet],
);
// UI state — persisted in URL query params
const [{ sessionId: selectedSessionId, versionId: selectedVersionId }, setUrlState] = useQueryStates(
{
sessionId: parseAsInteger,
versionId: parseAsInteger,
},
{ history: "replace" },
);
// Fetch versions and sessions for selected prompt
const { data: versionsData } = useGetVersionsQuery(selectedPrompt?.id ?? "", { skip: !selectedPrompt?.id });
const { data: sessionsData } = useGetSessionsQuery(selectedPrompt?.id ?? "", { skip: !selectedPrompt?.id });
// Mutations
const [createSession, { isLoading: isCreatingSession }] = useCreateSessionMutation();
const [renameSession] = useRenameSessionMutation();
const versions = versionsData?.versions ?? [];
const sessions = sessionsData?.sessions ?? [];
const handleSelectVersion = useCallback(
(versionId: number) => {
setUrlState({ versionId, sessionId: null });
},
[setUrlState],
);
// Build model_params with api_key_id for persistence
const buildSaveParams = useCallback((): ModelParams => {
const params = { ...modelParams };
if (apiKeyId && apiKeyId !== "__auto__") {
params.api_key_id = apiKeyId;
}
return params;
}, [modelParams, apiKeyId]);
const handleSaveSession = useCallback(async () => {
if (!selectedPrompt || !hasChanges || isStreaming) return;
try {
const result = await createSession({
promptId: selectedPrompt.id,
data: {
messages: Message.serializeAll(messages),
model_params: buildSaveParams(),
provider,
model,
variables: Object.keys(variables).length > 0 ? variables : undefined,
},
}).unwrap();
setUrlState({ sessionId: result.session.id, versionId: null });
toast.success("Session saved");
} catch (err) {
toast.error("Failed to save session", { description: getErrorMessage(err) });
}
}, [selectedPrompt?.id, messages, buildSaveParams, provider, model, variables, createSession, setUrlState, hasChanges, isStreaming]);
// Cmd+S / Ctrl+S to save session
useHotkeys(
"mod+s",
() => handleSaveSession(),
{
preventDefault: true,
enableOnFormTags: ["input", "textarea", "select"],
enabled: !!selectedPrompt && !isCreatingSession && !isStreaming,
},
[handleSaveSession, selectedPrompt, isCreatingSession, isStreaming],
);
const handleCommitVersion = useCallback(async () => {
if (!selectedPrompt) return;
if (!hasChanges) {
const selectedSession = sessions.find((s) => s.id === selectedSessionId);
if (selectedSession) {
onSessionSaved(selectedSession);
}
return;
}
try {
// Always create a new session with current state before committing
const result = await createSession({
promptId: selectedPrompt.id,
data: {
messages: Message.serializeAll(messages),
model_params: buildSaveParams(),
provider,
model,
variables: Object.keys(variables).length > 0 ? variables : undefined,
},
}).unwrap();
setUrlState({ sessionId: result.session.id, versionId: null });
onSessionSaved(result.session);
} catch (err) {
toast.error("Failed to save session", { description: getErrorMessage(err) });
}
}, [selectedPrompt?.id, messages, buildSaveParams, provider, model, variables, createSession, setUrlState, onSessionSaved, hasChanges]);
const handleRenameSession = useCallback(
async (sessionId: number, name: string) => {
if (!selectedPrompt) return;
try {
await renameSession({ id: sessionId, promptId: selectedPrompt.id, data: { name } }).unwrap();
} catch (err) {
toast.error("Failed to rename session", { description: getErrorMessage(err) });
}
},
[selectedPrompt?.id, renameSession],
);
const handleClearConversation = useCallback(() => {
const firstMsg = messages[0];
if (firstMsg?.role === MessageRole.SYSTEM) {
onMessagesChange([firstMsg]);
} else {
onMessagesChange([Message.system("")]);
}
}, [messages]);
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
const latestVersion = versions.find((v) => v.is_latest);
const displayVersion = selectedVersion ?? latestVersion;
return (
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate font-semibold">
{selectedPrompt?.name || "Playground"}
{hasChanges && <span className="text-destructive ml-1">*</span>}
</h3>
{displayVersion && <Badge variant={"secondary"}>v{displayVersion.version_number}</Badge>}
{hasVersionChanges && versions.length > 0 && <Badge variant="outline">Unpublished Changes</Badge>}
</div>
<div className="flex shrink-0 items-center gap-4">
{messages.length > 1 && (
<Button variant="ghost" size="sm" data-testid="header-clear" onClick={handleClearConversation} disabled={isStreaming}>
<Trash2 className="h-4 w-4" />
Clear
</Button>
)}
<SplitButton
onClick={handleSaveSession}
disabled={isCreatingSession || isStreaming}
isLoading={isCreatingSession}
dropdownContent={{
className: "w-72 p-0",
open: sessionsOpen,
onOpenChange: setSessionsOpen,
children: (
<Command>
<CommandInput placeholder="Search sessions..." data-testid="header-sessions-search" />
<CommandList>
<CommandEmpty>No sessions found.</CommandEmpty>
<CommandGroup>
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
isSelected={selectedSessionId === session.id}
onSelect={() => {
setUrlState({ sessionId: session.id, versionId: null });
setSessionsOpen(false);
}}
onRename={(name) => handleRenameSession(session.id, name)}
/>
))}
</CommandGroup>
</CommandList>
</Command>
),
}}
variant={"outline"}
dropdownTrigger={{
className: cn("bg-transparent"),
}}
button={{
dataTestId: "header-save-session",
className: "bg-transparent disabled:opacity-100 disabled:text-muted-foreground",
disabled: !hasChanges || !canUpdate,
}}
>
<Save className="h-4 w-4" />
Save Session
</SplitButton>
<SplitButton
onClick={handleCommitVersion}
disabled={isCreatingSession || isStreaming}
dropdownContent={{
className: "w-64 max-h-72 overflow-y-auto",
children: (
<>
<DropdownMenuLabel>Versions</DropdownMenuLabel>
<DropdownMenuSeparator />
{versions.length === 0 ? (
<div className="text-muted-foreground px-2 py-3 text-center text-sm">No versions yet</div>
) : (
versions.map((version) => (
<DropdownMenuItem
key={version.id}
onClick={() => handleSelectVersion(version.id)}
className="flex items-center justify-between gap-2"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm">
v{version.version_number}
{version.is_latest && <span className="text-primary ml-1.5 text-xs">(latest)</span>}
</span>
<span className="text-muted-foreground truncate text-xs">{version.commit_message || "No commit message"}</span>
<span className="text-muted-foreground text-xs">{formatSessionDate(version.created_at)}</span>
</div>
{selectedVersionId === version.id && <Check className="text-primary h-4 w-4 shrink-0" />}
</DropdownMenuItem>
))
)}
</>
),
}}
variant={"outline"}
dropdownTrigger={{
className: cn("bg-transparent"),
}}
button={{
dataTestId: "header-commit-version",
className: "bg-transparent disabled:opacity-100 disabled:text-muted-foreground",
disabled: !hasVersionChanges || !canUpdate,
}}
>
<GitCommit className="h-4 w-4" />
Commit Version
</SplitButton>
</div>
</div>
);
}
function formatSessionDate(dateStr: string): string {
const date = new Date(dateStr);
const month = date.toLocaleString("en-US", { month: "short" });
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, "0");
const ampm = hours >= 12 ? "pm" : "am";
const displayHours = (hours % 12 || 12).toString().padStart(2, "0");
return `${month} ${day}, ${displayHours}:${minutes}${ampm}`;
}
function SessionItem({
session,
isSelected,
onSelect,
onRename,
}: {
session: PromptSession;
isSelected: boolean;
onSelect: () => void;
onRename: (name: string) => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleRenameSubmit = () => {
const newName = inputRef.current?.value.trim() ?? "";
if (!newName || newName === session.name) {
setIsEditing(false);
return;
}
onRename(newName);
setIsEditing(false);
};
const dateLabel = formatSessionDate(session.created_at);
if (isEditing) {
return (
<div className="flex items-center gap-2 rounded-sm px-2 py-1.5" onKeyDown={(e) => e.stopPropagation()}>
<Input
ref={inputRef}
defaultValue={session.name}
placeholder="Session name"
className="h-auto border-none bg-transparent p-0 text-sm shadow-none focus-visible:border-none focus-visible:ring-0"
data-testid="session-rename-input"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameSubmit();
if (e.key === "Escape") setIsEditing(false);
}}
onBlur={handleRenameSubmit}
/>
</div>
);
}
return (
<CommandItem
value={`${session.id}-${dateLabel}-${session.name}`}
onSelect={onSelect}
className="group/item flex items-center justify-between gap-2 py-1"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm">
<span className="text-muted-foreground">{dateLabel}</span>
{session.name && <span className="ml-1.5">{session.name}</span>}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
aria-label="Rename session"
data-testid="session-rename"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsEditing(true);
}}
className="hover:bg-muted focus:bg-muted rounded-sm p-1 opacity-0 transition-opacity group-hover/item:opacity-100 focus:opacity-100"
>
<PencilIcon className="text-muted-foreground hover:text-foreground h-3.5 w-3.5 cursor-pointer" />
</button>
{isSelected && <Check className="text-primary h-4 w-4" />}
</div>
</CommandItem>
);
}

View File

@@ -0,0 +1,42 @@
import { usePromptContext } from "../context";
import { FolderSheet } from "../sheets/folderSheet";
import { PromptSheet } from "../sheets/promptSheet";
import { CommitVersionSheet } from "../sheets/commitVersionSheet";
export function PromptSheets() {
const { folderSheet, setFolderSheet, promptSheet, setPromptSheet, commitSheet, setCommitSheet, setUrlState } = usePromptContext();
return (
<>
<FolderSheet
open={folderSheet.open}
onOpenChange={(open) => setFolderSheet({ ...folderSheet, open })}
folder={folderSheet.folder}
onSaved={() => {}}
/>
<PromptSheet
open={promptSheet.open}
onOpenChange={(open) => setPromptSheet({ ...promptSheet, open })}
prompt={promptSheet.prompt}
folderId={promptSheet.folderId}
onSaved={(newPromptId) => {
if (newPromptId) {
setUrlState({ promptId: newPromptId, sessionId: null, versionId: null });
}
}}
/>
{commitSheet.session && (
<CommitVersionSheet
open={commitSheet.open}
onOpenChange={(open) => setCommitSheet({ ...commitSheet, open })}
session={commitSheet.session}
onCommitted={(versionId) => {
setUrlState({ versionId, sessionId: null });
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,58 @@
import { AutoSizeTextarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import type { VariableMap } from "@/lib/message";
import { useCallback, useMemo } from "react";
export function VariablesTableView({
variables,
onChange,
}: {
variables: VariableMap;
onChange: React.Dispatch<React.SetStateAction<VariableMap>>;
}) {
const entries = useMemo(() => Object.entries(variables).sort(([a], [b]) => a.localeCompare(b)), [variables]);
const handleValueChange = useCallback(
(name: string, value: string) => {
onChange((prev) => ({ ...prev, [name]: value }));
},
[onChange],
);
return (
<div className="flex flex-col gap-3">
<Label className="text-muted-foreground text-xs font-medium uppercase">Variables</Label>
<p className="text-muted-foreground text-xs">
Detected from <code className="bg-muted rounded px-1">{"{{ }}"}</code> syntax in messages. Values are substituted at runtime.
</p>
<div className="border-border overflow-hidden rounded-sm border">
<table className="w-full table-fixed text-sm">
<thead>
<tr className="bg-muted/50 border-border border-b">
<th className="text-muted-foreground w-[40%] max-w-[40%] px-3 py-1.5 text-left text-xs font-medium">Variable</th>
<th className="text-muted-foreground px-3 py-1.5 text-left text-xs font-medium">Value</th>
</tr>
</thead>
<tbody>
{entries.map(([name, value]) => (
<tr key={name} className="border-border border-b last:border-b-0">
<td className="w-[40%] max-w-[40%] px-3 py-1.5 align-top">
<span className="block truncate pt-1 text-xs">{name}</span>
</td>
<td className="py-1">
<AutoSizeTextarea
value={value}
onChange={(e) => handleValueChange(name, e.target.value)}
placeholder={"value"}
minRows={1}
className="min-h-0 w-full resize-none border-none bg-transparent px-3 py-1 text-xs shadow-none outline-none focus-visible:ring-0"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,623 @@
import { extractVariablesFromMessages, mergeVariables, Message, MessageRole, MessageType, type VariableMap } from "@/lib/message";
import { getErrorMessage } from "@/lib/store";
import {
useDeleteFolderMutation,
useDeletePromptMutation,
useGetFoldersQuery,
useGetPromptsQuery,
useGetPromptVersionQuery,
useGetSessionsQuery,
useUpdatePromptMutation,
} from "@/lib/store/apis/promptsApi";
import { useGetModelParametersQuery } from "@/lib/store/apis/providersApi";
import { Folder, ModelParams, Prompt, PromptSession, PromptVersion } from "@/lib/types/prompts";
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { executePrompt } from "./utils/executor";
interface PromptContextValue {
// Data
folders: Folder[];
prompts: Prompt[];
selectedPrompt?: Prompt;
sessions: PromptSession[];
selectedSession?: PromptSession;
selectedVersion?: PromptVersion;
// Loading states
foldersLoading: boolean;
promptsLoading: boolean;
foldersError: unknown;
promptsError: unknown;
isLoadingPlayground: boolean;
isStreaming: boolean;
// URL state
selectedPromptId: string | null;
selectedSessionId: number | null;
selectedVersionId: number | null;
setUrlState: (state: { promptId?: string | null; sessionId?: number | null; versionId?: number | null }) => void;
// Playground state
messages: Message[];
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
provider: string;
setProvider: React.Dispatch<React.SetStateAction<string>>;
model: string;
setModel: React.Dispatch<React.SetStateAction<string>>;
modelParams: ModelParams;
setModelParams: React.Dispatch<React.SetStateAction<ModelParams>>;
apiKeyId: string;
setApiKeyId: React.Dispatch<React.SetStateAction<string>>;
// Jinja2 variables
variables: VariableMap;
setVariables: React.Dispatch<React.SetStateAction<VariableMap>>;
// Sheet states
folderSheet: { open: boolean; folder?: Folder };
setFolderSheet: React.Dispatch<React.SetStateAction<{ open: boolean; folder?: Folder }>>;
promptSheet: { open: boolean; prompt?: Prompt; folderId?: string };
setPromptSheet: React.Dispatch<React.SetStateAction<{ open: boolean; prompt?: Prompt; folderId?: string }>>;
commitSheet: { open: boolean; session?: PromptSession };
setCommitSheet: React.Dispatch<React.SetStateAction<{ open: boolean; session?: PromptSession }>>;
// Delete dialog states
deleteFolderDialog: { open: boolean; folder?: Folder };
setDeleteFolderDialog: React.Dispatch<React.SetStateAction<{ open: boolean; folder?: Folder }>>;
deletePromptDialog: { open: boolean; prompt?: Prompt };
setDeletePromptDialog: React.Dispatch<React.SetStateAction<{ open: boolean; prompt?: Prompt }>>;
// Mutation loading states
isDeletingFolder: boolean;
isDeletingPrompt: boolean;
// Model capabilities
supportsVision: boolean;
// Diff detection
hasChanges: boolean;
hasVersionChanges: boolean;
hasSessionChanges: boolean;
// Handlers
handleSelectPrompt: (id: string) => void;
handleMovePrompt: (promptId: string, folderId: string | null) => Promise<void>;
handleDeleteFolder: () => Promise<void>;
handleDeletePrompt: () => Promise<void>;
handleSendMessage: (pendingMessage?: Message) => Promise<void>;
handleSubmitToolResult: (afterIndex: number, toolCallId: string, content: string) => Promise<void>;
// RBAC permissions
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
}
const PromptContext = createContext<PromptContextValue | null>(null);
export function usePromptContext() {
const context = useContext(PromptContext);
if (!context) {
throw new Error("usePromptContext must be used within a PromptProvider");
}
return context;
}
export function PromptProvider({ children }: { children: ReactNode }) {
// RBAC permissions
const canCreate = useRbac(RbacResource.PromptRepository, RbacOperation.Create);
const canUpdate = useRbac(RbacResource.PromptRepository, RbacOperation.Update);
const canDelete = useRbac(RbacResource.PromptRepository, RbacOperation.Delete);
// API queries
const { data: foldersData, isLoading: foldersLoading, error: foldersError } = useGetFoldersQuery();
const { data: promptsData, isLoading: promptsLoading, error: promptsError } = useGetPromptsQuery();
// Mutations
const [deleteFolder, { isLoading: isDeletingFolder }] = useDeleteFolderMutation();
const [deletePrompt, { isLoading: isDeletingPrompt }] = useDeletePromptMutation();
const [updatePrompt] = useUpdatePromptMutation();
// UI state — persisted in URL query params
const [{ promptId: selectedPromptId, sessionId: selectedSessionId, versionId: selectedVersionId }, setUrlState] = useQueryStates(
{
promptId: parseAsString,
sessionId: parseAsInteger,
versionId: parseAsInteger,
},
{ history: "replace" },
);
// Sheet states
const [folderSheet, setFolderSheet] = useState<{ open: boolean; folder?: Folder }>({ open: false });
const [promptSheet, setPromptSheet] = useState<{ open: boolean; prompt?: Prompt; folderId?: string }>({ open: false });
const [commitSheet, setCommitSheet] = useState<{ open: boolean; session?: PromptSession }>({ open: false });
// Delete dialog states
const [deleteFolderDialog, setDeleteFolderDialog] = useState<{ open: boolean; folder?: Folder }>({ open: false });
const [deletePromptDialog, setDeletePromptDialog] = useState<{ open: boolean; prompt?: Prompt }>({ open: false });
// Playground state
const [messages, setMessagesRaw] = useState<Message[]>([Message.system("")]);
const setMessages = useCallback<React.Dispatch<React.SetStateAction<Message[]>>>((action) => {
setMessagesRaw((prev) => {
const next = typeof action === "function" ? action(prev) : action;
return next.map((msg, i) => msg.withIndex(i));
});
}, []);
const [provider, setProvider] = useState("");
const [model, setModel] = useState("");
const [modelParams, setModelParams] = useState<ModelParams>({ stream: true });
const [apiKeyId, setApiKeyId] = useState("__auto__");
const [isStreaming, setIsStreaming] = useState(false);
const activeRunRef = useRef<symbol | null>(null);
const [variables, setVariables] = useState<VariableMap>({});
// Fetch model datasheet for capabilities
const { data: datasheetData } = useGetModelParametersQuery(model, { skip: !model });
const supportsVision = datasheetData?.supports_vision ?? false;
// Derived data
const folders = useMemo(() => foldersData?.folders ?? [], [foldersData]);
const prompts = useMemo(() => promptsData?.prompts ?? [], [promptsData]);
const selectedPrompt = useMemo(() => prompts.find((p) => p.id === selectedPromptId), [prompts, selectedPromptId]);
// Fetch versions and sessions for selected prompt
const { data: sessionsData, isLoading: isSessionsLoading } = useGetSessionsQuery(selectedPromptId ?? "", { skip: !selectedPromptId });
// Filter sessions to current prompt — RTK Query may briefly return stale cached data from the previous prompt
const sessions = useMemo(() => {
const all = sessionsData?.sessions ?? [];
if (!selectedPromptId) return [];
return all.filter((s) => s.prompt_id === selectedPromptId);
}, [sessionsData, selectedPromptId]);
const selectedSession = useMemo(() => sessions.find((s) => s.id === selectedSessionId), [sessions, selectedSessionId]);
// Fetch full version data (with messages) when a version is selected
const {
currentData: selectedVersionData,
isLoading: isVersionLoading,
isFetching: isVersionFetching,
} = useGetPromptVersionQuery(selectedVersionId ?? 0, {
skip: !selectedVersionId,
});
const selectedVersion = selectedVersionData?.version;
// Show loader only on initial fetch, not on cache refetches (avoids flicker on save)
const isLoadingPlayground = !!(
selectedPromptId &&
(isSessionsLoading ||
(selectedVersionId && isVersionLoading) ||
// Sessions loaded but auto-select hasn't happened yet
(sessions.length > 0 && !selectedSessionId && !selectedVersionId))
);
// Load session or version data when selection changes
useEffect(() => {
// Don't reset state while waiting for data that hasn't arrived yet
if (selectedSessionId && !selectedSession) return;
if (selectedVersionId && !selectedVersion) return;
const loadFromParams = (params: ModelParams, prov: string, mod: string) => {
const { api_key_id, ...rest } = params || ({} as ModelParams);
setModelParams({ stream: true, ...rest });
setApiKeyId(api_key_id || "__auto__");
setProvider(prov || "");
setModel(mod || "");
};
const loadMessages = (msgs: Message[]) => {
setMessages(msgs);
const varNames = extractVariablesFromMessages(msgs);
setVariables((prev) => mergeVariables(prev, varNames));
};
if (selectedSession) {
const raw = (selectedSession.messages ?? []).map((m) => m.message);
const loaded = Message.fromLegacyAll(raw);
loadMessages(loaded.length > 0 ? loaded : [Message.system("")]);
loadFromParams(selectedSession.model_params, selectedSession.provider, selectedSession.model);
// Restore variables (key:value) from session
if (selectedSession.variables && Object.keys(selectedSession.variables).length > 0) {
setVariables(selectedSession.variables);
}
} else if (selectedVersion) {
// If sessions are still loading and no session is explicitly selected,
// wait — a session may auto-select and take priority
if (isSessionsLoading && !selectedSessionId) return;
const raw = (selectedVersion.messages ?? []).map((m) => m.message);
const loaded = Message.fromLegacyAll(raw);
loadMessages(loaded.length > 0 ? loaded : [Message.system("")]);
loadFromParams(selectedVersion.model_params, selectedVersion.provider, selectedVersion.model);
// Initialize variables from version (keys with empty values)
if (selectedVersion.variables && Object.keys(selectedVersion.variables).length > 0) {
setVariables((prev) => mergeVariables(prev, Object.keys(selectedVersion.variables!)));
}
} else if (selectedPrompt?.latest_version) {
// Only fall back to latest_version after sessions have settled
// to avoid racing with the session auto-select effect
if (isSessionsLoading) return;
const version = selectedPrompt.latest_version;
const raw = (version.messages ?? []).map((m) => m.message);
const loaded = Message.fromLegacyAll(raw);
loadMessages(loaded.length > 0 ? loaded : [Message.system("")]);
loadFromParams(version.model_params, version.provider, version.model);
// Initialize variables from version (keys with empty values)
if (version.variables && Object.keys(version.variables).length > 0) {
setVariables((prev) => mergeVariables(prev, Object.keys(version.variables!)));
}
if (sessions.length === 0) {
setUrlState({ versionId: version.id });
}
} else {
setMessages([Message.system("")]);
setProvider("");
setModel("");
setModelParams({ stream: true });
setApiKeyId("__auto__");
}
}, [
selectedSession,
selectedVersion,
selectedPrompt,
selectedSessionId,
selectedVersionId,
setUrlState,
isSessionsLoading,
sessions.length,
]);
// Auto-select the most recent session when sessions load and none is selected
// Sessions take priority over versions for initial loading
useEffect(() => {
if (sessions.length > 0 && !selectedSessionId && !selectedVersionId) {
setUrlState({ sessionId: sessions[0].id });
}
}, [selectedPromptId, sessions, selectedSessionId, selectedVersionId, setUrlState]);
// Diff detection helper — compares current playground state against a reference config
const diffAgainst = useCallback(
(ref: { messages?: any[]; model_params?: ModelParams; provider?: string; model?: string } | undefined) => {
if (!ref) return true; // No reference — treat as changed
const refMessages = ref.messages ?? [];
const refProvider = ref.provider;
const refModel = ref.model;
const refParams = ref.model_params;
if (provider !== refProvider) return true;
if (model !== refModel) return true;
const { api_key_id: refApiKeyId, ...refParamsRest } = refParams || ({} as ModelParams);
const currentApiKeyId = apiKeyId !== "__auto__" ? apiKeyId : undefined;
if (currentApiKeyId !== (refApiKeyId || undefined)) return true;
// Normalize: treat missing stream as stream: true so legacy params without stream don't appear changed
const normalizeParams = (p: ModelParams): ModelParams => {
const { stream = true, ...rest } = p;
return { stream, ...rest };
};
const normalizedCurrent = normalizeParams(modelParams);
const normalizedRef = normalizeParams(refParamsRest);
if (
JSON.stringify(normalizedCurrent, Object.keys(normalizedCurrent).sort()) !==
JSON.stringify(normalizedRef, Object.keys(normalizedRef).sort())
)
return true;
const currentSerialized = Message.serializeAll(messages);
if (JSON.stringify(currentSerialized) !== JSON.stringify(refMessages)) return true;
return false;
},
[provider, model, modelParams, apiKeyId, messages],
);
// Diff detection — compare current playground state against the loaded session/version
const hasChanges = useMemo(() => {
// Suppress diff while version data is in flight to avoid flicker
if (selectedVersionId && (isVersionFetching || selectedVersion?.id !== selectedVersionId)) return false;
if (selectedSession) {
return diffAgainst({
messages: selectedSession.messages?.map((m) => m.message) ?? [],
model_params: selectedSession.model_params,
provider: selectedSession.provider,
model: selectedSession.model,
});
}
if (selectedVersion) {
return diffAgainst({
messages: selectedVersion.messages?.map((m) => m.message) ?? [],
model_params: selectedVersion.model_params,
provider: selectedVersion.provider,
model: selectedVersion.model,
});
}
return true;
}, [selectedSession, selectedVersion, diffAgainst, selectedVersionId, isVersionFetching]);
// Diff against the active version — drives "unpublished changes" badge & commit button
// Uses the explicitly selected version if available, otherwise falls back to latest_version
const activeVersionRef = selectedVersion ?? selectedPrompt?.latest_version;
const hasVersionChanges = useMemo(() => {
// Suppress diff while version data is in flight or mismatched to avoid flash
if (selectedVersionId && (isVersionFetching || selectedVersion?.id !== selectedVersionId)) return false;
if (!activeVersionRef) return true; // No versions yet — always allow commit
return diffAgainst({
messages: activeVersionRef.messages?.map((m) => m.message) ?? [],
model_params: activeVersionRef.model_params,
provider: activeVersionRef.provider,
model: activeVersionRef.model,
});
}, [activeVersionRef, diffAgainst, selectedVersionId, isVersionFetching, selectedVersion?.id]);
// Diff against the selected session — drives red asterisk indicator
const hasSessionChanges = useMemo(() => {
if (!selectedSession) return false;
return diffAgainst({
messages: selectedSession.messages?.map((m) => m.message) ?? [],
model_params: selectedSession.model_params,
provider: selectedSession.provider,
model: selectedSession.model,
});
}, [selectedSession, diffAgainst]);
// Handlers
const handleSelectPrompt = useCallback(
(id: string) => {
setMessages([Message.system("")]);
setProvider("");
setModel("");
setModelParams({ stream: true });
setApiKeyId("__auto__");
setUrlState({ promptId: id, sessionId: null, versionId: null });
},
[setUrlState],
);
const handleMovePrompt = useCallback(
async (promptId: string, folderId: string | null) => {
try {
await updatePrompt({ id: promptId, data: { folder_id: folderId } }).unwrap();
toast.success("Prompt moved successfully");
} catch (err) {
toast.error(getErrorMessage(err) || "Failed to move prompt");
}
},
[updatePrompt],
);
const handleDeleteFolder = useCallback(async () => {
if (!deleteFolderDialog.folder) return;
try {
await deleteFolder(deleteFolderDialog.folder.id).unwrap();
toast.success("Folder deleted");
setDeleteFolderDialog({ open: false });
if (selectedPrompt?.folder_id === deleteFolderDialog.folder.id) {
setUrlState({ promptId: null, sessionId: null, versionId: null });
}
} catch (err) {
toast.error("Failed to delete folder", { description: getErrorMessage(err) });
}
}, [deleteFolderDialog.folder, deleteFolder, selectedPrompt, setUrlState]);
const handleDeletePrompt = useCallback(async () => {
if (!deletePromptDialog.prompt) return;
try {
await deletePrompt(deletePromptDialog.prompt.id).unwrap();
toast.success("Prompt deleted");
setDeletePromptDialog({ open: false });
if (selectedPromptId === deletePromptDialog.prompt.id) {
setUrlState({ promptId: null, sessionId: null, versionId: null });
}
} catch (err) {
toast.error("Failed to delete prompt", { description: getErrorMessage(err) });
}
}, [deletePromptDialog.prompt, deletePrompt, selectedPromptId, setUrlState]);
const handleSendMessage = useCallback(
async (pendingMessage?: Message) => {
const runToken = Symbol();
activeRunRef.current = runToken;
const isActive = () => activeRunRef.current === runToken;
setIsStreaming(true);
await executePrompt(
messages,
pendingMessage,
{ provider, model, modelParams, apiKeyId, variables },
{
onStreamingStart: (allMessages, placeholder) => {
if (!isActive()) return;
setMessages([...allMessages, placeholder]);
},
onStreamChunk: (content) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
const clone = last.clone();
clone.content = content;
updated[updated.length - 1] = clone;
return updated;
});
},
onComplete: (content, usage) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = Message.response(content, 0, usage);
return updated;
});
},
onToolCallComplete: (content, toolCalls, usage) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = Message.toolCallResponse(content, toolCalls, 0, usage);
return updated;
});
},
onEmptyResponse: () => {
if (!isActive()) return;
setMessages((prev) => prev.slice(0, -1));
},
onError: (error) => {
if (!isActive()) return;
setMessages((prev) => {
const withoutPlaceholder = prev.slice(0, -1);
return [...withoutPlaceholder, Message.error(error)];
});
},
onFinally: () => {
if (!isActive()) return;
setIsStreaming(false);
},
},
);
},
[messages, provider, model, modelParams, apiKeyId, variables],
);
const handleSubmitToolResult = useCallback(
async (afterIndex: number, toolCallId: string, content: string) => {
const runToken = Symbol();
activeRunRef.current = runToken;
const isActive = () => activeRunRef.current === runToken;
const toolResultMsg = new Message(crypto.randomUUID(), 0, MessageType.ToolResult, {
role: MessageRole.TOOL,
content,
tool_call_id: toolCallId,
});
const newMessages = [...messages];
// Insert after any existing tool results that follow the assistant message
let insertAt = afterIndex + 1;
while (insertAt < newMessages.length && newMessages[insertAt].type === MessageType.ToolResult) {
insertAt++;
}
newMessages.splice(insertAt, 0, toolResultMsg);
setMessages(newMessages);
// Execute with the updated messages
setIsStreaming(true);
await executePrompt(
newMessages,
undefined,
{ provider, model, modelParams, apiKeyId, variables },
{
onStreamingStart: (allMessages, placeholder) => {
if (!isActive()) return;
setMessages([...allMessages, placeholder]);
},
onStreamChunk: (content) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
const clone = last.clone();
clone.content = content;
updated[updated.length - 1] = clone;
return updated;
});
},
onComplete: (content, usage) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = Message.response(content, 0, usage);
return updated;
});
},
onToolCallComplete: (content, toolCalls, usage) => {
if (!isActive()) return;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = Message.toolCallResponse(content, toolCalls, 0, usage);
return updated;
});
},
onEmptyResponse: () => {
if (!isActive()) return;
setMessages((prev) => prev.slice(0, -1));
},
onError: (error) => {
if (!isActive()) return;
setMessages((prev) => {
const withoutPlaceholder = prev.slice(0, -1);
return [...withoutPlaceholder, Message.error(error)];
});
},
onFinally: () => {
if (!isActive()) return;
setIsStreaming(false);
},
},
);
},
[messages, provider, model, modelParams, apiKeyId, variables],
);
const value: PromptContextValue = {
folders,
prompts,
selectedPrompt,
sessions,
selectedSession,
selectedVersion,
foldersLoading,
promptsLoading,
foldersError,
promptsError,
isLoadingPlayground,
isStreaming,
selectedPromptId,
selectedSessionId,
selectedVersionId,
setUrlState,
messages,
setMessages,
provider,
setProvider,
model,
setModel,
modelParams,
setModelParams,
apiKeyId,
setApiKeyId,
variables,
setVariables,
folderSheet,
setFolderSheet,
promptSheet,
setPromptSheet,
commitSheet,
setCommitSheet,
deleteFolderDialog,
setDeleteFolderDialog,
deletePromptDialog,
setDeletePromptDialog,
isDeletingFolder,
isDeletingPrompt,
supportsVision,
hasChanges,
hasVersionChanges,
hasSessionChanges,
handleSelectPrompt,
handleMovePrompt,
handleDeleteFolder,
handleDeletePrompt,
handleSendMessage,
handleSubmitToolResult,
canCreate,
canUpdate,
canDelete,
};
return <PromptContext.Provider value={value}>{children}</PromptContext.Provider>;
}

View File

@@ -0,0 +1,14 @@
import { ScrollArea } from "@/components/ui/scrollArea";
import { MessagesView } from "../components/messagesView/rootMessageView";
import { NewMessageInputView } from "../components/newMessageInputView";
export function PlaygroundPanel() {
return (
<div className="custom-scrollbar relative flex h-full flex-col overscroll-none">
<ScrollArea className="flex-1 scroll-mb-12 overflow-y-auto" viewportClassName="no-table">
<MessagesView />
</ScrollArea>
<NewMessageInputView />
</div>
);
}

View File

@@ -0,0 +1,232 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { ComboboxSelect } from "@/components/ui/combobox";
import ModelParameters from "@/components/ui/custom/modelParameters";
import { Label } from "@/components/ui/label";
import { ModelMultiselect } from "@/components/ui/modelMultiselect";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { getProviderLabel } from "@/lib/constants/logs";
import { useGetVirtualKeysQuery } from "@/lib/store";
import { useGetAllKeysQuery, useGetProvidersQuery } from "@/lib/store/apis/providersApi";
import { ModelProviderName } from "@/lib/types/config";
import { ModelParams } from "@/lib/types/prompts";
import { cn } from "@/lib/utils";
import { PromptDeploymentsAccordionItem } from "@enterprise/components/prompt-deployments/promptDeploymentsAccordionItem";
import { useCallback, useMemo, useState } from "react";
import { ApiKeySelectorView } from "../components/apiKeySelectorView";
import { VariablesTableView } from "../components/variablesTableView";
import { usePromptContext } from "../context";
export function SettingsPanel() {
const {
provider,
setProvider,
model,
setModel: onModelChange,
modelParams,
setModelParams: onModelParamsChange,
apiKeyId,
setApiKeyId,
variables,
setVariables,
selectedPromptId,
} = usePromptContext();
const onProviderChange = useCallback(
(p: string) => {
setProvider(p);
setApiKeyId("__auto__");
onModelChange("");
onModelParamsChange({} as ModelParams);
},
[setProvider, setApiKeyId, onModelChange, onModelParamsChange],
);
const onApiKeyIdChange = useCallback(
(id: string) => {
setApiKeyId(id);
},
[setApiKeyId],
);
// Dynamic providers
const { data: providers, isLoading: isLoadingProviders } = useGetProvidersQuery();
const { data: virtualKeysData } = useGetVirtualKeysQuery();
// Keys for the API Key selector (from /api/keys endpoint, provider-filtered)
const { data: allKeys, isSuccess: hasLoadedAllKeys } = useGetAllKeysQuery();
const isInitialLoading = isLoadingProviders;
const configuredProviders = useMemo(() => {
const activeVirtualKeys = virtualKeysData?.virtual_keys?.filter((vk) => vk.is_active) ?? [];
if (!hasLoadedAllKeys) {
return providers ?? [];
}
const keyedProviders = new Set((allKeys ?? []).map((k) => k.provider));
return (providers ?? []).filter((p) => {
if (keyedProviders.has(p.name)) return true;
// Include providers that have active virtual keys (wildcard or explicitly targeting this provider)
return activeVirtualKeys.some(
(vk) => !vk.provider_configs || vk.provider_configs.length === 0 || vk.provider_configs.some((pc) => pc.provider === p.name),
);
});
}, [providers, virtualKeysData, allKeys, hasLoadedAllKeys]);
// Ensure current provider always has a label-resolved option (even before providers query loads)
const providerOptions = useMemo(() => {
const opts = configuredProviders.map((p) => ({ label: getProviderLabel(p.name), value: p.name }));
if (provider && !opts.find((o) => o.value === provider)) {
opts.unshift({ label: getProviderLabel(provider), value: provider as ModelProviderName });
}
return opts;
}, [configuredProviders, provider]);
const providerKeys = useMemo(() => (allKeys ?? []).filter((k) => k.provider === provider), [allKeys, provider]);
// Virtual keys filtered by selected provider
const providerVirtualKeys = useMemo(() => {
const vks = virtualKeysData?.virtual_keys ?? [];
return vks.filter((vk) => {
if (!vk.is_active) return false;
// No provider configs means all providers are allowed (wildcard)
if (!vk.provider_configs || vk.provider_configs.length === 0) return true;
// Check if selected provider is in the configured providers
return vk.provider_configs.some((pc) => pc.provider === provider);
});
}, [virtualKeysData, provider]);
// Separate keys/vks to pass to model fetch for filtering.
const filterKeys = useMemo(() => {
const isProviderKey = providerKeys.some((k) => k.key_id === apiKeyId);
if (isProviderKey) return [apiKeyId];
const isVirtualKey = providerVirtualKeys.some((vk) => vk.id === apiKeyId);
if (isVirtualKey) return undefined;
// Auto: pass all provider key IDs
return providerKeys.map((k) => k.key_id);
}, [apiKeyId, providerKeys, providerVirtualKeys]);
const filterVks = useMemo(() => {
const isVirtualKey = providerVirtualKeys.some((vk) => vk.id === apiKeyId);
if (isVirtualKey) return [apiKeyId];
return undefined;
}, [apiKeyId, providerVirtualKeys]);
const handleModelParamsChange = useCallback(
(params: Record<string, any>) => {
onModelParamsChange(params as ModelParams);
},
[onModelParamsChange],
);
const hasModel = Boolean(model);
type SettingsSection = "parameters" | "deployments";
const [openSection, setOpenSection] = useState<SettingsSection | undefined>("parameters");
if (isInitialLoading) {
return (
<div className="flex h-full flex-col">
<div className="space-y-6 p-4">
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-9 w-full rounded-sm" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-9 w-full rounded-sm" />
</div>
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1 flex-col px-4 pt-2 pb-4">
<Accordion
type="single"
collapsible
value={openSection ?? ""}
onValueChange={(v) => {
if (v === "parameters" || v === "deployments") {
setOpenSection(v);
} else {
setOpenSection(undefined);
}
}}
className="flex min-h-0 flex-1 flex-col"
>
<AccordionItem
value="parameters"
className={cn("flex min-h-0 flex-col border-b-0", openSection === "parameters" ? "flex-1" : "shrink-0 overflow-hidden")}
>
<AccordionTrigger
data-testid="prompts-configuration-trigger"
className="text-muted-foreground shrink-0 py-3 pr-1 text-xs font-medium uppercase hover:no-underline"
>
<span className="min-w-0 flex-1 text-left font-semibold">Configuration</span>
</AccordionTrigger>
<AccordionContent
containerClassName="data-[state=open]:flex data-[state=open]:min-h-0 data-[state=open]:flex-1 data-[state=open]:flex-col"
className="min-h-0 flex-1 overflow-y-auto pt-0 pb-2"
>
<div className="space-y-6">
<div className="flex flex-col gap-2" data-testid="settings-provider">
<Label className="text-muted-foreground text-xs font-medium uppercase">Provider</Label>
<ComboboxSelect
options={providerOptions}
value={provider}
onValueChange={(v) => v && onProviderChange(v)}
placeholder="Select provider"
hideClear
/>
</div>
<div className="flex flex-col gap-2" data-testid="settings-model">
<Label className="text-muted-foreground text-xs font-medium uppercase">Model</Label>
<ModelMultiselect
provider={provider}
keys={filterKeys && filterKeys.length > 0 ? filterKeys : undefined}
vks={filterVks}
value={model}
onChange={(v) => onModelChange(v)}
isSingleSelect
placeholder={!provider ? "Select a provider first" : "Select model"}
disabled={!provider}
unfiltered={true}
/>
</div>
{(providerKeys.length > 0 || providerVirtualKeys.length > 0) && !!provider && (
<ApiKeySelectorView
providerKeys={providerKeys}
virtualKeys={providerVirtualKeys}
value={apiKeyId}
onValueChange={(v) => onApiKeyIdChange(v ?? "__auto__")}
disabled={!provider}
/>
)}
{Object.keys(variables).length > 0 && (
<>
<Separator />
<VariablesTableView variables={variables} onChange={setVariables} />
</>
)}
{hasModel && (
<>
<Separator />
<div className="flex flex-col gap-4">
<ModelParameters model={model} config={modelParams} onChange={handleModelParamsChange} hideFields={["promptTools"]} />
</div>
</>
)}
</div>
</AccordionContent>
</AccordionItem>
{selectedPromptId && <PromptDeploymentsAccordionItem activeSection={openSection} />}
</Accordion>
</div>
</div>
);
}

View File

@@ -0,0 +1,584 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdownMenu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scrollArea";
import { Folder, Prompt } from "@/lib/types/prompts";
import { cn } from "@/lib/utils";
import { DragDropProvider, useDraggable, useDroppable } from "@dnd-kit/react";
import {
ChevronDown,
ChevronRight,
FileText,
Folder as FolderIcon,
FolderOpen,
MoreHorizontal,
Pencil,
Plus,
PlusIcon,
Search,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePromptContext } from "../context";
/**
* Renders the prompt-manager sidebar including search, folder hierarchy, root prompts, and drag-and-drop reorganization.
*
* The sidebar supports creating, renaming, and deleting folders and prompts (when permitted), selecting prompts, auto-expanding the folder that contains the selected prompt, filtering by search query, and dragging prompts between folders or to the root. Visual drag-over feedback and permission gating for create/update/delete actions are applied.
*
* @returns The sidebar React element containing the search input, folder list, root prompt drop zone, and drag-and-drop provider.
*/
export function PromptSidebar() {
const {
folders,
prompts,
selectedPromptId,
handleSelectPrompt: onSelectPrompt,
setFolderSheet,
setDeleteFolderDialog,
setPromptSheet,
setDeletePromptDialog,
handleMovePrompt: onMovePrompt,
canCreate,
canUpdate,
canDelete,
} = usePromptContext();
const onCreateFolder = useCallback(() => setFolderSheet({ open: true }), [setFolderSheet]);
const onEditFolder = useCallback((folder: Folder) => setFolderSheet({ open: true, folder }), [setFolderSheet]);
const onDeleteFolder = useCallback((folder: Folder) => setDeleteFolderDialog({ open: true, folder }), [setDeleteFolderDialog]);
const onCreatePrompt = useCallback((folderId?: string) => setPromptSheet({ open: true, folderId }), [setPromptSheet]);
const onEditPrompt = useCallback((prompt: Prompt) => setPromptSheet({ open: true, prompt }), [setPromptSheet]);
const onDeletePrompt = useCallback((prompt: Prompt) => setDeletePromptDialog({ open: true, prompt }), [setDeletePromptDialog]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
// Auto-expand the folder containing the selected prompt
useEffect(() => {
if (!selectedPromptId) return;
const prompt = prompts.find((p) => p.id === selectedPromptId);
if (prompt?.folder_id) {
setExpandedFolders((prev) => {
if (prev.has(prompt.folder_id!)) return prev;
const next = new Set(prev);
next.add(prompt.folder_id!);
return next;
});
}
}, [selectedPromptId, prompts]);
const toggleFolder = useCallback((folderId: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
}, []);
// Group prompts by folder, root prompts have no folder_id
const { promptsByFolder, rootPrompts } = useMemo(() => {
const map = new Map<string, Prompt[]>();
const root: Prompt[] = [];
for (const prompt of prompts) {
if (!prompt.folder_id) {
root.push(prompt);
} else {
const list = map.get(prompt.folder_id) || [];
list.push(prompt);
map.set(prompt.folder_id, list);
}
}
return { promptsByFolder: map, rootPrompts: root };
}, [prompts]);
// Filter folders and prompts based on search
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return { folders, promptsByFolder, rootPrompts };
}
const query = searchQuery.toLowerCase();
const matchedFolderIds = new Set<string>();
const filteredPromptsByFolder = new Map<string, Prompt[]>();
const filteredRootPrompts: Prompt[] = [];
for (const prompt of prompts) {
if (prompt.name.toLowerCase().includes(query)) {
if (!prompt.folder_id) {
filteredRootPrompts.push(prompt);
} else {
matchedFolderIds.add(prompt.folder_id);
const list = filteredPromptsByFolder.get(prompt.folder_id) || [];
list.push(prompt);
filteredPromptsByFolder.set(prompt.folder_id, list);
}
}
}
const filteredFolders = folders.filter((folder) => folder.name.toLowerCase().includes(query) || matchedFolderIds.has(folder.id));
return {
folders: filteredFolders,
promptsByFolder: filteredPromptsByFolder,
rootPrompts: filteredRootPrompts,
};
}, [folders, prompts, promptsByFolder, rootPrompts, searchQuery]);
// Prompt lookup for drag events
const promptMap = useMemo(() => {
const map = new Map<string, Prompt>();
for (const p of prompts) map.set(p.id, p);
return map;
}, [prompts]);
return (
<DragDropProvider
onDragOver={(event) => {
if (!canUpdate) return;
const targetId = event.operation.target?.id as string | undefined;
setDragOverTarget(targetId ?? null);
}}
onDragEnd={(event) => {
setDragOverTarget(null);
if (!canUpdate) return;
if (event.canceled || !onMovePrompt) return;
const sourceId = event.operation.source?.id as string | undefined;
const targetId = event.operation.target?.id as string | undefined;
if (!sourceId || !targetId) return;
const promptId = sourceId.startsWith("prompt-") ? sourceId.slice(7) : null;
if (!promptId) return;
const prompt = promptMap.get(promptId);
if (!prompt) return;
let targetFolderId: string | null = null;
if (targetId === "root-drop-zone") {
targetFolderId = null;
} else if (targetId.startsWith("folder-")) {
targetFolderId = targetId.slice(7);
} else {
return;
}
if ((prompt.folder_id ?? null) === targetFolderId) return;
onMovePrompt(promptId, targetFolderId);
}}
>
<div className="flex h-full flex-col">
{/* Search */}
<div className="flex items-center gap-2 border-b p-3">
<div className="relative grow">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Search prompts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
data-testid="sidebar-search"
className="h-8 pl-8"
/>
</div>
{canCreate && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-8 shrink-0 bg-transparent"
data-testid="sidebar-create-menu"
aria-label="Create prompt or folder"
>
<PlusIcon className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="sidebar-create-prompt"
onClick={(e) => {
e.stopPropagation();
onCreatePrompt();
}}
>
New Prompt
</DropdownMenuItem>
<DropdownMenuItem
data-testid="sidebar-create-folder"
onClick={(e) => {
e.stopPropagation();
onCreateFolder();
}}
>
New Folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<ScrollArea className="grow overflow-y-auto" viewportClassName="no-table viewport-table-height-full">
<div className="flex flex-col p-2 px-3">
{filteredData.folders.length === 0 && filteredData.rootPrompts.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">{searchQuery ? "No results found" : "No prompts yet"}</div>
) : (
<>
{filteredData.folders.map((folder) => (
<DroppableFolder
key={folder.id}
folder={folder}
prompts={filteredData.promptsByFolder.get(folder.id) || promptsByFolder.get(folder.id) || []}
isExpanded={expandedFolders.has(folder.id) || !!searchQuery}
isDragOver={dragOverTarget === `folder-${folder.id}`}
selectedPromptId={selectedPromptId}
onToggle={() => toggleFolder(folder.id)}
onSelectPrompt={onSelectPrompt}
onEdit={() => onEditFolder(folder)}
onDelete={() => onDeleteFolder(folder)}
onCreatePrompt={() => onCreatePrompt(folder.id)}
onEditPrompt={onEditPrompt}
onDeletePrompt={onDeletePrompt}
canCreate={canCreate}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))}
<RootDropZone
isDragOver={dragOverTarget === "root-drop-zone"}
rootPrompts={filteredData.rootPrompts}
selectedPromptId={selectedPromptId}
onSelectPrompt={onSelectPrompt}
onEditPrompt={onEditPrompt}
onDeletePrompt={onDeletePrompt}
canUpdate={canUpdate}
canDelete={canDelete}
/>
</>
)}
</div>
</ScrollArea>
</div>
</DragDropProvider>
);
}
interface RootDropZoneProps {
isDragOver: boolean;
rootPrompts: Prompt[];
selectedPromptId?: string | null;
onSelectPrompt: (promptId: string) => void;
onEditPrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Renders the droppable root area that lists and hosts draggable root-level prompts.
*
* @param isDragOver - Whether a draggable item is currently over the root drop zone (applies drag-over styling).
* @param rootPrompts - Array of prompts that belong at the root (no folder).
* @param selectedPromptId - ID of the currently selected prompt, used to mark its item as selected.
* @param onSelectPrompt - Callback invoked with a prompt ID when a prompt is selected.
* @param onEditPrompt - Callback invoked with a prompt when the prompt's edit action is triggered.
* @param onDeletePrompt - Callback invoked with a prompt when the prompt's delete action is triggered.
* @param canUpdate - Whether prompts are movable/editable (enables dragging).
* @param canDelete - Whether prompts may be deleted (controls delete action visibility).
* @returns The JSX element for the root drop zone containing draggable prompt items.
*/
function RootDropZone({
isDragOver,
rootPrompts,
selectedPromptId,
onSelectPrompt,
onEditPrompt,
onDeletePrompt,
canUpdate,
canDelete,
}: RootDropZoneProps) {
const { ref } = useDroppable({ id: "root-drop-zone" });
return (
<div ref={ref} className={cn("min-h-[8px] grow rounded-sm transition-colors", isDragOver && "bg-primary/10 ring-primary/30 ring-1")}>
{rootPrompts.map((prompt) => (
<DraggablePromptItem
key={prompt.id}
prompt={prompt}
isSelected={selectedPromptId === prompt.id}
onSelect={() => onSelectPrompt(prompt.id)}
onEdit={() => onEditPrompt(prompt)}
onDelete={() => onDeletePrompt(prompt)}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))}
</div>
);
}
interface DroppableFolderProps {
folder: Folder;
prompts: Prompt[];
isExpanded: boolean;
isDragOver: boolean;
selectedPromptId?: string | null;
onToggle: () => void;
onSelectPrompt: (promptId: string) => void;
onEdit: () => void;
onDelete: () => void;
onCreatePrompt: () => void;
onEditPrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Renders a droppable folder header with optional action menu and its list of prompts.
*
* @param folder - Folder metadata (id, name, etc.)
* @param prompts - Prompts that belong to this folder
* @param isExpanded - Whether the folder is expanded to show its prompts
* @param isDragOver - Whether a draggable item is currently over this folder (affects visual state)
* @param selectedPromptId - ID of the currently selected prompt, used to highlight an item
* @param onToggle - Callback invoked to toggle the folder's expanded state
* @param onSelectPrompt - Callback invoked with a prompt ID when a prompt is selected
* @param onEdit - Callback invoked to start editing the folder
* @param onDelete - Callback invoked to start deleting the folder
* @param onCreatePrompt - Callback invoked to create a new prompt inside this folder
* @param onEditPrompt - Callback invoked with a prompt to start editing that prompt
* @param onDeletePrompt - Callback invoked with a prompt to start deleting that prompt
* @param canCreate - Whether the current user may create prompts in this folder
* @param canUpdate - Whether the current user may move/rename prompts or edit the folder
* @param canDelete - Whether the current user may delete prompts or the folder
* @returns A JSX element containing the folder row and, when expanded, its nested prompt items
*/
function DroppableFolder({
folder,
prompts,
isExpanded,
isDragOver,
selectedPromptId,
onToggle,
onSelectPrompt,
onEdit,
onDelete,
onCreatePrompt,
onEditPrompt,
onDeletePrompt,
canCreate,
canUpdate,
canDelete,
}: DroppableFolderProps) {
const { ref } = useDroppable({ id: `folder-${folder.id}` });
const showActions = canCreate || canUpdate || canDelete;
return (
<div ref={ref} className="mb-1 last:mb-0">
<div
className={cn(
"hover:bg-muted/50 group relative flex h-[30px] cursor-pointer items-center gap-1 rounded-sm px-2 transition-colors",
isDragOver && "bg-primary/10 ring-primary/30 ring-1",
)}
onClick={onToggle}
data-testid={`sidebar-folder-${folder.id}`}
>
<button className="flex shrink-0 items-center" aria-label="Toggle folder">
{isExpanded ? (
<ChevronDown className="text-muted-foreground h-4 w-4" />
) : (
<ChevronRight className="text-muted-foreground h-4 w-4" />
)}
</button>
{isExpanded ? (
<FolderOpen className="text-muted-foreground h-4 w-4 shrink-0" />
) : (
<FolderIcon className="text-muted-foreground h-4 w-4 shrink-0" />
)}
<span className="flex-1 truncate text-sm font-medium">{folder.name}</span>
<span className="text-muted-foreground mr-1 shrink-0 text-xs">{prompts.length}</span>
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()} className="bg-card absolute top-1/2 right-2 -translate-y-1/2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
data-testid={`sidebar-folder-actions-${folder.id}`}
aria-label="Folder actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canCreate && (
<DropdownMenuItem
data-testid="folder-create-prompt"
onClick={(e) => {
e.stopPropagation();
onCreatePrompt();
}}
>
<Plus className="mr-2 h-4 w-4" />
New Prompt
</DropdownMenuItem>
)}
{canCreate && (canUpdate || canDelete) && <DropdownMenuSeparator />}
{canUpdate && (
<DropdownMenuItem
data-testid="folder-action-edit"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil className="h-4 w-4" />
Edit Folder
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem
variant="destructive"
data-testid="folder-action-delete"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-4 w-4" />
Delete Folder
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{isExpanded && (
<div className="ml-4 border-l pl-2">
{prompts.length === 0 ? (
<div className="text-muted-foreground py-2 pl-4 text-xs">{isDragOver ? "Drop here" : "No prompts"}</div>
) : (
prompts.map((prompt) => (
<DraggablePromptItem
key={prompt.id}
prompt={prompt}
isSelected={selectedPromptId === prompt.id}
onSelect={() => onSelectPrompt(prompt.id)}
onEdit={() => onEditPrompt(prompt)}
onDelete={() => onDeletePrompt(prompt)}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))
)}
</div>
)}
</div>
);
}
interface DraggablePromptItemProps {
prompt: Prompt;
isSelected: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Renders a draggable prompt list item that shows the prompt name, selection/drag states, and an actions menu when permitted.
*
* Displays a file icon and truncated prompt name, applies visual styles for selection and dragging, prevents selection while dragging, and exposes rename/delete actions via a dropdown when `canUpdate` or `canDelete` are true.
*
* @param prompt - The prompt object to render.
* @param isSelected - Whether this prompt is currently selected; used for styling.
* @param onSelect - Callback invoked when the item is clicked (not invoked if the item is being dragged).
* @param onEdit - Callback invoked to start editing/renaming the prompt.
* @param onDelete - Callback invoked to delete the prompt.
* @param canUpdate - When true, enables dragging and shows the rename action.
* @param canDelete - When true, shows the delete action.
* @returns The rendered prompt item JSX element.
*/
function DraggablePromptItem({ prompt, isSelected, onSelect, onEdit, onDelete, canUpdate, canDelete }: DraggablePromptItemProps) {
const { ref, isDragging } = useDraggable({
id: `prompt-${prompt.id}`,
disabled: !canUpdate,
});
const showActions = canUpdate || canDelete;
return (
<div
ref={ref}
data-testid={`sidebar-prompt-${prompt.id}`}
className={cn(
"group mb-1 flex h-[30px] cursor-pointer items-center gap-2 rounded-sm px-2 last:mb-0",
isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted/50",
isDragging && "opacity-50",
)}
onClick={() => {
// Don't navigate if this was a drag
if (isDragging) return;
onSelect();
}}
>
<FileText className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate text-sm">{prompt.name}</span>
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
data-testid={`sidebar-prompt-actions-${prompt.id}`}
aria-label="Prompt actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canUpdate && (
<DropdownMenuItem
className="cursor-pointer"
data-testid="prompt-action-rename"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil className="h-4 w-4" />
Rename
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem
variant="destructive"
className="cursor-pointer"
data-testid="prompt-action-delete"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import FullPageLoader from "@/components/fullPageLoader";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { AlertCircle, Loader2 } from "lucide-react";
import { PromptSidebar } from "./fragments/sidebar";
import { PlaygroundPanel } from "./fragments/playgroundPanel";
import { SettingsPanel } from "./fragments/settingsPanel";
import { DeleteFolderDialog, DeletePromptDialog } from "./components/alerts";
import { PromptSheets } from "./components/sheets";
import { EmptyState, PromptsEmptyState } from "./components/emptyState";
import PromptsViewHeader from "./components/promptsViewHeader";
import { usePromptContext } from "./context";
export default function PromptsView() {
const { folders, prompts, foldersLoading, promptsLoading, foldersError, promptsError, isLoadingPlayground, selectedPromptId } =
usePromptContext();
if (foldersLoading || promptsLoading) {
return <FullPageLoader />;
}
if (foldersError || promptsError) {
return (
<div className="no-padding-parent no-border-parent p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Failed to load prompt repository</AlertDescription>
</Alert>
</div>
);
}
if (folders.length === 0 && prompts.length === 0) {
return (
<div className="no-padding-parent no-border-parent flex h-[calc(100dvh_-_18px)] w-full items-center">
<PromptSheets />
<PromptsEmptyState />
</div>
);
}
return (
<div className="no-padding-parent no-border-parent bg-background h-[calc(100dvh_-_16px)] w-full">
<DeleteFolderDialog />
<DeletePromptDialog />
<PromptSheets />
<ResizablePanelGroup direction="horizontal" className="h-full">
<ResizablePanel defaultSize={20} className="bg-card mr-1 overflow-hidden rounded-r-md">
<PromptSidebar />
</ResizablePanel>
<ResizableHandle className="mr-1 bg-transparent" />
<ResizablePanel defaultSize={80} minSize={50} className="bg-card overflow-hidden rounded-md">
{selectedPromptId ? (
<div className="flex h-full flex-col">
<PromptsViewHeader />
{isLoadingPlayground ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : (
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel defaultSize={70} minSize={40}>
<PlaygroundPanel />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={30} minSize={20}>
<SettingsPanel />
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
) : (
<EmptyState />
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scrollArea";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Message, MessageType } from "@/lib/message";
import { Markdown } from "@/components/ui/markdown";
import { getErrorMessage } from "@/lib/store";
import { useCommitSessionMutation } from "@/lib/store/apis/promptsApi";
import { PromptSession, PromptSessionMessage } from "@/lib/types/prompts";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface CommitVersionFormData {
commitMessage: string;
}
interface CommitVersionSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
session: PromptSession;
onCommitted: (versionId: number) => void;
}
function MessagePreview({
sessionMessage,
selected,
onToggle,
}: {
sessionMessage: PromptSessionMessage;
selected: boolean;
onToggle: () => void;
}) {
const msg = useMemo(() => Message.deserialize(sessionMessage.message), [sessionMessage.message]);
const role = msg.role;
const content = msg.content;
const hasToolCalls = msg.type === MessageType.CompletionResult && msg.toolCalls && msg.toolCalls.length > 0;
return (
<label
className={cn(
"group flex items-start gap-3 rounded-md border px-3 py-2.5 cursor-pointer transition-colors",
selected ? "border-border" : "border-transparent",
)}
>
<Checkbox checked={selected} onCheckedChange={onToggle} className="mt-1 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium uppercase">{role}</span>
<div className="text-muted-foreground mt-1 line-clamp-3 text-sm">
{hasToolCalls && !content ? (
<span className="italic">Tool call: {msg.toolCalls!.map((tc) => tc.function.name).join(", ")}</span>
) : content ? (
<Markdown content={content} className="text-muted-foreground [&_*]:text-sm" />
) : (
<span className="italic">Empty message</span>
)}
</div>
</div>
</label>
);
}
export function CommitVersionSheet({ open, onOpenChange, session, onCommitted }: CommitVersionSheetProps) {
const [commitSession, { isLoading }] = useCommitSessionMutation();
const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set());
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CommitVersionFormData>({
defaultValues: { commitMessage: "" },
});
// Reset form and select only the first message when sheet opens
useEffect(() => {
if (open) {
reset({ commitMessage: "" });
setSelectedIndices(new Set(session.messages.length > 0 ? [0] : []));
}
}, [open, reset, session?.messages?.length]);
const toggleMessage = useCallback((index: number) => {
setSelectedIndices((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
}, []);
const allSelected = selectedIndices.size === session.messages.length;
const toggleAll = useCallback(() => {
if (allSelected) {
setSelectedIndices(new Set());
} else {
setSelectedIndices(new Set(session.messages.map((_, i) => i)));
}
}, [allSelected, session.messages]);
async function onSubmit(data: CommitVersionFormData) {
if (selectedIndices.size === 0) {
toast.error("Please select at least one message to commit");
return;
}
try {
const sortedIndices = Array.from(selectedIndices).sort((a, b) => a - b);
const commitData: { commit_message: string; message_indices?: number[] } = {
commit_message: data.commitMessage.trim(),
};
// Only send message_indices if not all messages are selected
if (!allSelected) {
commitData.message_indices = sortedIndices;
}
const result = await commitSession({
id: session.id,
promptId: session.prompt_id,
data: commitData,
}).unwrap();
toast.success("Version committed");
reset();
onCommitted(result.version.id);
onOpenChange(false);
} catch (err) {
toast.error("Failed to commit version", {
description: getErrorMessage(err),
});
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className="flex h-full flex-col p-8"
onOpenAutoFocus={(e) => {
e.preventDefault();
document.getElementById("commitMessage")?.focus();
}}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-1 flex-col overflow-hidden">
<SheetHeader className="flex flex-col items-start">
<SheetTitle>Commit as Version</SheetTitle>
<SheetDescription>Select the messages to include in this version. Uncheck any messages you want to exclude.</SheetDescription>
</SheetHeader>
{/* Messages selection - scrollable */}
<div className="mt-4 flex flex-1 flex-col overflow-hidden">
<div className="mb-2 flex items-center justify-between">
<Label className="text-sm">
Messages ({selectedIndices.size}/{session.messages.length})
</Label>
<button type="button" onClick={toggleAll} className="text-muted-foreground hover:text-foreground text-xs transition-colors">
{allSelected ? "Deselect all" : "Select all"}
</button>
</div>
<ScrollArea className="flex-1 overflow-y-auto rounded-md border">
<div className="space-y-1 p-2">
{session.messages.map((sessionMsg, index) => (
<MessagePreview
key={sessionMsg.id}
sessionMessage={sessionMsg}
selected={selectedIndices.has(index)}
onToggle={() => toggleMessage(index)}
/>
))}
</div>
</ScrollArea>
</div>
{/* Commit message + CTAs - always visible at bottom */}
<div className="mt-4 shrink-0 space-y-4">
<div className="space-y-2">
<Label htmlFor="commitMessage">Commit Message</Label>
<Input
id="commitMessage"
data-testid="commit-version-message"
placeholder="Added system message for better context..."
{...register("commitMessage", {
required: "Commit message is required",
validate: (v) => v.trim().length > 0 || "Commit message cannot be blank",
})}
autoFocus
/>
{errors.commitMessage ? (
<p className="text-destructive text-xs">{errors.commitMessage.message}</p>
) : (
<p className="text-muted-foreground text-xs">
Describe what changed in this version (e.g., &quot;Added error handling instructions&quot;)
</p>
)}
</div>
<SheetFooter className="flex flex-row items-center justify-end gap-2 p-0">
<Button type="button" variant="outline" data-testid="commit-version-cancel" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
data-testid="commit-version-submit"
disabled={isLoading || selectedIndices.size === 0}
className={selectedIndices.size === 0 ? "opacity-50" : ""}
>
{isLoading ? "Committing..." : "Commit Version"}
</Button>
</SheetFooter>
</div>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,131 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage } from "@/lib/store";
import { useCreateFolderMutation, useUpdateFolderMutation } from "@/lib/store/apis/promptsApi";
import { Folder } from "@/lib/types/prompts";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
interface FolderFormData {
name: string;
description: string;
}
interface FolderSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
folder?: Folder;
onSaved: () => void;
}
export function FolderSheet({ open, onOpenChange, folder, onSaved }: FolderSheetProps) {
const [createFolder, { isLoading: isCreating }] = useCreateFolderMutation();
const [updateFolder, { isLoading: isUpdating }] = useUpdateFolderMutation();
const isLoading = isCreating || isUpdating;
const isEditing = !!folder;
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FolderFormData>({
defaultValues: { name: "", description: "" },
});
useEffect(() => {
if (open) {
reset({
name: folder?.name ?? "",
description: folder?.description ?? "",
});
}
}, [open, folder, reset]);
async function onSubmit(data: FolderFormData) {
try {
if (isEditing) {
await updateFolder({
id: folder.id,
data: { name: data.name.trim(), description: data.description.trim() || undefined },
}).unwrap();
toast.success("Folder updated");
} else {
await createFolder({
name: data.name.trim(),
description: data.description.trim() || undefined,
}).unwrap();
toast.success("Folder created");
}
onSaved();
onOpenChange(false);
} catch (err) {
toast.error(`Failed to ${isEditing ? "update" : "create"} folder`, {
description: getErrorMessage(err),
});
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className="p-8"
onOpenAutoFocus={(e) => {
e.preventDefault();
document.getElementById("name")?.focus();
}}
>
<form onSubmit={handleSubmit(onSubmit)}>
<SheetHeader className="flex flex-col items-start">
<SheetTitle>{isEditing ? "Edit Folder" : "Create Folder"}</SheetTitle>
<SheetDescription>
{isEditing ? "Update the folder name and description." : "Create a new folder to organize your prompts."}
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
data-testid="folder-name-input"
placeholder="My Prompts"
{...register("name", {
required: "Folder name is required",
validate: (v) => v.trim().length > 0 || "Folder name cannot be blank",
})}
autoFocus
/>
{errors.name && <p className="text-destructive text-xs">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
data-testid="folder-description-input"
placeholder="Prompts for customer support use cases..."
className="resize-none"
{...register("description")}
/>
</div>
</div>
<SheetFooter className="mt-6 flex flex-row items-center justify-end gap-2 p-0">
<Button type="button" variant="outline" data-testid="folder-cancel" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" data-testid="folder-submit" disabled={isLoading}>
{isLoading ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,117 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { getErrorMessage } from "@/lib/store";
import { useCreatePromptMutation, useUpdatePromptMutation } from "@/lib/store/apis/promptsApi";
import { Prompt } from "@/lib/types/prompts";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
interface PromptFormData {
name: string;
}
interface PromptSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
prompt?: Prompt;
folderId?: string;
onSaved: (promptId?: string) => void;
}
export function PromptSheet({ open, onOpenChange, prompt, folderId, onSaved }: PromptSheetProps) {
const [createPrompt, { isLoading: isCreating }] = useCreatePromptMutation();
const [updatePrompt, { isLoading: isUpdating }] = useUpdatePromptMutation();
const isLoading = isCreating || isUpdating;
const isEditing = !!prompt;
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<PromptFormData>({
defaultValues: { name: "" },
});
useEffect(() => {
if (open) {
reset({ name: prompt?.name ?? "" });
}
}, [open, prompt, reset]);
async function onSubmit(data: PromptFormData) {
try {
if (isEditing) {
await updatePrompt({
id: prompt.id,
data: { name: data.name.trim() },
}).unwrap();
toast.success("Prompt updated");
onSaved();
} else {
const result = await createPrompt({
name: data.name.trim(),
...(folderId ? { folder_id: folderId } : {}),
}).unwrap();
toast.success("Prompt created");
onSaved(result.prompt.id);
}
onOpenChange(false);
} catch (err) {
toast.error(`Failed to ${isEditing ? "update" : "create"} prompt`, {
description: getErrorMessage(err),
});
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className="p-8"
onOpenAutoFocus={(e) => {
e.preventDefault();
document.getElementById("name")?.focus();
}}
>
<form onSubmit={handleSubmit(onSubmit)}>
<SheetHeader className="flex flex-col items-start">
<SheetTitle>{isEditing ? "Rename Prompt" : "Create Prompt"}</SheetTitle>
<SheetDescription>
{isEditing ? "Update the prompt name." : folderId ? "Create a new prompt in this folder." : "Create a new prompt."}
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
data-testid="prompt-name-input"
placeholder="Customer Support Assistant"
{...register("name", {
required: "Prompt name is required",
validate: (v) => v.trim().length > 0 || "Prompt name cannot be blank",
})}
autoFocus
/>
{errors.name && <p className="text-destructive text-xs">{errors.name.message}</p>}
</div>
</div>
<SheetFooter className="mt-6 flex flex-row items-center justify-end gap-2 p-0">
<Button type="button" variant="outline" data-testid="prompt-cancel" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" data-testid="prompt-submit" disabled={isLoading}>
{isLoading ? "Saving..." : isEditing ? "Update" : "Create"}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,42 @@
import { MessageContent } from "@/lib/message";
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export async function fileToAttachment(file: File): Promise<MessageContent | null> {
if (file.type.startsWith("image/")) {
const dataUrl = await fileToBase64(file);
return {
type: "image_url",
image_url: { url: dataUrl, detail: "auto" },
};
}
if (file.type.startsWith("audio/")) {
const dataUrl = await fileToBase64(file);
// Extract base64 data and format from data URL
const base64Data = dataUrl.split(",")[1] || "";
const format = file.name.split(".").pop() || file.type.split("/")[1] || "wav";
return {
type: "input_audio",
input_audio: { data: base64Data, format },
};
}
// Generic file — API expects full data URL with MIME prefix
const dataUrl = await fileToBase64(file);
return {
type: "file",
file: {
file_data: dataUrl,
filename: file.name,
file_type: file.type || "application/octet-stream",
},
};
}

View File

@@ -0,0 +1,183 @@
import { Message, type CompletionUsage, type ToolCall, type VariableMap, replaceVariablesInMessages } from "@/lib/message";
import { getErrorMessage } from "@/lib/store";
import type { ModelParams } from "@/lib/types/prompts";
export interface ExecutionConfig {
provider: string;
model: string;
modelParams: ModelParams;
apiKeyId: string;
variables?: VariableMap;
}
function getBaseUrl() {
if (process.env.NODE_ENV === "development") {
return "http://localhost:8080";
} else {
return "";
}
}
export interface ExecutionCallbacks {
onStreamingStart: (allMessages: Message[], placeholder: Message) => void;
onStreamChunk: (content: string) => void;
onComplete: (content: string, usage?: CompletionUsage) => void;
onToolCallComplete: (content: string, toolCalls: ToolCall[], usage?: CompletionUsage) => void;
onEmptyResponse: () => void;
onError: (error: string) => void;
onFinally: () => void;
}
export async function executePrompt(
currentMessages: Message[],
pendingMessage: Message | undefined,
config: ExecutionConfig,
callbacks: ExecutionCallbacks,
) {
let allMessages: Message[];
if (pendingMessage) {
allMessages = [...currentMessages, pendingMessage];
} else {
allMessages = [...currentMessages];
}
const placeholder = Message.response("");
callbacks.onStreamingStart(allMessages, placeholder);
// Replace Jinja2 variables before sending to the API
const resolvedMessages = config.variables ? replaceVariablesInMessages(allMessages, config.variables) : allMessages;
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (config.apiKeyId && config.apiKeyId !== "__auto__") {
if (config.apiKeyId.startsWith("sk-bf-")) {
headers["Authorization"] = `Bearer ${config.apiKeyId}`;
} else {
headers["x-bf-api-key-id"] = config.apiKeyId;
}
}
const { api_key_id: _, ...requestParams } = config.modelParams;
const response = await fetch(`${getBaseUrl()}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: `${config.provider}/${config.model}`,
messages: Message.toAPIMessages(resolvedMessages),
...requestParams,
stream: requestParams.stream,
}),
});
if (!response.ok) {
let errorMessage = `HTTP error! status: ${response.status}`;
try {
const data = await response.json();
errorMessage = data.error?.error || data.error?.message || errorMessage;
} catch (error) {
console.error("Failed to parse error response:", error);
}
throw new Error(errorMessage);
}
const contentType = response.headers.get("content-type") || "";
const isStreamResponse = contentType.includes("text/event-stream");
if (!isStreamResponse) {
const data = await response.json();
const content = data.choices?.[0]?.message?.content ?? "";
const toolCalls = data.choices?.[0]?.message?.tool_calls as ToolCall[] | undefined;
const usage = data.usage as CompletionUsage | undefined;
if (toolCalls && toolCalls.length > 0) {
callbacks.onToolCallComplete(content, toolCalls, usage);
} else if (content) {
callbacks.onComplete(content, usage);
} else {
callbacks.onEmptyResponse();
}
} else {
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let assistantContent = "";
let streamUsage: CompletionUsage | undefined;
const toolCallsMap = new Map<number, ToolCall>();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last (potentially incomplete) line in the buffer
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data: ")) continue;
const data = trimmed.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta;
if (parsed.usage) {
streamUsage = parsed.usage as CompletionUsage;
}
const content = delta?.content;
if (content) {
assistantContent += content;
callbacks.onStreamChunk(assistantContent);
}
const deltaToolCalls = delta?.tool_calls as Array<{
index: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
}>;
if (deltaToolCalls) {
for (const dtc of deltaToolCalls) {
const idx = dtc.index;
const existing = toolCallsMap.get(idx);
if (existing) {
if (dtc.function?.arguments) {
existing.function.arguments += dtc.function.arguments;
}
} else {
toolCallsMap.set(idx, {
type: "function",
id: dtc.id ?? "",
function: {
name: dtc.function?.name ?? "",
arguments: dtc.function?.arguments ?? "",
},
});
}
}
}
} catch {
// Ignore parse errors
}
}
}
const toolCalls = Array.from(toolCallsMap.values());
if (toolCalls.length > 0) {
callbacks.onToolCallComplete(assistantContent, toolCalls, streamUsage);
} else if (assistantContent) {
callbacks.onComplete(assistantContent, streamUsage);
} else {
callbacks.onEmptyResponse();
}
}
} catch (err) {
callbacks.onError(getErrorMessage(err));
} finally {
callbacks.onFinally();
}
}

View File

@@ -0,0 +1,16 @@
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
import { getProviderLabel } from "@/lib/constants/logs";
interface ProviderProps {
provider: string;
size?: number;
}
export default function Provider({ provider, size = 16 }: ProviderProps) {
return (
<div className="flex items-center gap-1">
<RenderProviderIcon provider={provider as ProviderIconType} size={size} className="mt-0.5" />
<span>{getProviderLabel(provider)}</span>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { Progress } from "@/components/ui/progress";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { resetDurationLabels } from "@/lib/constants/governance";
import { cn } from "@/lib/utils";
import { formatCompactNumber } from "@/lib/utils/governance";
interface RateLimitShape {
token_max_limit?: number | null;
token_reset_duration?: string | null;
token_current_usage?: number | null;
request_max_limit?: number | null;
request_reset_duration?: string | null;
request_current_usage?: number | null;
}
interface RateLimitDisplayProps {
rateLimits: RateLimitShape | null | undefined;
/** Compact mode for narrow cells — still renders bars, just tighter */
compact?: boolean;
/** Render limit + reset period only (no usage bar). Use for template entities like access profiles. */
limitOnly?: boolean;
}
const formatResetDuration = (duration?: string | null) => {
if (!duration) return "";
return resetDurationLabels[duration] || duration;
};
function LimitText({ label, max, resetDuration }: { label: string; max: number; resetDuration?: string | null }) {
return (
<div className="flex items-center justify-between gap-4 text-xs">
<span className="font-mono">
{formatCompactNumber(max)} {label}
</span>
<span className="text-muted-foreground">{formatResetDuration(resetDuration)}</span>
</div>
);
}
function Bar({ label, current, max, resetDuration, compact }: {
label: string;
current: number;
max: number;
resetDuration?: string | null;
compact?: boolean;
}) {
const pct = max > 0 ? Math.min((current / max) * 100, 100) : 0;
const isExhausted = max > 0 && current >= max;
const barClass = isExhausted
? "[&>div]:bg-red-500/70"
: pct > 80
? "[&>div]:bg-amber-500/70"
: "[&>div]:bg-emerald-500/70";
return (
<Tooltip>
<TooltipTrigger asChild>
<div className={cn("space-y-1.5", compact && "space-y-1")}>
<div className="flex items-center justify-between gap-4 text-xs">
<span className="font-medium">
{formatCompactNumber(max)} {label}
</span>
<span className="text-muted-foreground">{formatResetDuration(resetDuration)}</span>
</div>
<Progress value={pct} className={cn("bg-muted/70 dark:bg-muted/30 h-1", barClass)} />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">
{current.toLocaleString()} / {max.toLocaleString()} {label}
</p>
{resetDuration ? (
<p className="text-primary-foreground/80 text-xs">Resets {formatResetDuration(resetDuration)}</p>
) : null}
</TooltipContent>
</Tooltip>
);
}
export function RateLimitDisplay({ rateLimits, compact, limitOnly }: RateLimitDisplayProps) {
if (!rateLimits) {
return <span className="text-muted-foreground text-sm">-</span>;
}
const hasTokens = rateLimits.token_max_limit != null && rateLimits.token_max_limit > 0;
const hasRequests = rateLimits.request_max_limit != null && rateLimits.request_max_limit > 0;
if (!hasTokens && !hasRequests) {
return <span className="text-muted-foreground text-sm">-</span>;
}
return (
<div className={cn("space-y-2.5 min-w-[160px]", compact && "space-y-2", limitOnly && "space-y-1")}>
{hasTokens ? (
limitOnly ? (
<LimitText label="tokens" max={rateLimits.token_max_limit!} resetDuration={rateLimits.token_reset_duration} />
) : (
<Bar
label="tokens"
current={rateLimits.token_current_usage ?? 0}
max={rateLimits.token_max_limit!}
resetDuration={rateLimits.token_reset_duration}
compact={compact}
/>
)
) : null}
{hasRequests ? (
limitOnly ? (
<LimitText label="req" max={rateLimits.request_max_limit!} resetDuration={rateLimits.request_reset_duration} />
) : (
<Bar
label="req"
current={rateLimits.request_current_usage ?? 0}
max={rateLimits.request_max_limit!}
resetDuration={rateLimits.request_reset_duration}
compact={compact}
/>
)
) : null}
</div>
);
}

1272
ui/components/sidebar.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Columns3, RotateCcw } from "lucide-react";
import type { ColumnConfigEntry } from "./hooks/useColumnConfig";
interface ColumnConfigDropdownProps {
entries: ColumnConfigEntry[];
labels?: Record<string, string>;
onToggleVisibility: (columnId: string) => void;
onReset: () => void;
}
function formatColumnId(id: string): string {
return id
.replace(/^metadata_/, "")
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
export function ColumnConfigDropdown({ entries, labels = {}, onToggleVisibility, onReset }: ColumnConfigDropdownProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7.5 w-7.5" data-testid="column-config-trigger" aria-label="Column configuration">
<Columns3 className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-2" align="end">
<div className="space-y-1">
<div className="text-muted-foreground px-1 pb-1 text-xs font-medium">Toggle Columns</div>
{entries.map((entry) => (
<label key={entry.id} className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-1 py-1">
<Checkbox
checked={entry.visible}
onCheckedChange={() => onToggleVisibility(entry.id)}
data-testid={`column-visibility-${entry.id}`}
/>
<span className="truncate text-sm">{labels[entry.id] ?? formatColumnId(entry.id)}</span>
</label>
))}
<div className="border-t pt-1">
<Button
type="button"
onClick={onReset}
variant="ghost"
className="w-full justify-start text-sm"
data-testid="column-reset-default"
>
<RotateCcw className="h-3 w-3" />
Reset to default
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,94 @@
import { Column, ColumnPinningState } from "@tanstack/react-table";
import { CSSProperties, useLayoutEffect, useRef, useState } from "react";
/**
* Measures actual header cell DOM widths and computes pixel-perfect
* sticky offsets for pinned columns.
*/
export function usePinOffsets(
headerCellRefs: React.MutableRefObject<Map<string, HTMLTableCellElement>>,
columnPinning: ColumnPinningState,
) {
const [offsets, setOffsets] = useState<Map<string, number>>(new Map());
// Serialize pinning arrays to stable strings so the effect only fires
// when the actual pinned column IDs change, not on every render.
const leftKey = (columnPinning.left ?? []).join(",");
const rightKey = (columnPinning.right ?? []).join(",");
useLayoutEffect(() => {
const leftPinned = leftKey ? leftKey.split(",") : [];
const rightPinned = rightKey ? rightKey.split(",") : [];
const next = new Map<string, number>();
let left = 0;
for (const id of leftPinned) {
next.set(id, left);
const el = headerCellRefs.current.get(id);
if (el) left += el.getBoundingClientRect().width;
}
let right = 0;
for (let i = rightPinned.length - 1; i >= 0; i--) {
const id = rightPinned[i];
next.set(id, right);
const el = headerCellRefs.current.get(id);
if (el) right += el.getBoundingClientRect().width;
}
// Only update if offsets actually changed to avoid infinite re-render loops
setOffsets((prev) => {
if (prev.size === next.size) {
let same = true;
for (const [k, v] of next) {
if (prev.get(k) !== v) {
same = false;
break;
}
}
if (same) return prev;
}
return next;
});
}, [leftKey, rightKey, headerCellRefs]);
return offsets;
}
/**
* Returns a ref callback setter and the refs map for header cell measurement.
*/
export function useHeaderCellRefs() {
const refs = useRef<Map<string, HTMLTableCellElement>>(new Map());
const setRef = (columnId: string) => (el: HTMLTableCellElement | null) => {
if (el) refs.current.set(columnId, el);
else refs.current.delete(columnId);
};
return { headerCellRefs: refs, setHeaderCellRef: setRef };
}
/**
* Builds a CSS style object for a pinned column using measured offsets.
*/
export function buildPinStyle<T>(column: Column<T>, offsets: Map<string, number>): CSSProperties {
const pinned = column.getIsPinned();
if (!pinned) return {};
const px = offsets.get(column.id) ?? 0;
return {
position: "sticky",
left: pinned === "left" ? `${px}px` : undefined,
right: pinned === "right" ? `${px}px` : undefined,
zIndex: 1,
};
}
/**
* CSS class for the shadow on the last left-pinned or first right-pinned column.
* Uses an `after` pseudo-element so it isn't clipped by overflow on the table container.
*/
export const PIN_SHADOW_LEFT =
"after:pointer-events-none after:absolute after:top-0 after:-right-6 after:h-full after:w-6 after:shadow-[inset_6px_0_6px_-6px_rgba(0,0,0,0.15)] dark:after:shadow-[inset_6px_0_6px_-6px_rgba(0,0,0,0.5)]";
export const PIN_SHADOW_RIGHT =
"before:pointer-events-none before:absolute before:top-0 before:-left-6 before:h-full before:w-6 before:shadow-[inset_-6px_0_6px_-6px_rgba(0,0,0,0.15)] dark:before:shadow-[inset_-6px_0_6px_-6px_rgba(0,0,0,0.5)]";

View File

@@ -0,0 +1,130 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdownMenu";
import { cn } from "@/lib/utils";
import { Header, flexRender } from "@tanstack/react-table";
import { ArrowLeftToLine, ArrowRightToLine, Ellipsis, EyeOff, PinOff } from "lucide-react";
import { CSSProperties, useState } from "react";
export const TH_CLASS = "text-foreground h-10 px-4 text-left align-middle font-medium whitespace-nowrap";
export function DraggableColumnHeader<TData>({
header,
isConfigurable,
pinStyle,
pinnedHeaderClassName,
className: extraClassName,
onHide,
onPin,
onDrop,
cellRef,
}: {
header: Header<TData, unknown>;
isConfigurable: boolean;
pinStyle: CSSProperties;
pinnedHeaderClassName?: string;
className?: string;
onHide: (id: string) => void;
onPin: (id: string, position: "left" | "right") => void;
onDrop: (draggedId: string, targetId: string) => void;
cellRef: (el: HTMLTableCellElement | null) => void;
}) {
const [isDragging, setIsDragging] = useState(false);
const [isDropTarget, setIsDropTarget] = useState(false);
const pinned = header.column.getIsPinned();
const size = header.getSize();
return (
<th
ref={cellRef}
style={{ width: size, minWidth: size, maxWidth: size, ...pinStyle }}
className={cn(
TH_CLASS,
pinned && (pinnedHeaderClassName ?? "bg-card"),
isDragging && "opacity-50",
isDropTarget && "ring-primary ring-inset ring-1",
isConfigurable && "cursor-grab active:cursor-grabbing",
extraClassName,
)}
draggable={isConfigurable}
onDragStart={(e) => {
setIsDragging(true);
e.dataTransfer.setData("text/plain", header.column.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => setIsDragging(false)}
onDragOver={(e) => {
if (!isConfigurable) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsDropTarget(true);
}}
onDragLeave={() => setIsDropTarget(false)}
onDrop={(e) => {
e.preventDefault();
setIsDropTarget(false);
const draggedId = e.dataTransfer.getData("text/plain");
if (draggedId && draggedId !== header.column.id) {
onDrop(draggedId, header.column.id);
}
}}
>
{header.isPlaceholder ? null : (
<div className="group/col flex items-center">
<div className="flex-1">{flexRender(header.column.columnDef.header, header.getContext())}</div>
{isConfigurable && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-1 shrink-0 opacity-0 transition-opacity group-hover/col:opacity-100 focus-visible:opacity-100"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
aria-label="Column actions"
>
<Ellipsis className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="bottom">
<DropdownMenuItem onClick={() => onHide(header.column.id)}>
<EyeOff className="h-4 w-4" />
Hide column
</DropdownMenuItem>
<DropdownMenuSeparator />
{pinned === "left" ? (
<DropdownMenuItem onClick={() => onPin(header.column.id, "left")}>
<PinOff className="h-4 w-4" />
Unpin
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onPin(header.column.id, "left")}>
<ArrowLeftToLine className="h-4 w-4" />
Pin to left
</DropdownMenuItem>
)}
{pinned === "right" ? (
<DropdownMenuItem onClick={() => onPin(header.column.id, "right")}>
<PinOff className="h-4 w-4" />
Unpin
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onPin(header.column.id, "right")}>
<ArrowRightToLine className="h-4 w-4" />
Pin to right
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</th>
);
}

View File

@@ -0,0 +1,143 @@
import { ColumnPinningState, VisibilityState } from "@tanstack/react-table";
import { parseAsString, useQueryState } from "nuqs";
import { useCallback, useMemo } from "react";
export interface ColumnConfigEntry {
id: string;
visible: boolean;
pinned: "left" | "right" | false;
}
interface UseColumnConfigOptions {
/** All available column IDs in their default order */
columnIds: string[];
/** URL query param name for persistence */
paramName?: string;
/** Columns excluded from configuration (always visible, always in position) */
fixedColumns?: { left?: string[]; right?: string[] };
}
// URL format: col1,col2:h,col3:l,col4:r
// no suffix = visible & unpinned, :h = hidden, :l = pinned left, :r = pinned right
function serialize(entries: ColumnConfigEntry[]): string {
return entries
.map((e) => {
let flags = "";
if (!e.visible) flags += "h";
if (e.pinned === "left") flags += "l";
if (e.pinned === "right") flags += "r";
const encoded = encodeURIComponent(e.id);
return flags ? `${encoded}:${flags}` : encoded;
})
.join(",");
}
function deserialize(str: string): ColumnConfigEntry[] {
if (!str) return [];
return str.split(",").map((part) => {
const [rawId, flags = ""] = part.split(":");
const id = decodeURIComponent(rawId);
return {
id,
visible: !flags.includes("h"),
pinned: flags.includes("l") ? ("left" as const) : flags.includes("r") ? ("right" as const) : false,
};
});
}
export function useColumnConfig({ columnIds, paramName = "cols", fixedColumns }: UseColumnConfigOptions) {
const [raw, setRaw] = useQueryState(paramName, parseAsString.withDefault(""));
const fixedLeft = fixedColumns?.left ?? [];
const fixedRight = fixedColumns?.right ?? [];
const fixedSet = useMemo(() => new Set([...fixedLeft, ...fixedRight]), [fixedLeft, fixedRight]);
const configurableIds = useMemo(() => columnIds.filter((id) => !fixedSet.has(id)), [columnIds, fixedSet]);
// Merge URL config with available columns (handles added/removed columns)
const entries = useMemo(() => {
const parsed = deserialize(raw);
const result: ColumnConfigEntry[] = [];
const seen = new Set<string>();
// Columns present in URL config that still exist
for (const entry of parsed) {
if (configurableIds.includes(entry.id) && !seen.has(entry.id)) {
result.push(entry);
seen.add(entry.id);
}
}
// New columns not yet in URL config
for (const id of configurableIds) {
if (!seen.has(id)) {
result.push({ id, visible: true, pinned: false });
}
}
return result;
}, [raw, configurableIds]);
// TanStack table state
const columnOrder = useMemo(() => [...fixedLeft, ...entries.map((e) => e.id), ...fixedRight], [entries, fixedLeft, fixedRight]);
const columnVisibility = useMemo(() => {
const vis: VisibilityState = {};
for (const entry of entries) {
if (!entry.visible) vis[entry.id] = false;
}
return vis;
}, [entries]);
const columnPinning = useMemo(
(): ColumnPinningState => ({
left: [...fixedLeft, ...entries.filter((e) => e.pinned === "left").map((e) => e.id)],
right: [...entries.filter((e) => e.pinned === "right").map((e) => e.id), ...fixedRight],
}),
[entries, fixedLeft, fixedRight],
);
const persist = useCallback(
(newEntries: ColumnConfigEntry[]) => {
const serialized = serialize(newEntries);
setRaw(serialized || null);
},
[setRaw],
);
const toggleVisibility = useCallback(
(columnId: string) => {
persist(entries.map((e) => (e.id === columnId ? { ...e, visible: !e.visible } : e)));
},
[entries, persist],
);
const togglePin = useCallback(
(columnId: string, position: "left" | "right") => {
persist(entries.map((e) => (e.id === columnId ? { ...e, pinned: e.pinned === position ? false : position } : e)));
},
[entries, persist],
);
const reorder = useCallback(
(newEntries: ColumnConfigEntry[]) => {
persist(newEntries);
},
[persist],
);
const reset = useCallback(() => {
setRaw(null);
}, [setRaw]);
return {
entries,
columnOrder,
columnVisibility,
columnPinning,
toggleVisibility,
togglePin,
reorder,
reset,
};
}

View File

@@ -0,0 +1,5 @@
export { ColumnConfigDropdown } from "./columnConfigDropdown";
export { buildPinStyle, PIN_SHADOW_LEFT, PIN_SHADOW_RIGHT, useHeaderCellRefs, usePinOffsets } from "./columnPinning";
export { DraggableColumnHeader, TH_CLASS } from "./draggableColumnHeader";
export { useColumnConfig } from "./hooks/useColumnConfig";
export type { ColumnConfigEntry } from "./hooks/useColumnConfig";

View File

@@ -0,0 +1,6 @@
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,30 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hover:text-primary text-muted-foreground h-5 w-5 border-0 ring-offset-0 outline-none select-none focus-visible:ring-0"
>
<Sun className="h-5.5 w-5.5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" strokeWidth={2} />
<Moon className="absolute h-5.5 w-5.5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" strokeWidth={2} />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,56 @@
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return <AccordionPrimitive.Item data-slot="accordion-item" className={cn("border-b last:border-b-0", className)} {...props} />;
}
function AccordionTrigger({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex w-full min-w-0">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex w-full min-w-0 flex-1 items-center justify-between gap-2 rounded-sm py-4 text-left text-sm font-medium transition-colors outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
containerClassName,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content> & {
/** Classes merged onto the Radix content wrapper (outer). `className` still targets the inner div. */
containerClassName?: string;
}) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className={cn(
"data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm",
containerClassName,
)}
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@@ -0,0 +1,43 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-sm border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-red-200/30 bg-red-50/30 text-red-900 dark:border-red-800/30 dark:bg-red-950/30 dark:text-red-100 [&>svg]:text-red-600 dark:[&>svg]:text-red-400 *:data-[slot=alert-description]:text-red-700 dark:*:data-[slot=alert-description]:text-red-300",
info: "border-blue-200/30 bg-blue-50/30 text-blue-900 dark:border-blue-800/30 dark:bg-blue-950/30 dark:text-blue-100 [&>svg]:text-blue-600 dark:[&>svg]:text-blue-400 *:data-[slot=alert-description]:text-blue-700 dark:*:data-[slot=alert-description]:text-blue-300",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="alert-title" className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)} {...props} />
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn("text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
);
}
export { Alert, AlertDescription, AlertTitle };

View File

@@ -0,0 +1,92 @@
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as React from "react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"dark:bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-white p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} />;
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="alert-dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
);
}
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return <AlertDialogPrimitive.Title data-slot="alert-dialog-title" className={cn("text-lg font-semibold", className)} {...props} />;
}
function AlertDialogDescription({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return <AlertDialogPrimitive.Action className={cn(buttonVariants({ variant: "destructive" }), className)} {...props} />;
}
function AlertDialogCancel({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return <AlertDialogPrimitive.Cancel className={cn(buttonVariants({ variant: "outline" }), className)} {...props} />;
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View File

@@ -0,0 +1,7 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({ ...props }: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

View File

@@ -0,0 +1,747 @@
import { CheckIcon, ChevronDown, PlusIcon, XIcon } from "lucide-react";
import React, { KeyboardEventHandler, useCallback, useEffect, useRef, useState } from "react";
import {
ClearIndicatorProps,
components,
ControlProps,
DropdownIndicatorProps,
GroupBase,
GroupHeadingProps,
GroupProps,
IndicatorsContainerProps,
IndicatorSeparatorProps,
InputProps,
LoadingIndicatorProps,
MenuListProps,
MenuProps,
MultiValueGenericProps,
MultiValueProps,
MultiValueRemoveProps,
NoticeProps,
OptionProps,
PlaceholderProps,
SingleValueProps,
ValueContainerProps,
} from "react-select";
import AsyncCreatableSelect from "react-select/async-creatable";
import { useDebouncedFunction } from "../../hooks/useDebounce";
import { Icons } from "./icons";
import { Label } from "./label";
import {
createOption,
CustomComponentsProps,
CustomDropdownIndicatorProps,
CustomOptionProps,
CustomPlaceholderProps,
Option,
OptionGroup,
} from "./multiselectUtils";
import { Separator } from "./separator";
import { cn, radixDialogOnBlurWorkaround } from "./utils";
// Create wrapper functions for react-select components to fix TypeScript issues
const OptionWrapper = <T extends unknown = unknown>(props: OptionProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.Option(props) as React.ReactNode;
};
const GroupHeadingWrapper = <T extends unknown = unknown>(props: GroupHeadingProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.GroupHeading(props) as React.ReactNode;
};
const GroupWrapper = <T extends unknown = unknown>(props: GroupProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.Group(props) as React.ReactNode;
};
const MultiValueWrapper = <T extends unknown = unknown>(props: MultiValueProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.MultiValue(props) as React.ReactNode;
};
const ControlWrapper = <T extends unknown = unknown>(props: ControlProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.Control(props) as React.ReactNode;
};
const MultiValueRemoveWrapper = <T extends unknown = unknown>(props: MultiValueRemoveProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.MultiValueRemove(props) as React.ReactNode;
};
const IndicatorSeparatorWrapper = <T extends unknown = unknown>(
props: IndicatorSeparatorProps<T, boolean, GroupBase<T>>,
): React.ReactNode => {
return components.IndicatorSeparator(props) as React.ReactNode;
};
const InputWrapper = <T extends unknown = unknown>(props: InputProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.Input(props) as React.ReactNode;
};
const LoadingIndicatorWrapper = <T extends unknown = unknown>(props: LoadingIndicatorProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.LoadingIndicator(props) as React.ReactNode;
};
const MenuWrapper = <T extends unknown = unknown>(props: MenuProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.Menu(props) as React.ReactNode;
};
const MenuListWrapper = <T extends unknown = unknown>(props: MenuListProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.MenuList(props) as React.ReactNode;
};
const MultiValueContainerWrapper = <T extends unknown = unknown>(
props: MultiValueGenericProps<T, boolean, GroupBase<T>>,
): React.ReactNode => {
return components.MultiValueContainer(props) as React.ReactNode;
};
const NoOptionsMessageWrapper = <T extends unknown = unknown>(props: NoticeProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.NoOptionsMessage(props) as React.ReactNode;
};
const PlaceholderWrapper = <T extends unknown = unknown>(props: PlaceholderProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.Placeholder(props) as React.ReactNode;
};
const SingleValueWrapper = <T extends unknown = unknown>(props: SingleValueProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.SingleValue(props) as React.ReactNode;
};
const ValueContainerWrapper = <T extends unknown = unknown>(props: ValueContainerProps<T, boolean, GroupBase<T>>): React.ReactNode => {
return components.ValueContainer(props) as React.ReactNode;
};
interface AsyncMultiSelectProps<T> {
/** disable multiselect */
isSingleSelect?: boolean;
/** disable async */
isNonAsync?: boolean;
/** enable cross option to clear all selected values (default: false) */
isClearable?: boolean;
/** enable create new option functionality (default: false) */
isCreatable?: boolean;
/** Force close the menu on selection of option (default: false when isMulti is true) */
closeMenuOnSelect?: boolean;
/** hide selected options
* false : would add a checkmark in front of the selected option (default)
* true: would remove the selected option from the list
*/
hideSelectedOptions?: boolean;
/** style of the check icon if hideSelectedOption is false */
checkIconStyling?: string;
/** Controls whether the selected value is rendered in the control element.
* Set to false to hide the selected value in the control when rendering custom selection UI
* or to reduce visual clutter when labels are shown elsewhere.
* @default true
*/
controlShouldRenderValue?: boolean;
/** enable loading state */
isLoading?: boolean;
debounce?: number;
reload?: (query: string, callback: (options: Option<T>[] | OptionGroup<T>[]) => void) => void;
menuPosition?: "absolute" | "fixed";
/** Target element for the menu portal. When set, the menu renders inside this element instead of document.body. */
menuPortalTarget?: HTMLElement | null;
/** enable dynamic option creation from the input */
dynamicOptionCreation?: boolean;
/** default options to be displayed */
defaultOptions?: Option<T>[] | OptionGroup<T>[];
onChange?: (items: Option<T>[]) => any;
/** callback function to be called when a new option is created */
onCreateOption?: (value: string) => void;
placeholder?: React.ReactNode;
/** placeholder to be displayed when no results are found */
noResultsFoundPlaceholder?: string;
/** placeholder to be displayed when no results currently */
emptyResultPlaceholder?: React.ReactNode;
disabled?: boolean;
inputValue?: string;
hideDropdownIndicator?: boolean;
autoFocus?: boolean;
/** hide search icon present on the left */
hideSearchIcon?: boolean;
/** hide plus icon present in the create option */
hidePlusIcon?: boolean;
className?: string;
triggerClassName?: string;
menuClassName?: string;
menuListClassName?: string;
groupClassName?: string;
menuPlacement?: "auto" | "top" | "bottom";
value?: any;
menuIsOpen?: boolean;
/** format create new option label when dynamically creating option */
formatCreateLabel?: (inputValue: string) => string;
defaultValue?: any;
defaultMenuIsOpen?: boolean;
/** text to be displayed when static create option */
createOptionText?: string;
onBlur?: () => void;
/** callback function to be called when input value changes */
onInputChange?: (inputValue: string, actionMeta: { action: string }) => void;
onKeyDown?: KeyboardEventHandler;
/** custom no options message */
noOptionsMessage?: () => React.ReactNode;
valueContainerClassName?: string;
noOptionsMessageClassName?: string;
/** id for the search input (accessibility) */
inputId?: string;
/** id of element that labels this control (accessibility) */
ariaLabelledBy?: string;
/** test selector for the container element */
"data-testid"?: string;
views?: {
clearIndicator?: (props: ClearIndicatorProps<T>) => React.ReactNode;
control?: (props: ControlProps<T>) => React.ReactNode;
dropdownIndicator?: (props: DropdownIndicatorProps<T>) => React.ReactNode;
group?: (props: GroupProps<T>) => React.ReactNode;
groupHeading?: (props: GroupHeadingProps<T>) => React.ReactNode;
indicatorsContainer?: (props: IndicatorsContainerProps<T>) => React.ReactNode;
indicatorSeparator?: (props: IndicatorSeparatorProps) => React.ReactNode;
input?: (props: InputProps) => React.ReactNode;
loadingIndicator?: (props: LoadingIndicatorProps) => React.ReactNode;
menu?: (props: MenuProps) => React.ReactNode;
menuList?: (props: MenuListProps) => React.ReactNode;
noOptionsMessage?: (props: NoticeProps) => React.ReactNode;
multiValue?: (props: MultiValueProps<T>) => React.ReactNode;
multiValueLabel?: (props: MultiValueGenericProps<T>) => React.ReactNode;
multiValueContainer?: (props: MultiValueGenericProps<T>) => React.ReactNode;
multiValueRemove?: (props: MultiValueRemoveProps<T>) => React.ReactNode;
option?: (props: OptionProps<T>) => React.ReactNode;
placeholder?: (props: PlaceholderProps) => React.ReactNode;
singleValue?: (props: SingleValueProps<T>) => React.ReactNode;
valueContainer?: (props: ValueContainerProps) => React.ReactNode;
};
}
export function AsyncMultiSelect<T>(props: AsyncMultiSelectProps<T>) {
const menuOpenRef = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
// Add a native keydown listener at document level in capture phase
useEffect(() => {
const handleKeyDownCapture = (event: KeyboardEvent) => {
// Only intercept Escape when menu is open
if (menuOpenRef.current && event.key === "Escape") {
// Stop propagation and prevent default to block Sheet/Dialog
event.stopPropagation();
event.stopImmediatePropagation();
event.preventDefault();
// Close the menu by blurring the currently focused input
// When react-select menu is open, its input is focused
if (document.activeElement && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
};
// Listen at document level in capture phase with highest priority
// Must be in capture to beat Sheet/Dialog listeners
document.addEventListener("keydown", handleKeyDownCapture, { capture: true });
return () => {
document.removeEventListener("keydown", handleKeyDownCapture, { capture: true });
};
}, []);
// When having an asynchronous component, this will loadOptions from the reload and debounce passed in through props
const debouncedReload = useDebouncedFunction(
useCallback(
(query: string, callback: (options: Option<T>[] | OptionGroup<T>[]) => void) => {
if (!props.reload) {
return;
}
props.reload(query, callback);
},
[props.reload],
),
props.debounce ?? 0,
);
const loadOptions = props.debounce && props.reload ? debouncedReload : props.reload;
// This is a custom implementation of the load options when using the nonAsync flag. This will help in filtering based on input value
const loadOptionsForNonAsyncComponents = (inputValue: string, callback: (options: Option<T>[] | OptionGroup<T>[]) => void) => {
const options = props.defaultOptions || [];
let filtered;
if (options.length > 0 && "options" in options[0]) {
// Grouped options
filtered = (options as OptionGroup<T>[])
.map((group) => ({
...group,
options: group.options.filter((opt) => opt.label.toLowerCase().includes(inputValue.toLowerCase())),
}))
.filter((group) => group.options.length > 0);
} else {
// Flat options
filtered = (options as Option<T>[]).filter((opt) => opt.label.toLowerCase().includes(inputValue.toLowerCase()));
}
callback(filtered as Option<T>[] | OptionGroup<T>[]);
};
const handleKeyDown: KeyboardEventHandler = (event) => {
// If menu is open and Escape is pressed, stop propagation to prevent parent handlers (like dialogs)
if (menuOpenRef.current && event.key === "Escape") {
// Prevent the event from bubbling up to parent components (like Sheet/Dialog)
event.stopPropagation();
// Also prevent default to ensure no other handlers process this
event.preventDefault();
// Return early to not call the original handler with Escape when menu is open
return;
}
// Call the original onKeyDown handler if provided
if (props.onKeyDown) {
props.onKeyDown(event);
}
};
const customOptionProps: CustomOptionProps = {
dynamicOptionCreation: props.dynamicOptionCreation,
createOptionText: props.createOptionText,
checkIconStyling: props.checkIconStyling,
hideSelectedOptions: props.hideSelectedOptions ?? false,
hidePlusIcon: props.hidePlusIcon,
};
const customDropdownIndicatorProps: CustomDropdownIndicatorProps = {
hideDropdownIndicator: props.hideDropdownIndicator,
};
const customPlaceholderProps: CustomPlaceholderProps = {
hideSearchIcon: props.hideSearchIcon,
placeholder: props.placeholder,
};
const customComponentsProps: CustomComponentsProps = {
clearIndicatorView: props.views?.clearIndicator,
controlView: props.views?.control,
dropdownIndicatorView: props.views?.dropdownIndicator,
groupView: props.views?.group,
groupHeadingView: props.views?.groupHeading,
indicatorSeparatorView: props.views?.indicatorSeparator,
inputView: props.views?.input,
loadingIndicatorView: props.views?.loadingIndicator,
menuView: props.views?.menu,
menuListView: props.views?.menuList,
multiValueView: props.views?.multiValue,
multiValueRemoveView: props.views?.multiValueRemove,
multiValueLabelView: props.views?.multiValueLabel,
optionView: props.views?.option,
noOptionsMessageView: props.views?.noOptionsMessage,
placeholderView: props.views?.placeholder,
singleValueView: props.views?.singleValue,
valueContainerView: props.views?.valueContainer,
};
return (
<div ref={containerRef} data-testid={props["data-testid"]}>
<AsyncCreatableSelect
isDisabled={props.disabled}
autoFocus={props.autoFocus}
onKeyDown={handleKeyDown}
isClearable={props.isClearable ?? false}
onCreateOption={props.isCreatable ? props.onCreateOption : undefined}
isValidNewOption={props.isCreatable ? (option) => option.length > 0 : () => false}
isLoading={props.isLoading}
defaultOptions={props.defaultOptions}
loadOptions={props.isNonAsync ? loadOptionsForNonAsyncComponents : loadOptions}
isMulti={!props.isSingleSelect}
placeholder={props.placeholder}
closeMenuOnSelect={props.closeMenuOnSelect === true || props.isSingleSelect === true}
onChange={(selection, actionMeta) => {
switch (actionMeta.action) {
case "remove-value":
case "pop-value":
if ((actionMeta.removedValue as any)?.isFixed) {
return;
}
break;
case "clear":
if (selection && Array.isArray(selection)) {
selection = (selection as Option<T>[]).filter((v) => !(v as any)?.isFixed);
}
break;
}
// Normalize selection to array for consistent API
// When isSingleSelect is true, react-select returns single object (not array)
let normalizedSelection: Option<T>[];
if (props.isSingleSelect) {
normalizedSelection = selection ? [selection as Option<T>] : [];
} else {
normalizedSelection = (selection as Option<T>[]) || [];
}
props.onChange && props.onChange(normalizedSelection);
}}
formatCreateLabel={props.formatCreateLabel}
controlShouldRenderValue={props.controlShouldRenderValue ?? true}
menuPlacement={props.menuPlacement}
blurInputOnSelect={false}
menuPosition={props.menuPosition ?? "fixed"}
menuPortalTarget={props.menuPortalTarget}
onInputChange={(newValue, actionMeta) => {
if (props.onInputChange) {
props.onInputChange(newValue, { action: actionMeta.action });
}
}}
onBlur={(e) => {
if (!props.menuPortalTarget) {
radixDialogOnBlurWorkaround(e);
}
if (props.onBlur) props.onBlur();
}}
onMenuOpen={() => {
menuOpenRef.current = true;
}}
onMenuClose={() => {
menuOpenRef.current = false;
}}
menuIsOpen={props.menuIsOpen}
noOptionsMessage={
props.noOptionsMessage
? props.noOptionsMessage
: ({ inputValue }) => (inputValue.length > 0 ? <div>{props.noResultsFoundPlaceholder}</div> : props.emptyResultPlaceholder)
}
inputValue={props.inputValue}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (base) => ({ ...base, boxShadow: "none", minHeight: "32px" }),
multiValue: () => ({}),
multiValueLabel: () => ({}),
multiValueRemove: () => ({}),
option: () => ({}),
indicatorSeparator: () => ({
visibility: "hidden",
}),
input: (base) => ({ ...base, margin: 0, padding: 0 }),
noOptionsMessage: () => ({}),
valueContainer: (base) => ({ ...base, padding: 6, gap: 8 }),
placeholder: (base) => ({ ...base, marginLeft: 0 }),
indicatorsContainer: (base) => ({ ...base, height: "32px" }),
}}
value={props.value}
defaultValue={props.defaultValue ?? []}
defaultMenuIsOpen={props.defaultMenuIsOpen}
classNames={{
container: () => cn("min-h-8 border-none", props.className),
control: () =>
cn(
"border-border! multiselect-control dark:!bg-accent flex flex-wrap items-start justify-between rounded-md border bg-white",
props.triggerClassName,
),
placeholder: () => "text-sm text-content-disabled truncate p-0 text-ellipsis",
group: () => cn(props.groupClassName),
input: () => "text-sm m-0 border-none p-0 !text-secondary-foreground",
menu: () => cn("dark:!bg-accent p-0", props.menuClassName),
menuList: () => cn("p-2", props.menuListClassName),
valueContainer: () => cn("flex h-full w-full", props.valueContainerClassName),
option: ({ isFocused }) =>
cn("multiselect-option flex w-full justify-between rounded-sm p-2 text-sm", isFocused && "bg-background-highlight-primary/60"),
singleValue: () => "text-sm text-content-primary",
multiValue: () => "bg-accent dark:!bg-card flex cursor-pointer items-center gap-1 rounded-sm px-1 py-0.5 text-sm",
multiValueLabel: () => "text-content-tertiary",
multiValueRemove: () => "text-content-tertiary h-inherit flex items-center opacity-60 hover:cursor-pointer hover:opacity-100",
loadingMessage: () => "text-sm",
noOptionsMessage: () => cn("text-content-disabled flex items-center justify-center text-sm", props.noOptionsMessageClassName),
indicatorsContainer: () => "h-8",
}}
minMenuHeight={160}
components={{
ClearIndicator: CustomClearIndicator,
Control: CustomControl,
DropdownIndicator: CustomDropdownIndicator,
Group: CustomGroup,
GroupHeading: CustomGroupHeading,
IndicatorSeparator: CustomIndicatorSeparator,
Input: CustomInput,
LoadingIndicator: CustomLoadingIndicator,
Menu: CustomMenu,
MenuList: CustomMenuList,
MultiValue: CustomMultiValue,
MultiValueContainer: CustomMultiValueContainer,
MultiValueLabel: CustomMultiValueLabel,
MultiValueRemove: CustomMultiValueRemove,
Option: CustomOption,
NoOptionsMessage: CustomNoOptionsMessage,
Placeholder: CustomPlaceholder,
SingleValue: CustomSingleValue,
ValueContainer: CustomValueContainer,
}}
inputId={props.inputId}
aria-labelledby={props.ariaLabelledBy}
data-testid={props["data-testid"]}
{...customOptionProps}
{...customDropdownIndicatorProps}
{...customComponentsProps}
{...customPlaceholderProps}
/>
</div>
);
}
export function MultiSelectInput<T>(props: AsyncMultiSelectProps<T>) {
const [inputValue, setInputValue] = useState("");
const [value, setValue] = useState<readonly Option<T>[]>(props.value ? props.value.map((val: string) => createOption<T>(val)) : []);
const handleKeyDown: KeyboardEventHandler = (event) => {
if (!inputValue) return;
switch (event.key) {
case "Enter":
case "Tab": {
const newOptions = [...value, createOption<T>(inputValue)];
setValue(newOptions);
setInputValue("");
event.preventDefault();
props.onChange && props.onChange(newOptions);
break;
}
}
};
return (
<AsyncMultiSelect<T>
{...props}
isCreatable
dynamicOptionCreation
isClearable
menuIsOpen={false}
value={value}
hideDropdownIndicator
hideSearchIcon
onChange={(newValue) => {
setValue(newValue);
props.onChange && props.onChange(newValue);
}}
onKeyDown={handleKeyDown}
inputValue={inputValue}
onInputChange={(newValue) => setInputValue(newValue)}
autoFocus
/>
);
}
function CustomOption<T>(props: OptionProps<Option<T>> & { selectProps: CustomOptionProps & CustomComponentsProps }) {
const { Option } = components;
if (props.selectProps.optionView) {
return props.selectProps.optionView(props);
}
// So, this is a bit of a hack to style the `Create new option` button for react select. React select populates this property for this option
if (props.selectProps.dynamicOptionCreation === true && (props.data as any).__isNew__) {
return (
<div className={cn("flex w-full flex-col gap-2", props.options.length > 1 ? "pt-2" : "")}>
{props.options.length > 1 && <Separator />}
<OptionWrapper {...props} className="flex w-full items-center justify-start gap-1">
{props.selectProps.hidePlusIcon !== true && <PlusIcon size={14} />}
{props.children}
</OptionWrapper>
</div>
);
}
if ((props.data as any).__isNew__) {
return (
<div className={cn("flex w-full flex-col gap-2", props.options.length > 1 ? "pt-2" : "")}>
{props.options.length > 1 && <Separator />}
<OptionWrapper {...props} className="flex w-full items-center justify-start gap-1">
{props.selectProps.hidePlusIcon !== true && <PlusIcon size={14} />}
<div className="text-content-primary">{props.selectProps.createOptionText}</div>
</OptionWrapper>
</div>
);
}
return (
<OptionWrapper {...props}>
{props.children}
<div className="flex items-center justify-between">
{props.selectProps.hideSelectedOptions !== true && props.isSelected && (
<CheckIcon size={14} className={cn("text-content-primary", props.selectProps.checkIconStyling)} />
)}
</div>
</OptionWrapper>
);
}
function CustomControl<T>(props: ControlProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.controlView) {
return props.selectProps.controlView(props);
}
return <ControlWrapper {...props}>{props.children}</ControlWrapper>;
}
function CustomDropdownIndicator<T>(
props: DropdownIndicatorProps<Option<T>> & { selectProps: CustomDropdownIndicatorProps & CustomComponentsProps },
) {
if (props.selectProps.dropdownIndicatorView) {
return props.selectProps.dropdownIndicatorView(props);
}
if (props.selectProps.hideDropdownIndicator) {
return null;
}
return <ChevronDown className="text-content-primary m-2 h-4 w-4 shrink-0 self-start opacity-50 mt-2.5" />;
}
function CustomMultiValueRemove<T>(props: MultiValueRemoveProps<Option<T>> & { selectProps: CustomComponentsProps }) {
const { MultiValueRemove } = components;
if (props.selectProps.multiValueRemoveView) {
return props.selectProps.multiValueRemoveView(props);
}
return (
<MultiValueRemoveWrapper {...props}>
<XIcon size={14} />
</MultiValueRemoveWrapper>
);
}
function CustomMultiValueLabel<T>(props: MultiValueGenericProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.multiValueLabelView) {
return props.selectProps.multiValueLabelView(props);
}
return <Label className="text-content-tertiary text-sm font-normal">{props.children}</Label>;
}
function CustomMultiValue<T>(props: MultiValueProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.multiValueView) {
return props.selectProps.multiValueView(props);
}
return <MultiValueWrapper {...props} />;
}
function CustomGroupHeading<T>(props: GroupHeadingProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.groupHeadingView) {
return props.selectProps.groupHeadingView(props);
}
return <GroupHeadingWrapper {...props} />;
}
function CustomGroup<T>(props: GroupProps<Option<T>> & { selectProps: CustomComponentsProps }) {
const { Group } = components;
if (props.selectProps.groupView) {
return props.selectProps.groupView(props);
}
return <GroupWrapper {...props} />;
}
function CustomClearIndicator<T>(props: ClearIndicatorProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.clearIndicatorView) {
return props.selectProps.clearIndicatorView(props);
}
const parentTestId = (props.selectProps as { ["data-testid"]?: string })["data-testid"];
return (
<div
{...props.innerProps}
data-testid={parentTestId ? `${parentTestId}-clear-indicator-btn` : "multiselect-clear-indicator-btn"}
className="text-muted-foreground hover:text-foreground flex cursor-pointer items-center px-1 transition-colors mt-1"
>
<XIcon className="h-3.5 w-3.5" />
</div>
);
}
function CustomIndicatorSeparator<T>(props: IndicatorSeparatorProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.indicatorSeparatorView) {
return props.selectProps.indicatorSeparatorView(props);
}
return <IndicatorSeparatorWrapper {...props} />;
}
function CustomInput<T>(props: InputProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.inputView) {
return props.selectProps.inputView(props);
}
return <InputWrapper {...props} />;
}
function CustomLoadingIndicator<T>(props: LoadingIndicatorProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.loadingIndicatorView) {
return props.selectProps.loadingIndicatorView(props);
}
return <LoadingIndicatorWrapper {...props} />;
}
function CustomMenu<T>(props: MenuProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.menuView) {
return props.selectProps.menuView(props);
}
return <MenuWrapper {...props} />;
}
function CustomMenuList<T>(props: MenuListProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.menuListView) {
return props.selectProps.menuListView(props);
}
return <MenuListWrapper {...props} />;
}
function CustomMultiValueContainer<T>(props: MultiValueGenericProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.multiValueContainerView) {
return props.selectProps.multiValueContainerView(props);
}
return <MultiValueContainerWrapper {...props} />;
}
function CustomNoOptionsMessage<T>(props: NoticeProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.noOptionsMessageView) {
return props.selectProps.noOptionsMessageView(props);
}
return <NoOptionsMessageWrapper {...props} />;
}
function CustomPlaceholder<T>(props: PlaceholderProps<Option<T>> & { selectProps: CustomPlaceholderProps & CustomComponentsProps }) {
if (props.selectProps.placeholderView) {
return props.selectProps.placeholderView(props);
}
return (
<PlaceholderWrapper {...props} className="text-content-disabled flex flex-row items-center">
{props.selectProps.hideSearchIcon !== true && <Icons.search className="mr-2 h-3.5 w-3.5" strokeWidth={1.5} />}{" "}
{props.selectProps.placeholder}
</PlaceholderWrapper>
);
}
function CustomSingleValue<T>(props: SingleValueProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.singleValueView) {
return props.selectProps.singleValueView(props);
}
return <SingleValueWrapper {...props} />;
}
function CustomValueContainer<T>(props: ValueContainerProps<Option<T>> & { selectProps: CustomComponentsProps }) {
if (props.selectProps.valueContainerView) {
return props.selectProps.valueContainerView(props);
}
return <ValueContainerWrapper {...props} />;
}

View File

@@ -0,0 +1,30 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return <AvatarPrimitive.Image data-slot="avatar-image" className={cn("aspect-square size-full", className)} {...props} />;
}
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,48 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-sm border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary/10 border-primary/50 text-primary [a&]:hover:bg-primary/90 [a&]:hover:text-primary-foreground",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive/10 border-destructive/50 text-black dark:text-destructive-foreground [a&]:hover:bg-destructive/90 [a&]:hover:text-destructive-foreground focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
success:
"border-transparent bg-green-100 border-green-500 text-black [a&]:hover:bg-green-700/90 [a&]:hover:text-white",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,73 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn("text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", className)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return <Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground transition-colors", className)} {...props} />;
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li data-slot="breadcrumb-separator" role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis };

View File

@@ -0,0 +1,81 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
const buttonVariants = cva(
"inline-flex items-center ring-none justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none aria-invalid:border-destructive active:scale-[0.99] transition-transform duration-100",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white font-normal hover:bg-destructive/90 dark:bg-destructive/60",
outline:
"border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-7.5 px-2 py-1 has-[>svg]:px-2",
sm: "h-8 rounded-sm gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-sm px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
children,
isLoading = false,
dataTestId,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
isLoading?: boolean;
dataTestId?: string;
}) {
return (
<BaseButton className={className} variant={variant} size={size} asChild={asChild} dataTestId={dataTestId} {...props}>
{isLoading ? <Loader2 className="size-4 animate-spin" /> : children}
</BaseButton>
);
}
function BaseButton({
className,
variant,
size,
asChild = false,
dataTestId,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
dataTestId?: string;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
data-testid={dataTestId}
className={cn(buttonVariants({ variant, size, className }), "cursor-pointer")}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,145 @@
import * as React from "react";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn("flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next,
),
month_caption: cn("flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", defaultClassNames.month_caption),
dropdowns: cn("w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", defaultClassNames.dropdowns),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root,
),
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn("text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", defaultClassNames.weekday),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
week_number: cn("text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day,
),
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn("bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", defaultClassNames.today),
outside: cn("text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
}
if (orientation === "right") {
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
}
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

50
ui/components/ui/card.tsx Normal file
View File

@@ -0,0 +1,50 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-sm border py-6 shadow-sm", className)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="card-action" className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} {...props} />
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />;
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };

View File

@@ -0,0 +1,24 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="flex items-center justify-center text-current transition-none">
<CheckIcon className="text-primary-foreground size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -0,0 +1,240 @@
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
import { editor } from "monaco-editor";
import { useTheme } from "next-themes";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
// Lazy-loaded Monaco Editor (SSR isn't a concern in SPA mode).
const MonacoEditorLazy = lazy(() => import("@monaco-editor/react").then((mod) => ({ default: mod.default })));
const MonacoEditor = (props: React.ComponentProps<typeof MonacoEditorLazy>) => (
<Suspense fallback={<Loader2 className="h-4 w-4 animate-spin p-4" />}>
<MonacoEditorLazy {...props} />
</Suspense>
);
export type CompletionItem = {
label: string;
insertText: string;
documentation?: string;
description?: string;
type: "variable" | "method" | "object";
};
export interface CodeEditorProps {
id?: string;
className?: string;
lang?: string;
code?: string;
readonly?: boolean;
maxHeight?: number;
height?: string | number;
minHeight?: number;
width?: string | number;
onChange?: (value: string) => void;
wrap?: boolean;
onBlur?: () => void;
onSave?: () => void;
onFocus?: () => void;
customCompletions?: (CompletionItem & {
methods?: (CompletionItem & {
signature?: {
parameters: string[];
returnType?: string;
};
})[];
description?: string;
signature?: {
parameters: string[];
returnType?: string;
};
})[];
variant?: "ghost" | "default";
customLanguage?: CustomLanguage;
shouldAdjustInitialHeight?: boolean;
autoResize?: boolean;
autoFocus?: boolean;
autoFormat?: boolean;
fontSize?: number;
options?: {
autoSizeOnContentChange?: boolean;
lineNumbers?: "on" | "off";
collapsibleBlocks?: boolean;
alwaysConsumeMouseWheel?: boolean;
autoSuggest?: boolean;
overviewRulerLanes?: number;
scrollBeyondLastLine?: boolean;
showIndentLines?: boolean;
quickSuggestions?: boolean;
disableHover?: boolean;
lineNumbersMinChars?: number;
showVerticalScrollbar?: boolean;
showHorizontalScrollbar?: boolean;
};
containerClassName?: string;
}
export interface CustomLanguage {
id: string;
register: (monaco: any) => void;
validate: (monaco: any, model: any) => any[];
}
export function CodeEditor(props: CodeEditorProps) {
const { className, lang, code, onChange } = props;
const editorContainer = useRef<HTMLDivElement>(null);
const [isClient, setIsClient] = useState(false);
const [editorHeight, setEditorHeight] = useState<number | string>(props.height || props.minHeight || 200);
// Ensure we only render on client
useEffect(() => {
setIsClient(true);
}, []);
const { theme, systemTheme } = useTheme();
// Calculate theme
const getTheme = () => {
if (theme === "dark") return "custom-dark";
if (theme === "system" && systemTheme === "dark") return "custom-dark";
return "light";
};
// Handle editor mount
const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor) => {
if (props.autoFocus) {
editor.focus();
}
// Auto-resize logic
if (props.shouldAdjustInitialHeight || props.autoResize) {
const clampHeight = (h: number) => {
if (props.minHeight && h < props.minHeight) h = props.minHeight;
if (props.maxHeight && h > props.maxHeight) h = props.maxHeight;
return h;
};
editor.onDidContentSizeChange((e: editor.IContentSizeChangedEvent) => {
if (!e.contentHeightChanged) return;
const height = clampHeight(e.contentHeight);
setEditorHeight(height);
editor.layout();
});
// Initial height adjustment
const height = clampHeight(editor.getContentHeight());
setEditorHeight(height);
editor.layout();
}
// Auto-format
if (props.autoFormat) {
try {
editor.getAction("editor.action.formatDocument")?.run();
} catch (error) {
console.warn("Auto-format failed:", error);
}
}
};
const isFoldingEnabled = props.options?.collapsibleBlocks ?? false;
const editorOptions = {
lineNumbers: (props.options?.lineNumbers || "off") as "on" | "off",
readOnly: props.readonly,
scrollBeyondLastLine: props.options?.scrollBeyondLastLine ?? false,
minimap: { enabled: false },
contextmenu: false,
fontFamily: "var(--font-geist-mono)",
fontSize: props.fontSize || 12.5,
padding: { top: 2, bottom: 2 },
wordWrap: props.wrap ? ("on" as const) : ("off" as const),
folding: isFoldingEnabled,
glyphMargin: false,
lineNumbersMinChars: props.options?.lineNumbersMinChars ?? 4,
lineDecorationsWidth: 8,
showFoldingControls: isFoldingEnabled ? ("always" as const) : ("mouseover" as const),
overviewRulerLanes: props.options?.overviewRulerLanes ?? 0,
renderLineHighlight: "none" as const,
cursorStyle: "line" as const,
cursorBlinking: "smooth" as const,
scrollbar: {
vertical: (props.options?.showVerticalScrollbar ? "auto" : "hidden") as "auto" | "hidden",
horizontal: (props.options?.showHorizontalScrollbar ? "auto" : "hidden") as "auto" | "hidden",
alwaysConsumeMouseWheel: props.options?.alwaysConsumeMouseWheel ?? false,
},
guides: {
indentation: props.options?.showIndentLines ?? true,
},
hover: {
enabled: !props.options?.disableHover,
},
wordBasedSuggestions: "off" as const,
...props.options,
} as editor.IStandaloneEditorConstructionOptions;
if (!isClient) {
return (
<div className={cn("group relative flex h-24 w-full items-center justify-center", props.containerClassName)}>
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
return (
<div id={props.id} ref={editorContainer} className={cn("group relative h-full w-full", props.containerClassName)} onBlur={props.onBlur}>
<MonacoEditor
height={editorHeight}
width={props.width}
language={lang || "javascript"}
value={code || ""}
theme={getTheme()}
options={editorOptions}
loading={<Loader2 className="h-4 w-4 animate-spin" />}
onChange={(value) => {
if (onChange) {
onChange(value || "");
}
}}
onMount={handleEditorDidMount}
className={cn("code text-md w-full bg-transparent ring-offset-transparent outline-none", className)}
beforeMount={(monaco) => {
// Configure Monaco for static exports
// This is a hack to disable web workers when using the editor in a static export, do not change this.
if (typeof window !== "undefined") {
// Disable web workers
(window as any).MonacoEnvironment = {
getWorker: () => {
return {
postMessage: () => {},
terminate: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
onerror: null,
onmessage: null,
onmessageerror: null,
};
},
};
// Define custom dark theme with transparent background
monaco.editor.defineTheme("custom-dark", {
base: "vs-dark",
inherit: true,
rules: [],
colors: {
"editor.background": "#00000000",
focusBorder: "#00000000",
"editor.lineHighlightBorder": "#00000000",
"editor.selectionHighlightBorder": "#00000000",
"editorWidget.border": "#00000000",
"editorOverviewRuler.border": "#00000000",
},
});
}
}}
/>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
}
function CollapsibleContent({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,520 @@
import { Command as CommandPrimitive } from "cmdk";
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface ComboboxContextValue {
open: boolean;
setOpen: (open: boolean) => void;
value: string | string[] | null;
onValueChange: (value: any) => void;
inputValue: string;
setInputValue: (value: string) => void;
multiple?: boolean;
filter: ((value: string, search: string) => number) | null;
itemToStringLabel?: (value: string | null) => string;
}
const ComboboxContext = React.createContext<ComboboxContextValue | null>(null);
function useComboboxContext() {
const ctx = React.useContext(ComboboxContext);
if (!ctx) throw new Error("Combobox compound components must be used within <Combobox>");
return ctx;
}
interface ComboboxRootProps {
children: React.ReactNode;
value?: string | string[] | null;
onValueChange?: (value: any) => void;
onOpenChange?: (open: boolean) => void;
onInputValueChange?: (value: string) => void;
filter?: ((value: string, search: string) => number) | null;
multiple?: boolean;
itemToStringLabel?: (value: string | null) => string;
open?: boolean;
defaultOpen?: boolean;
}
function Combobox({
children,
value: controlledValue,
onValueChange,
onOpenChange,
onInputValueChange,
filter = null,
multiple,
itemToStringLabel,
open: controlledOpen,
defaultOpen = false,
}: ComboboxRootProps) {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const [inputValue, setInputValueState] = React.useState("");
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const setOpen = React.useCallback(
(v: boolean) => {
setInternalOpen(v);
onOpenChange?.(v);
},
[onOpenChange],
);
const setInputValue = React.useCallback(
(v: string) => {
setInputValueState(v);
onInputValueChange?.(v);
},
[onInputValueChange],
);
const handleValueChange = React.useCallback(
(v: any) => {
onValueChange?.(v);
},
[onValueChange],
);
const ctx = React.useMemo<ComboboxContextValue>(
() => ({
open,
setOpen,
value: controlledValue ?? null,
onValueChange: handleValueChange,
inputValue,
setInputValue,
multiple,
filter,
itemToStringLabel,
}),
[open, setOpen, controlledValue, handleValueChange, inputValue, setInputValue, multiple, filter, itemToStringLabel],
);
return (
<ComboboxContext.Provider value={ctx}>
<Popover open={open} onOpenChange={setOpen}>
{children}
</Popover>
</ComboboxContext.Provider>
);
}
function ComboboxInput({
className,
disabled = false,
showTrigger = true,
showClear = false,
placeholder,
}: {
className?: string;
children?: React.ReactNode;
disabled?: boolean;
showTrigger?: boolean;
showClear?: boolean;
placeholder?: string;
readOnly?: boolean;
autoFocus?: boolean;
}) {
const { value, itemToStringLabel, onValueChange } = useComboboxContext();
const displayValue = React.useMemo(() => {
if (Array.isArray(value)) return "";
if (value && itemToStringLabel) return itemToStringLabel(value);
return value ?? "";
}, [value, itemToStringLabel]);
return (
<PopoverTrigger asChild disabled={disabled}>
<Button
variant="outline"
role="combobox"
disabled={disabled}
data-testid="combobox-trigger-button"
className={cn(
"h-8 w-full justify-between !bg-transparent font-normal active:scale-none",
!value && "text-muted-foreground",
className,
)}
>
<span className="truncate">{displayValue || placeholder || "Select..."}</span>
<div className="ml-2 flex shrink-0 items-center gap-1">
{showClear && value && (
<button
type="button"
aria-label="Clear selection"
data-testid="combobox-clear-button"
className="rounded-sm opacity-50 hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onValueChange(null);
}}
>
<XIcon className="size-3.5" />
</button>
)}
{showTrigger && <ChevronDownIcon className="size-4 opacity-50" />}
</div>
</Button>
</PopoverTrigger>
);
}
// ---------------------------------------------------------------------------
// ComboboxContent — popover dropdown with Command
// ---------------------------------------------------------------------------
function ComboboxContent({
className,
children,
...props
}: {
className?: string;
children?: React.ReactNode;
anchor?: React.RefObject<HTMLElement | null>;
[key: string]: any;
}) {
const { filter } = useComboboxContext();
return (
<PopoverContent
className={cn("w-[var(--radix-popover-trigger-width)] p-0", className)}
align="start"
sideOffset={4}
onOpenAutoFocus={(e) => e.preventDefault()}
{...props}
>
<CommandPrimitive
filter={
filter === null
? () => 1 // disable internal filtering — consumer controls it
: (filter ?? undefined)
}
>
{children}
</CommandPrimitive>
</PopoverContent>
);
}
function ComboboxList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
const { inputValue, setInputValue } = useComboboxContext();
return (
<>
<div className="flex items-center border-b px-3">
<CommandPrimitive.Input
placeholder="Search..."
className="placeholder:text-muted-foreground flex h-8 w-full bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50"
value={inputValue}
autoFocus
onValueChange={setInputValue}
/>
</div>
<CommandPrimitive.List data-slot="combobox-list" className={cn("max-h-[300px] overflow-y-auto p-1", className)} {...props} />
</>
);
}
function ComboboxItem({
className,
children,
value: itemValue,
...props
}: {
className?: string;
children?: React.ReactNode;
value: string;
[key: string]: any;
}) {
const { value: selectedValue, onValueChange, setOpen, multiple } = useComboboxContext();
const isSelected = Array.isArray(selectedValue) ? selectedValue.includes(itemValue) : selectedValue === itemValue;
return (
<CommandPrimitive.Item
data-slot="combobox-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
className,
)}
value={itemValue}
onSelect={() => {
if (multiple) {
const current = Array.isArray(selectedValue) ? selectedValue : [];
const next = isSelected ? current.filter((v) => v !== itemValue) : [...current, itemValue];
onValueChange(next);
return;
}
onValueChange(isSelected ? null : itemValue);
setOpen(false);
}}
{...props}
>
{children}
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
{isSelected && <CheckIcon className="size-4" />}
</span>
</CommandPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return <CommandPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />;
}
function ComboboxLabel({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="combobox-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs font-medium", className)} {...props} />;
}
function ComboboxSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return <CommandPrimitive.Separator data-slot="combobox-separator" className={cn("bg-border -mx-1 my-1 h-px", className)} {...props} />;
}
function ComboboxEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty data-slot="combobox-empty" className={cn("py-6 text-center text-sm", className)} {...props} />;
}
interface ComboboxSelectOption {
label: string;
value: string;
}
interface ComboboxSelectBaseProps {
options: ComboboxSelectOption[];
placeholder?: string;
disabled?: boolean;
disableSearch?: boolean;
hideClear?: boolean;
className?: string;
emptyMessage?: string;
}
interface ComboboxSelectSingleProps extends ComboboxSelectBaseProps {
multiple?: false;
value?: string | null;
onValueChange?: (value: string | null) => void;
}
interface ComboboxSelectMultiProps extends ComboboxSelectBaseProps {
multiple: true;
value?: string[];
onValueChange?: (value: string[]) => void;
}
type ComboboxSelectProps = ComboboxSelectSingleProps | ComboboxSelectMultiProps;
function ComboboxSelect(props: ComboboxSelectProps) {
const {
options,
placeholder = "Select…",
disabled = false,
disableSearch = false,
className,
emptyMessage = "No results found.",
} = props;
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const filtered = React.useMemo(() => {
if (disableSearch || !query) return options;
const q = query.toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(q));
}, [options, query, disableSearch]);
const getLabel = React.useCallback((val: string | null) => options.find((o) => o.value === val)?.label ?? val ?? "", [options]);
// Multi-select variant
if (props.multiple) {
const selectedValues = props.value ?? [];
return (
<Popover
open={open}
onOpenChange={(v) => {
setOpen(v);
if (v) setQuery("");
}}
>
<PopoverTrigger asChild disabled={disabled}>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"h-8 w-full justify-between !bg-transparent font-normal active:scale-none",
selectedValues.length === 0 && "text-muted-foreground",
className,
)}
>
<div className="flex flex-1 flex-wrap gap-1 overflow-hidden">
{selectedValues.length === 0 ? (
<span>{placeholder}</span>
) : (
selectedValues.map((val) => (
<Badge key={val} variant="secondary" className="text-xs">
{getLabel(val)}
<button
type="button"
aria-label={`Remove ${getLabel(val)}`}
data-testid={`combobox-remove-${val}`}
className="ml-1 rounded-full opacity-50 outline-none hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
props.onValueChange?.(selectedValues.filter((v) => v !== val));
}}
>
<XIcon className="size-3" />
</button>
</Badge>
))
)}
</div>
<ChevronDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" sideOffset={1}>
<CommandPrimitive filter={() => 1}>
{!disableSearch && (
<div className="flex items-center border-b px-3">
<CommandPrimitive.Input
placeholder="Search..."
className="placeholder:text-muted-foreground flex h-8 w-full bg-transparent py-3 text-sm outline-none"
value={query}
onValueChange={setQuery}
/>
</div>
)}
<CommandPrimitive.List className="max-h-[300px] overflow-y-auto p-1">
{filtered.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<CommandPrimitive.Item
key={option.value}
value={option.value}
className="data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none"
onSelect={() => {
const next = isSelected ? selectedValues.filter((v) => v !== option.value) : [...selectedValues, option.value];
props.onValueChange?.(next);
}}
>
{option.label}
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
{isSelected && <CheckIcon className="size-4" />}
</span>
</CommandPrimitive.Item>
);
})}
{!disableSearch && filtered.length === 0 && (
<div className="text-muted-foreground py-6 text-center text-sm">{emptyMessage}</div>
)}
</CommandPrimitive.List>
</CommandPrimitive>
</PopoverContent>
</Popover>
);
}
// Single-select variant
const selectedLabel = props.value ? getLabel(props.value) : null;
return (
<Popover
open={open}
onOpenChange={(v) => {
setOpen(v);
if (v) setQuery("");
}}
>
<PopoverTrigger asChild disabled={disabled}>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"h-8 w-full justify-between !bg-transparent font-normal active:scale-none",
!selectedLabel && "text-muted-foreground",
className,
)}
>
<span className="truncate">{selectedLabel || placeholder}</span>
<div className="ml-2 flex shrink-0 items-center gap-1">
{!props.hideClear && props.value && (
<button
type="button"
aria-label="Clear selection"
data-testid="combobox-select-clear-button"
className="rounded-sm opacity-50 hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
props.onValueChange?.(null);
setOpen(false);
}}
>
<XIcon className="size-3.5" />
</button>
)}
<ChevronDownIcon className="size-4 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" sideOffset={4}>
<CommandPrimitive filter={() => 1}>
{!disableSearch && (
<div className="flex items-center border-b px-3">
<CommandPrimitive.Input
placeholder="Search..."
className="placeholder:text-muted-foreground flex h-8 w-full bg-transparent py-3 text-sm outline-none"
value={query}
onValueChange={setQuery}
/>
</div>
)}
<CommandPrimitive.List className="max-h-[300px] overflow-y-auto p-1">
{filtered.map((option) => (
<CommandPrimitive.Item
key={option.value}
value={option.value}
className="data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none"
onSelect={() => {
props.onValueChange?.(option.value);
setOpen(false);
}}
>
{option.label}
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
{props.value === option.value && <CheckIcon className="size-4" />}
</span>
</CommandPrimitive.Item>
))}
{!disableSearch && filtered.length === 0 && (
<div className="text-muted-foreground py-6 text-center text-sm">{emptyMessage}</div>
)}
</CommandPrimitive.List>
</CommandPrimitive>
</PopoverContent>
</Popover>
);
}
export {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxSelect,
ComboboxSeparator,
};
export type { ComboboxSelectOption, ComboboxSelectProps };

View File

@@ -0,0 +1,112 @@
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import * as React from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn("bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-sm", className)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton}>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-sm bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
{...props}
/>
);
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />;
}
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return <CommandPrimitive.Separator data-slot="command-separator" className={cn("bg-border -mx-1 h-px", className)} {...props} />;
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span data-slot="command-shortcut" className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props} />
);
}
export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut };

View File

@@ -0,0 +1,21 @@
import { Info } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface ConfigSyncAlertProps {
className?: string;
}
export function ConfigSyncAlert({ className }: ConfigSyncAlertProps) {
return (
<Alert variant="info" className={className}>
<Info className="h-4 w-4" />
<AlertDescription>
<p>
This config is synced from <code className="bg-muted rounded px-1 py-0.5 font-mono text-xs">config.json</code>. Any future updates
to config.json will overwrite UI changes. If you are using config.json to bootstrap the initial config, you can ignore this alert.
</p>
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,40 @@
/**
* Action Button Component for CEL Rule Builder
* Used for Add/Remove actions in query builder
*/
import { Button } from "@/components/ui/button";
import { Plus, X } from "lucide-react";
import { ActionProps } from "react-querybuilder";
export function ActionButton({ handleOnClick, label, className, title }: ActionProps) {
const labelStr = typeof label === "string" ? label : "";
const labelLower = labelStr.toLowerCase();
const isAddButton = labelLower.includes("add");
const isRemoveButton =
labelLower.includes("remove") ||
labelLower === "x" ||
labelStr === "x" ||
label?.toString().toLowerCase() === "x" ||
title === "Remove rule" ||
title === "Remove group";
// Icon-only remove button needs an accessible name (no visible label is rendered)
const iconOnly = isRemoveButton;
const ariaLabel = iconOnly ? labelStr?.trim() || (typeof title === "string" ? title.trim() : "") || "Remove" : undefined;
return (
<Button
type="button"
onClick={(e) => handleOnClick(e)}
variant={isRemoveButton ? "ghost" : "outline"}
size="sm"
className={className}
aria-label={ariaLabel}
>
{isRemoveButton && <X className="h-4 w-4" />}
{isAddButton && <Plus className="mr-1 h-4 w-4" />}
{!isRemoveButton && label}
</Button>
);
}

View File

@@ -0,0 +1,185 @@
/**
* CEL Rule Builder Component
* Reusable visual query builder for creating CEL expressions
*/
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Field, QueryBuilder, RuleGroupType } from "react-querybuilder";
import "react-querybuilder/dist/query-builder.css";
import { ActionButton } from "./actionButton";
import { CombinatorSelector } from "./combinatorSelector";
import { FieldSelector } from "./fieldSelector";
import { OperatorSelector } from "./operatorSelector";
import { QueryBuilderWrapper } from "./queryBuilderWrapper";
import { ValueEditor } from "./valueEditor";
export interface CELFieldDefinition {
name: string;
label: string;
placeholder?: string;
inputType?: string;
valueEditorType?: string | ((operator: string) => string);
operators?: string[];
defaultOperator?: string;
defaultValue?: any;
values?: Array<{ name: string; label: string; disabled?: boolean }>;
metricOptions?: Array<{ name: string; label: string }>;
description?: string;
}
export interface CELOperatorDefinition {
name: string;
label: string;
celSyntax: string;
}
export interface CELRuleBuilderProps {
onChange?: (celExpression: string, query: RuleGroupType) => void;
initialQuery?: RuleGroupType;
isLoading?: boolean;
/** Fields available in the query builder */
fields: CELFieldDefinition[];
/** Operators available in the query builder */
operators: CELOperatorDefinition[];
/** Function to convert a RuleGroupType to a CEL expression string */
convertToCEL: (ruleGroup: RuleGroupType) => string;
/** Optional regex validation function, passed to ValueEditor via context */
validateRegex?: (pattern: string) => string | null;
/** Additional context passed to the QueryBuilder controlElements */
builderContext?: Record<string, any>;
options?: {
hideCELExpression?: boolean;
};
}
const defaultQuery: RuleGroupType = {
combinator: "and",
rules: [],
};
export function CELRuleBuilder({
onChange,
initialQuery,
isLoading = false,
fields: fieldDefinitions,
operators,
convertToCEL,
validateRegex,
builderContext,
options = {
hideCELExpression: false,
},
}: CELRuleBuilderProps) {
const [query, setQuery] = useState<RuleGroupType>(initialQuery || defaultQuery);
const [celExpression, setCelExpression] = useState("");
const onChangeRef = useRef(onChange);
const convertToCELRef = useRef(convertToCEL);
const { copy, copied } = useCopyToClipboard();
// Keep refs updated so the query effect always invokes the latest callbacks
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
convertToCELRef.current = convertToCEL;
}, [convertToCEL]);
// Convert field definitions to react-querybuilder Field format
const fields = useMemo(() => {
return fieldDefinitions.map((field) => ({
...field,
value: field.name,
})) as Field[];
}, [fieldDefinitions]);
useEffect(() => {
const expression = convertToCELRef.current(query);
setCelExpression(expression);
onChangeRef.current?.(expression, query);
}, [query]);
// Show loading state
if (isLoading) {
return (
<div className="flex items-center justify-center space-x-2 rounded-md border p-8">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-muted-foreground text-sm">Loading CEL builder...</span>
</div>
);
}
const context = {
...builderContext,
...(validateRegex ? { validateRegex } : {}),
};
return (
<div className="space-y-4">
<div className="rounded-md border">
<div className="custom-scrollbar flex w-full flex-col overflow-scroll">
<QueryBuilderWrapper>
<QueryBuilder
fields={fields}
query={query}
onQueryChange={setQuery}
context={context}
controlClassnames={{ queryBuilder: "queryBuilder-branches" }}
operators={operators.map((op) => ({
name: op.name,
label: op.label,
}))}
controlElements={{
fieldSelector: FieldSelector,
operatorSelector: OperatorSelector,
valueEditor: ValueEditor,
addRuleAction: ActionButton,
addGroupAction: ActionButton,
removeRuleAction: ActionButton,
removeGroupAction: ActionButton,
combinatorSelector: CombinatorSelector,
}}
translations={{
addRule: { label: "Add Rule" },
addGroup: { label: "Add Rule Group" },
}}
/>
</QueryBuilderWrapper>
</div>
</div>
{!options.hideCELExpression && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>CEL Expression Preview</Label>
<Button
variant="outline"
size="sm"
onClick={() => copy(celExpression)}
disabled={!celExpression}
className="gap-2"
type="button"
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
</div>
<Textarea value={celExpression || "No rules defined yet"} readOnly className="font-mono text-sm" rows={4} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,29 @@
/**
* Combinator Selector Component for CEL Rule Builder
* Allows selection of AND/OR combinators between rules
*/
import { Button } from "@/components/ui/button";
import { CombinatorSelectorProps } from "react-querybuilder";
export function CombinatorSelector({ value, handleOnChange, options }: CombinatorSelectorProps) {
return (
<div className="flex gap-1">
{options.map((option) => {
if ("options" in option) return null; // Skip option groups
return (
<Button
key={option.name}
type="button"
variant={value === option.name ? "default" : "outline"}
size="sm"
onClick={() => handleOnChange(option.name)}
className="px-3"
>
{option.label.toUpperCase()}
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,110 @@
/**
* Field Selector Component for CEL Rule Builder
* Allows selection of fields for building CEL expressions
* For keyValue fields (headers/params), also renders "has value" label and key input
*/
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useCallback, useMemo } from "react";
import { FieldSelectorProps, RuleGroupType, RuleType } from "react-querybuilder";
/**
* Recursively find and update a rule's value by path in the query tree.
*/
function updateRuleValueAtPath(query: RuleGroupType, targetPath: number[], newValue: string): RuleGroupType {
if (targetPath.length === 0) return query;
const [currentIndex, ...restPath] = targetPath;
const newRules = [...query.rules];
if (restPath.length === 0) {
// We're at the target rule
const rule = newRules[currentIndex] as RuleType;
newRules[currentIndex] = { ...rule, value: newValue };
} else {
// Recurse into nested group
newRules[currentIndex] = updateRuleValueAtPath(newRules[currentIndex] as RuleGroupType, restPath, newValue);
}
return { ...query, rules: newRules };
}
export function FieldSelector({ value, handleOnChange, options, rule, path, schema }: FieldSelectorProps) {
// Check if this is a keyValue field (headers/params)
const fieldData = useMemo(() => schema?.fields?.find((f) => "value" in f && f.value === value), [schema?.fields, value]);
const isKeyValueField = fieldData && "inputType" in fieldData && fieldData.inputType === "keyValue";
// Parse the key from the rule's value ("key:value" or just "key")
const headerKey = useMemo(() => {
if (!isKeyValueField || !rule?.value || typeof rule.value !== "string") return "";
const colonIndex = rule.value.indexOf(":");
if (colonIndex > 0) return rule.value.substring(0, colonIndex).trim();
return rule.value.trim();
}, [isKeyValueField, rule?.value]);
const handleKeyChange = useCallback(
(newKey: string) => {
if (!schema || !path) return;
// Preserve the existing value part
const currentValue = typeof rule?.value === "string" ? rule.value : "";
const colonIndex = currentValue.indexOf(":");
const valuePart = colonIndex > 0 ? currentValue.substring(colonIndex + 1).trim() : "";
let updatedValue: string;
if (newKey && valuePart) {
updatedValue = `${newKey}:${valuePart}`;
} else if (newKey) {
updatedValue = newKey;
} else {
updatedValue = "";
}
// Update the rule value via query dispatch
const currentQuery = schema.getQuery() as RuleGroupType;
const updatedQuery = updateRuleValueAtPath(currentQuery, path, updatedValue);
schema.dispatchQuery(updatedQuery);
},
[schema, path, rule?.value],
);
return (
<div className="flex items-center gap-2">
<Select value={value || ""} onValueChange={handleOnChange}>
<SelectTrigger className="w-[180px]" data-testid="cel-builder-field-selector-select">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
{options.map((option) => {
// Handle option groups (not currently used, but type-safe)
if ("options" in option) {
return null;
}
// Handle regular options - skip empty values
if (!option.name) {
return null;
}
return (
<SelectItem key={option.name} value={option.name} disabled={option.disabled}>
{option.label}
</SelectItem>
);
})}
</SelectContent>
</Select>
{isKeyValueField && (
<>
<span className="text-muted-foreground text-sm whitespace-nowrap">has key</span>
<Input
type="text"
value={headerKey}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder={`${fieldData?.label || "Key"} name (e.g., x-api-key)`}
className="w-[180px]"
data-testid="cel-builder-field-selector-key-input"
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { CELRuleBuilder } from "./celRuleBuilder";
export type { CELRuleBuilderProps, CELFieldDefinition, CELOperatorDefinition } from "./celRuleBuilder";

View File

@@ -0,0 +1,34 @@
/**
* Operator Selector Component for CEL Rule Builder
* Allows selection of operators for CEL expressions
*/
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { OperatorSelectorProps } from "react-querybuilder";
export function OperatorSelector({ value, handleOnChange, options }: OperatorSelectorProps) {
return (
<Select value={value || ""} onValueChange={handleOnChange}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Select operator..." />
</SelectTrigger>
<SelectContent>
{options.map((option) => {
// Handle option groups (not currently used, but type-safe)
if ("options" in option) {
return null;
}
// Handle regular options - skip empty values
if (!option.name) {
return null;
}
return (
<SelectItem key={option.name} value={option.name} disabled={option.disabled}>
{option.label}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,97 @@
.query-builder-wrapper .queryBuilder {
font-family: inherit;
}
.query-builder-wrapper .ruleGroup {
background-color: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
margin-left: 0.07rem;
margin-bottom: 0.5rem;
}
.query-builder-wrapper .ruleGroup .ruleGroup {
background-color: hsl(var(--background));
}
.query-builder-wrapper .ruleGroup-header {
display: flex;
gap: 0.5rem;
padding-bottom: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.query-builder-wrapper .ruleGroup-body {
display: flex;
flex-direction: column;
padding-left: 1rem;
}
.query-builder-wrapper .rule {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.5rem;
background-color: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
}
.query-builder-wrapper .rule > * {
flex-shrink: 0;
}
.query-builder-wrapper .ruleGroup-addRule,
.query-builder-wrapper .ruleGroup-addGroup {
margin-top: 0.5rem;
}
.query-builder-wrapper .ruleGroup-header .ruleGroup-addRule,
.query-builder-wrapper .ruleGroup-header .ruleGroup-addGroup {
margin-top: 0;
}
.query-builder-wrapper > .queryBuilder > .ruleGroup > .ruleGroup-header .ruleGroup-remove {
margin-left: auto;
}
.query-builder-wrapper .ruleGroup .ruleGroup .ruleGroup-header .ruleGroup-remove {
margin-left: 0.5rem;
}
/* Branch line styles */
.query-builder-wrapper .queryBuilder-branches .ruleGroup-body {
padding-left: 1rem;
}
.query-builder-wrapper .queryBuilder-branches .rule,
.query-builder-wrapper .queryBuilder-branches .ruleGroup {
position: relative;
}
.query-builder-wrapper .queryBuilder-branches .rule::before,
.query-builder-wrapper .queryBuilder-branches .rule::after,
.query-builder-wrapper .queryBuilder-branches .ruleGroup::before,
.query-builder-wrapper .queryBuilder-branches .ruleGroup::after {
content: "";
position: absolute;
background-color: hsl(var(--border));
}
/* Validation styles */
.query-builder-wrapper .validateQuery .queryBuilder .ruleGroup.queryBuilder-invalid {
background-color: color-mix(in srgb, rebeccapurple, transparent 60%);
}
.query-builder-wrapper .validateQuery .queryBuilder .ruleGroup.queryBuilder-invalid .ruleGroup-addRule {
font-weight: bold !important;
}
.query-builder-wrapper .validateQuery .queryBuilder .rule.queryBuilder-invalid .rule-value {
background-color: color-mix(in srgb, rebeccapurple, transparent 60%);
}
.query-builder-wrapper .validateQuery .queryBuilder .rule.queryBuilder-invalid .rule-value::placeholder {
color: color-mix(in srgb, rebeccapurple, black 30%);
}

View File

@@ -0,0 +1,15 @@
/**
* Query Builder Wrapper Component
* Provides styled wrapper with custom CSS for react-querybuilder
*/
import { ReactNode } from "react";
import "./queryBuilderWrapper.css";
interface QueryBuilderWrapperProps {
children: ReactNode;
}
export function QueryBuilderWrapper({ children }: QueryBuilderWrapperProps) {
return <div className="query-builder-wrapper">{children}</div>;
}

View File

@@ -0,0 +1,321 @@
/**
* Value Editor Component for CEL Rule Builder
* Smart input component that adapts based on operator and field type
*/
import { AsyncMultiSelect } from "@/components/ui/asyncMultiselect";
import { Input } from "@/components/ui/input";
import { ModelMultiselect } from "@/components/ui/modelMultiselect";
import { Option } from "@/components/ui/multiselectUtils";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
import { getProviderLabel } from "@/lib/constants/logs";
import { useEffect, useState } from "react";
import { ValueEditorProps, ValueEditorType } from "react-querybuilder";
type CELValueEditorContext = {
validateRegex?: (pattern: string) => string | null;
menuPosition?: "absolute" | "fixed";
menuPortalTarget?: HTMLElement | null;
};
export function ValueEditor({
value,
handleOnChange,
operator,
fieldData,
type,
context,
}: ValueEditorProps & { context?: CELValueEditorContext }) {
// Compute all conditions upfront before any early returns
const isArrayOperator = operator === "in" || operator === "notIn";
const isRegexOperator = operator === "matches";
const isNullOperator = operator === "null" || operator === "notNull";
const validateRegex = context?.validateRegex;
const menuPosition = context?.menuPosition;
const menuPortalTarget = context?.menuPortalTarget;
// Get valueEditorType, handling both string and function types
const valueEditorType =
typeof fieldData?.valueEditorType === "function" ? fieldData.valueEditorType(operator) : fieldData?.valueEditorType;
const isKeyValueType = valueEditorType === ("keyValue" as ValueEditorType);
const isSelectType = valueEditorType === ("select" as ValueEditorType);
// Parse keyValue format: "key:value" or just "key" for null/notNull operators
const [keyValuePair, setKeyValuePair] = useState(() => {
if (!isKeyValueType) return { key: "", value: "" };
if (typeof value === "string" && value) {
if (value.includes(":")) {
const parts = value.split(":");
const key = parts[0] || "";
const valuePart = parts.slice(1).join(":") || "";
return { key: key.trim(), value: valuePart.trim() };
}
// Key-only value (for null/notNull operators)
return { key: value.trim(), value: "" };
}
return { key: "", value: "" };
});
useEffect(() => {
if (isKeyValueType && typeof value === "string" && value) {
if (value.includes(":")) {
const parts = value.split(":");
const key = parts[0] || "";
const valuePart = parts.slice(1).join(":") || "";
setKeyValuePair({ key: key.trim(), value: valuePart.trim() });
} else {
// Key-only value (for null/notNull operators)
setKeyValuePair({ key: value.trim(), value: "" });
}
} else {
setKeyValuePair({ key: "", value: "" });
}
}, [value, isKeyValueType]);
// For null/notNull operators, no value input needed
// (keyValue fields show key input in FieldSelector)
if (isNullOperator) {
return null;
}
// For keyValue fields, the key input is in FieldSelector.
// Here we handle updating the value part while preserving the key.
const handleKeyValueValueChange = (newValue: string) => {
const key = keyValuePair.key;
setKeyValuePair({ ...keyValuePair, value: newValue });
if (key && newValue) {
handleOnChange(`${key}:${newValue}`);
} else if (key) {
handleOnChange(key);
} else {
handleOnChange("");
}
};
// Handle model field with ModelMultiselect
const isModelField = fieldData?.name === "model";
if (isModelField && isSelectType) {
// For array operators (in, notIn), use multi-select
if (isArrayOperator) {
let selectedModels: string[] = [];
if (typeof value === "string" && value) {
try {
selectedModels = JSON.parse(value);
if (!Array.isArray(selectedModels)) {
selectedModels = [value];
}
} catch {
selectedModels = value
.split(",")
.map((v) => v.trim())
.filter((v) => v);
}
}
const handleMultiModelChange = (selected: string[]) => {
handleOnChange(selected.length > 0 ? JSON.stringify(selected) : "");
};
return (
<ModelMultiselect
value={selectedModels}
onChange={handleMultiModelChange}
placeholder="Select models..."
loadModelsOnEmptyProvider
className="!min-h-9 w-[360px]"
menuPosition={menuPosition}
menuPortalTarget={menuPortalTarget}
/>
);
}
let valueToUse = value;
if (typeof value === "string" && value) {
try {
const parsedValue = JSON.parse(value);
if (Array.isArray(parsedValue)) {
valueToUse = parsedValue[0];
} else if (typeof parsedValue === "string") {
valueToUse = parsedValue;
}
} catch(error) {}
}
// For single operators (=, !=), use single select
return (
<ModelMultiselect
value={valueToUse || ""}
onChange={handleOnChange}
placeholder="Search for a model..."
isSingleSelect
clearable={true}
loadModelsOnEmptyProvider
className="border-input w-[360px]"
menuPosition={menuPosition}
menuPortalTarget={menuPortalTarget}
/>
);
}
// Handle select type (for provider dropdown)
if (isSelectType && fieldData?.values) {
// For array operators with provider, use multi-select dropdown
if (isArrayOperator) {
// Parse comma-separated or JSON array value
let selectedValues: string[] = [];
if (typeof value === "string" && value) {
try {
// Try parsing as JSON array first
selectedValues = JSON.parse(value);
if (!Array.isArray(selectedValues)) {
selectedValues = [value];
}
} catch {
// Fall back to comma-separated
selectedValues = value
.split(",")
.map((v) => v.trim())
.filter((v) => v);
}
}
const selectedOptions: Option<string>[] = selectedValues.map((val) => ({
value: val,
label: (fieldData.values as any[]).find((opt) => (opt as any).name === val)?.label || val,
}));
const allOptions: Option<string>[] = (fieldData.values as any[])
.filter((opt) => !("options" in opt) && (opt as any).name)
.map((opt) => ({
value: (opt as any).name,
label: (opt as any).label,
}));
const handleMultiselectChange = (selected: Option<string>[]) => {
const values = selected.map((opt) => opt.value);
handleOnChange(values.length > 0 ? JSON.stringify(values) : "");
};
return (
<AsyncMultiSelect
value={selectedOptions}
onChange={handleMultiselectChange}
defaultOptions={allOptions}
isNonAsync={true}
isClearable={false}
placeholder="Select providers..."
className="w-[360px]"
triggerClassName="!shadow-none !border-border h-10"
menuClassName="!z-[100] w-full cursor-pointer"
/>
);
}
// Check if this is a provider field to render icons in trigger
const isProviderField = fieldData?.name === "provider";
return (
<Select value={value || ""} onValueChange={handleOnChange}>
<SelectTrigger className="w-[360px]">
{isProviderField && value ? (
<div className="flex items-center gap-2">
<RenderProviderIcon provider={value as ProviderIconType} size="sm" className="h-4 w-4" />
<span>{getProviderLabel(value)}</span>
</div>
) : (
<SelectValue placeholder={fieldData.placeholder || "Select..."} />
)}
</SelectTrigger>
<SelectContent>
{fieldData.values.map((option) => {
if ("options" in option) return null; // Skip option groups
const optName = (option as any).name || "";
if (!optName) return null; // Skip empty values — SelectItem requires non-empty value
const optLabel = (option as any).label || optName;
const optDisabled = (option as any).disabled || false;
let iconElement: React.ReactNode | undefined;
let displayLabel = optLabel;
if (isProviderField) {
iconElement = <RenderProviderIcon provider={optName as ProviderIconType} size="sm" className="h-4 w-4" />;
displayLabel = getProviderLabel(optName);
}
return (
<SelectItem key={optName} value={optName} disabled={optDisabled} icon={iconElement}>
{displayLabel}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
// Handle keyValue type (for header and parameter)
// Key input is rendered in FieldSelector, only show value input here
if (isKeyValueType) {
return (
<Input
type="text"
value={keyValuePair.value}
onChange={(e) => handleKeyValueValueChange(e.target.value)}
placeholder="Value"
className="w-[180px]"
data-testid="cel-builder-keyvalue-value-input"
/>
);
}
const placeholder = isArrayOperator
? "Enter comma-separated values or JSON array"
: isRegexOperator
? "e.g., .* (any), openai|anthropic (multiple), ^gpt.* (prefix)"
: fieldData?.placeholder || "Enter value...";
// Use textarea for array inputs
if (isArrayOperator) {
return (
<Textarea
value={value || ""}
onChange={(e) => handleOnChange(e.target.value)}
placeholder={placeholder}
className="min-h-[80px] w-[360px] font-mono text-sm"
/>
);
}
// Use text input with validation for regex
if (isRegexOperator) {
const regexError = validateRegex && value ? validateRegex(String(value)) : null;
return (
<div className="flex flex-col gap-1">
<Input
type="text"
value={value || ""}
onChange={(e) => handleOnChange(e.target.value)}
placeholder={placeholder}
className={`w-[360px] font-mono text-sm ${regexError ? "border-red-500 bg-red-50 dark:bg-red-950" : ""}`}
/>
{regexError && <p className="text-xs text-red-600 dark:text-red-400">{regexError}</p>}
</div>
);
}
// Use regular input for single values
return (
<Input
type={type === ("number" as ValueEditorType) ? "number" : "text"}
value={value || ""}
onChange={(e) => handleOnChange(e.target.value)}
placeholder={placeholder}
className="w-[360px]"
/>
);
}

View File

@@ -0,0 +1,169 @@
import { cn } from "@/lib/utils";
import React, { useEffect, useMemo, useRef } from "react";
import { DropdownGroup } from "./dropdownGroup";
import { DropdownItem } from "./dropdownItem";
import { DropdownOption, FlattenedDropdownOption } from "./types";
interface CustomDropdownProps<T = {}> {
options: DropdownOption<T>[];
onChange?: (value: DropdownOption<T> | undefined) => void;
defaultValue?: DropdownOption<T>;
value?: DropdownOption<T>;
className?: string;
selectFirstOptionByDefault?: boolean;
style?: React.CSSProperties;
emptyViewText?: string;
groupHeadingClassName?: string;
selectedIndex?: number;
onHover?: (index: number) => void;
}
export function CustomDropdown<T = {}>({
options,
onChange,
style,
className,
selectFirstOptionByDefault,
emptyViewText,
groupHeadingClassName,
selectedIndex: controlledSelectedIndex,
onHover,
}: CustomDropdownProps<T>) {
const [internalSelectedIndex, setInternalSelectedIndex] = React.useState(selectFirstOptionByDefault ? 0 : -1);
const isControlled = controlledSelectedIndex !== undefined;
const selectedIndex = isControlled ? controlledSelectedIndex : internalSelectedIndex;
const containerRef = useRef<HTMLDivElement>(null);
const flattenedOptions = useMemo(() => {
return options.reduce<FlattenedDropdownOption[]>((acc, option, parentIndex) => {
if (option.type === "group" && option.options) {
const groupOptions = option.options.map((groupOption, groupIndex) => ({
option: groupOption,
groupIndex,
parentIndex,
}));
return [...acc, ...groupOptions];
}
return [...acc, { option, parentIndex, groupIndex: undefined }];
}, []);
}, [options]);
useEffect(() => {
if (isControlled) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setInternalSelectedIndex((prev) => (prev < flattenedOptions.length - 1 ? prev + 1 : prev));
break;
case "ArrowUp":
e.preventDefault();
setInternalSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;
case "Enter":
e.preventDefault();
e.stopPropagation();
if (selectedIndex >= 0 && selectedIndex < flattenedOptions.length) {
const newValue = flattenedOptions[selectedIndex].option;
onChange?.(newValue);
}
break;
case "Escape":
e.preventDefault();
// onChange?.(undefined);
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [flattenedOptions, selectedIndex, onChange, isControlled]);
const isOptionSelected = (option: DropdownOption, groupIndex?: number, parentIndex?: number): boolean => {
const selectedItem = flattenedOptions[selectedIndex];
if (!selectedItem) return false;
if (groupIndex !== undefined && parentIndex !== undefined) {
return selectedItem.groupIndex === groupIndex && selectedItem.parentIndex === parentIndex;
}
return selectedItem.option === option;
};
useEffect(() => {
// Resetting the selected index if the options change
if (!isControlled && internalSelectedIndex != (selectFirstOptionByDefault ? 0 : -1)) {
setInternalSelectedIndex(selectFirstOptionByDefault ? 0 : -1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flattenedOptions, isControlled, selectFirstOptionByDefault]);
const handleSelectItem = (selectedOption: DropdownOption) => {
onChange?.(selectedOption);
};
useEffect(() => {
if (selectedIndex === flattenedOptions.length - 1) {
containerRef.current?.parentElement?.scrollTo({ top: containerRef.current.scrollHeight + 100, behavior: "smooth" });
} else if (selectedIndex === 0) {
containerRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });
}
}, [flattenedOptions, selectedIndex]);
return (
<div className={cn("flex flex-col rounded-md border p-2", className)} ref={containerRef} style={style}>
{options.length === 0 ? (
<div className="w-[350px]">
<p className="text-content-secondary text-md">{emptyViewText}</p>
</div>
) : (
<>
{options.map((option, parentIndex) => {
if (option.type === "group") {
return (
<DropdownGroup
key={`group-${parentIndex}`}
onSelectItem={handleSelectItem}
label={option.label}
icon={option.icon}
options={option.options ?? []}
parentIndex={parentIndex}
isOptionSelected={(groupOption, groupIndex) => isOptionSelected(groupOption, groupIndex, parentIndex)}
onHover={(groupIndex) => {
const flatIndex = flattenedOptions.findIndex(
(item) => item.parentIndex === parentIndex && item.groupIndex === groupIndex,
);
if (flatIndex !== -1) {
if (!isControlled) setInternalSelectedIndex(flatIndex);
onHover?.(flatIndex);
}
}}
groupHeadingClassName={groupHeadingClassName}
/>
);
}
return (
<DropdownItem
field={option}
key={`item-${option.value ?? parentIndex}`}
onSelectItem={handleSelectItem}
isSelected={isOptionSelected(option)}
onHover={() => {
const flatIndex = flattenedOptions.findIndex((item) => item.option === option);
if (flatIndex !== -1) {
if (!isControlled) setInternalSelectedIndex(flatIndex);
onHover?.(flatIndex);
}
}}
description={option.description}
/>
);
})}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { cn } from "@/lib/utils";
import { Separator } from "../../separator";
import { DropdownItem } from "./dropdownItem";
import { DropdownOption } from "./types";
import { ReactNode } from "react";
interface DropdownGroupProps {
label?: string;
options: DropdownOption[];
onSelectItem: (option: DropdownOption) => void;
parentIndex: number;
isOptionSelected: (option: DropdownOption, groupIndex: number) => boolean;
onHover: (groupIndex: number) => void;
icon?: ReactNode;
groupHeadingClassName?: string;
}
export function DropdownGroup({
label,
options,
onSelectItem,
isOptionSelected,
onHover,
parentIndex,
icon,
groupHeadingClassName,
}: DropdownGroupProps) {
return (
<>
{parentIndex > 0 && <Separator className="bg-border mt-2 mb-4" />}
<div className="flex flex-col gap-1">
{label && (
<div className={cn("flex items-center gap-1", groupHeadingClassName)}>
{icon}
<p className="text-content-tertiary text-sm font-medium">{label}</p>
</div>
)}
<div className="flex flex-col">
{options.map((option, groupIndex) => (
<DropdownItem
key={option.value ?? groupIndex}
field={option}
onSelectItem={onSelectItem}
isSelected={isOptionSelected(option, groupIndex)}
onHover={() => onHover(groupIndex)}
description={option.description}
/>
))}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,66 @@
import { ReactNode, useEffect, useRef } from "react";
import { DropdownOption } from "./types";
import { cn } from "@/lib/utils";
interface DropdownItemProps<T extends DropdownOption> {
field: T;
onSelectItem: (field: T) => void;
isSelected?: boolean;
onHover?: () => void;
description?: ReactNode;
}
export function DropdownItem<T extends DropdownOption>({ field, onSelectItem, isSelected, onHover, description }: DropdownItemProps<T>) {
const itemRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isSelected && itemRef.current) {
itemRef.current.scrollIntoView({
block: "nearest",
inline: "nearest",
});
}
}, [isSelected]);
return (
<div
ref={itemRef}
role="option"
tabIndex={0}
aria-selected={isSelected}
className={cn(
"text-content-primary text-body-medium flex cursor-pointer items-center gap-1 rounded-sm px-2 py-1.5 font-normal outline-hidden select-none",
isSelected ? "bg-background-highlight-primary" : "",
)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelectItem(field);
}}
onTouchStart={(e) => {
e.preventDefault();
e.stopPropagation();
onSelectItem(field);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
onSelectItem(field);
}
}}
onMouseEnter={onHover}
onMouseLeave={() => {}}
>
{field.view ?? (
<div className="flex flex-col overflow-hidden overscroll-auto">
<div className="flex items-center gap-1">
{field.icon}
<span className="block truncate overscroll-auto">{field.label ?? field.value}</span>
</div>
{description && <span className="text-content-tertiary block truncate overscroll-auto text-xs">{description}</span>}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { CustomDropdown } from "./dropdown";
import { SearchableDropdown } from "./searchableDropdown";
import type { DropdownGroupOption, DropdownItemOption, DropdownOption, DropdownOptionBase, FlattenedDropdownOption } from "./types";
export {
CustomDropdown,
SearchableDropdown,
type DropdownGroupOption,
type DropdownItemOption,
type DropdownOption,
type DropdownOptionBase,
type FlattenedDropdownOption,
};

View File

@@ -0,0 +1,158 @@
import React, { useMemo, useState } from "react";
import { CustomDropdown } from "./dropdown";
import { DropdownOption } from "./types";
import { cn } from "@/lib/utils";
import { Icons } from "../../icons";
const EMPTY_SELECTED_VALUES: ReadonlyArray<DropdownOption<unknown>> = [];
interface SearchableDropdownProps<T = {}> {
options: DropdownOption<T>[];
onChange?: (value: DropdownOption<T> | undefined) => void;
defaultValue?: DropdownOption<T>;
value?: DropdownOption<T>;
className?: string;
selectFirstOptionByDefault?: boolean;
style?: React.CSSProperties;
emptyViewText?: string;
groupHeadingClassName?: string;
searchPlaceholder?: string;
searchClassName?: string;
noResultsText?: string;
maxHeight?: string | number;
dropdownClassName?: string;
removeEmptyGroups?: boolean;
hideSelectedValues?: boolean;
selectedValues?: DropdownOption<T>[];
}
export function SearchableDropdown<T = {}>({
options,
onChange,
defaultValue,
value,
className,
selectFirstOptionByDefault,
style,
emptyViewText,
groupHeadingClassName,
searchPlaceholder = "Search",
searchClassName,
noResultsText = "No results found",
maxHeight = "300px",
dropdownClassName,
removeEmptyGroups,
hideSelectedValues = false,
selectedValues,
...props
}: SearchableDropdownProps<T>) {
const [searchTerm, setSearchTerm] = useState("");
const selectedValuesSafe = selectedValues ?? EMPTY_SELECTED_VALUES;
const filteredOptions = useMemo(() => {
const searchTermLower = searchTerm.toLowerCase();
// Helper function to check if an option is selected
const isOptionSelected = (option: DropdownOption<T>): boolean => {
if (!hideSelectedValues) return false;
// Check against current value
if (value && option.value === value.value) return true;
// Check against selectedValues array
return selectedValuesSafe.some((selectedOption) => selectedOption.value === option.value);
};
const filterOption = (option: DropdownOption<T>): DropdownOption<T> | null => {
if (option.type === "group") {
const filteredGroupOptions = option.options?.map(filterOption).filter(Boolean) || [];
if ((removeEmptyGroups ?? true) && filteredGroupOptions.length === 0) {
return null;
}
return {
...option,
options: filteredGroupOptions as DropdownOption<T>[],
};
} else {
// First check if this option should be hidden because it's selected
if (isOptionSelected(option)) {
return null;
}
// Then check search term filtering
if (!searchTerm.trim()) {
return option;
}
const labelMatches = option.label?.toLowerCase().includes(searchTermLower);
const valueMatches = option.value?.toLowerCase().includes(searchTermLower);
const descriptionMatches =
typeof option.description === "string" ? option.description.toLowerCase().includes(searchTermLower) : false;
if (labelMatches || valueMatches || descriptionMatches) {
return option;
}
return null;
}
};
return options.map(filterOption).filter(Boolean) as DropdownOption<T>[];
}, [options, searchTerm, hideSelectedValues, value, selectedValuesSafe]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
};
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
e.preventDefault();
const dropdownContainer = e.currentTarget.parentElement?.querySelector('[role="listbox"]') as HTMLElement;
dropdownContainer?.focus?.();
}
};
return (
<div className={cn("flex flex-col", className)} style={style}>
<div className="relative mb-2">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Icons.search
className="h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
/>
</div>
<input
autoFocus
type="text"
placeholder={searchPlaceholder}
value={searchTerm}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
aria-label={searchPlaceholder}
className={cn(
"w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 text-sm",
"focus:outline-none",
"placeholder-gray-400",
searchClassName,
)}
/>
</div>
<div className="overflow-y-auto" style={{ maxHeight: typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight }}>
<CustomDropdown
options={filteredOptions}
onChange={onChange}
defaultValue={defaultValue}
value={value}
selectFirstOptionByDefault={selectFirstOptionByDefault}
emptyViewText={filteredOptions.length === 0 && searchTerm ? noResultsText : emptyViewText}
groupHeadingClassName={groupHeadingClassName}
className={cn("border-0 p-0", dropdownClassName)}
{...props}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { ReactNode } from "react";
export type DropdownOptionBase<T = {}> = {
label?: string;
icon?: ReactNode;
view?: ReactNode; // Optional view for items
onSelectItem?: (option: DropdownOption<T>) => void;
} & T;
export type DropdownItemOption<T = {}> = DropdownOptionBase<T> & {
type?: "item";
value: string; // Required for items
description?: ReactNode;
hidden?: boolean;
} & Record<string, any>;
export type DropdownGroupOption<T = {}> = DropdownOptionBase<T> & {
type: "group";
value?: never; // Not allowed for groups
description?: string;
options?: DropdownOption<T>[]; // Sub-options for groups
hidden?: boolean;
};
export type DropdownOption<T = {}> = DropdownItemOption<T> | DropdownGroupOption<T>;
export type FlattenedDropdownOption<T = {}> = {
option: DropdownOption<T>;
groupIndex?: number;
parentIndex: number;
};

View File

@@ -0,0 +1,14 @@
/* hide the up and down arrow for input field */
/* Chrome, Safari, Edge, Opera */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}

View File

@@ -0,0 +1,107 @@
import { useId } from "react";
import { Parameter } from "./types";
import { cn } from "@/lib/utils";
import ParameterFieldView from "./paramFieldView";
import { Switch } from "@/components/ui/switch";
import FieldLabel from "./fieldLabel";
interface Props {
field: Parameter;
config: Record<string, unknown>;
onChange: (value: unknown, overrides?: Record<string, unknown>) => void;
disabled?: boolean;
onClear?: () => void;
className?: string;
forceHideFields?: string[];
}
export default function BooleanFieldView(props: Props) {
const { field, config } = props;
const switchId = useId();
// use provided trueValue when present, otherwise default to true
const trueVal = field.trueValue !== undefined ? field.trueValue : true;
let value = false;
if (field.accesorKey) {
const parent = config[field.id] as Record<string, unknown> | undefined;
const v = parent ? parent[field.accesorKey] : undefined;
if (v !== undefined) value = v === trueVal;
} else {
if (config[field.id] !== undefined) value = config[field.id] === trueVal;
}
const onFieldChange = (fieldValue: boolean) => {
// When turning on => set to trueVal
if (fieldValue) {
const valToSet = trueVal;
const res = field.accesorKey ? { [field.accesorKey]: valToSet } : valToSet;
props.onChange(res);
return;
}
// Turning off => either remove the field or set to falseValue/false depending on config
const falseVal = field.falseValue !== undefined ? field.falseValue : false;
if (field.accesorKey) {
if (field.removeFieldOnFalse) {
props.onChange(undefined);
} else {
props.onChange({ [field.accesorKey]: falseVal });
}
} else {
if (field.removeFieldOnFalse) {
props.onChange(undefined);
} else {
props.onChange(falseVal);
}
}
};
const onSubFieldChange = (subFieldId: string, subFieldValue: unknown) => {
const falseVal = field.falseValue !== undefined ? field.falseValue : false;
const parentVal = value ? trueVal : field.removeFieldOnFalse ? undefined : falseVal;
if (field.accesorKey) {
const existing = config[field.id] && typeof config[field.id] === "object" ? (config[field.id] as Record<string, unknown>) : {};
props.onChange({
...existing,
[field.accesorKey]: parentVal,
[subFieldId]: subFieldValue,
});
} else {
// No accesorKey: keep field.id as primitive, pass subfield via overrides
props.onChange(parentVal, { [subFieldId]: subFieldValue });
}
};
const currentField = field.options?.find((f) => f.value === String(value));
return (
<div className={cn("flex flex-col gap-2", props.className)}>
<FieldLabel label={field.label} helpText={field.helpText} htmlFor={switchId} onClear={props.onClear}>
<Switch
id={switchId}
className="ml-auto"
onCheckedChange={(e) => onFieldChange(!!e)}
checked={!!value}
disabled={props.disabled && props.disabled === true}
/>
</FieldLabel>
{currentField?.subFields && (
<div className="mt-2">
{currentField.subFields.map((subField) => (
<ParameterFieldView
key={subField.id}
field={subField}
parentField={field}
config={config}
onChange={(fieldValue) => onSubFieldChange(subField.id, fieldValue)}
disabled={props.disabled && props.disabled === true}
forceHideFields={props.forceHideFields}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { HelpCircle, X } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import type { ReactNode } from "react";
interface FieldLabelProps {
label: string;
helpText?: string;
htmlFor?: string;
onClear?: () => void;
/** Extra content rendered after the label+help group (e.g. NumberInput) */
children?: ReactNode;
}
export default function FieldLabel({ label, helpText, htmlFor, onClear, children }: FieldLabelProps) {
return (
<div className="group/label flex flex-row items-center overflow-hidden">
<div className="flex h-4 grow flex-row items-center gap-1 pr-1">
<Label htmlFor={htmlFor} className="truncate">
{label}
</Label>
{helpText && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="text-content-disabled h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">{helpText}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{onClear && (
<Button
variant="ghost"
size="icon"
onClick={onClear}
className="text-muted-foreground hover:text-foreground h-4 w-4 opacity-0 transition-opacity group-hover/label:opacity-100"
title={`Clear ${label}`}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { useGetModelParametersQuery } from "@/lib/store/apis/providersApi";
import { Parameter, ParameterType } from "./types";
import ParameterFieldView from "./paramFieldView";
import { Skeleton } from "@/components/ui/skeleton";
import { useCallback, useEffect, useMemo, useRef } from "react";
const SUPPORTED_TYPES = new Set<string>(Object.values(ParameterType));
interface ModelParametersProps {
model: string;
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
disabled?: boolean;
/** Parameter IDs to exclude from rendering */
hideFields?: string[];
}
function ModelParametersSkeleton() {
return (
<div className="flex flex-col gap-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex flex-col gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-full" />
</div>
))}
</div>
);
}
export default function ModelParameters({ model, config, onChange, disabled, hideFields }: ModelParametersProps) {
const { data, isLoading, isError } = useGetModelParametersQuery(model, {
skip: !model,
});
// Ensure parameters belong to the current model (RTK Query may briefly return stale cached data)
const datasheetModel = data?.base_model;
const parameters = useMemo(() => {
if (!data?.model_parameters || isLoading) return [];
return data.model_parameters.filter((p) => SUPPORTED_TYPES.has(p.type));
}, [data, isLoading]);
// Clear config when switching models — values stay undefined until the user explicitly sets them
const prevModelRef = useRef(model);
useEffect(() => {
if (prevModelRef.current === model) return;
prevModelRef.current = model;
onChange({});
}, [model, datasheetModel, parameters, onChange]);
const handleFieldChange = useCallback(
(fieldId: string, value: any, overrides?: Record<string, any>) => {
const next = { ...config, ...overrides };
if (value === undefined) {
delete next[fieldId];
} else {
next[fieldId] = value;
}
onChange(next);
},
[config, onChange],
);
if (isLoading) return <ModelParametersSkeleton />;
if (isError || parameters.length === 0) return null;
return (
<div className="flex flex-col gap-6">
{parameters.map((param) => (
<ParameterFieldView
key={param.id}
field={param as Parameter}
config={config}
onChange={(value, overrides) => handleFieldChange(param.id, value, overrides)}
disabled={disabled}
forceHideFields={hideFields}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { CodeEditor } from "@/components/ui/codeEditor";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
import FieldLabel from "./fieldLabel";
import { Parameter } from "./types";
interface Props {
field: Parameter;
parentField?: Parameter;
config: Record<string, unknown>;
onChange: (value: any) => void;
disabled?: boolean;
onClear?: () => void;
className?: string;
}
export default function JSONFieldView(props: Props) {
const { field, parentField, config } = props;
const rawValue = parentField ? (config[parentField.id] as any)?.[field.id] : config[field.id];
const value = rawValue !== undefined ? JSON.stringify(rawValue, null, 2) : "";
const [currentValue, setCurrentValue] = useState<string>(value);
// Sync local state when config changes externally (e.g., session load)
useEffect(() => {
setCurrentValue(value);
}, [value]);
return (
<div className={cn("flex flex-col gap-2", props.className)}>
<FieldLabel label={field.label} helpText={field.helpText} onClear={props.onClear} />
<CodeEditor
code={currentValue}
readonly={props.disabled}
onChange={(v) => {
setCurrentValue(v);
try {
props.onChange(JSON.parse(v));
} catch {}
}}
onBlur={() => {
try {
setCurrentValue(JSON.stringify(JSON.parse(currentValue), null, 2));
} catch {}
}}
lang="json"
wrap={true}
height={200}
className="h-[200px] w-full rounded-md border py-1"
options={{
scrollBeyondLastLine: false,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
import NumberInput from "../number";
import FieldLabel from "./fieldLabel";
import { Parameter } from "./types";
interface Props {
field: Parameter;
config: Record<string, unknown>;
onChange: (value: any) => void;
disabled?: boolean;
onInvalid?: (invalid: boolean, field?: string) => void;
onClear?: () => void;
className?: string;
disabledText?: string;
}
export default function NumberFieldView(props: Props) {
const { field, config } = props;
const invalid = field.range ? isInvalid(config[field.id] as number, field.range) : false;
useEffect(() => {
if (!props.onInvalid) return;
if (invalid) {
props.onInvalid(true, field.id);
} else {
props.onInvalid(false, field.id);
}
}, [invalid]);
return (
<div className={cn("flex flex-col gap-3", props.className)}>
<FieldLabel label={field.label} helpText={field.helpText} onClear={props.onClear}>
{field.range && config[field.id] !== undefined && (
<NumberInput
className={cn(
"ml-auto h-[24px] w-[80px] text-center shrink-0",
invalid ? "border-border-error focus-visible:ring-border-error" : "",
)}
value={config[field.id] as number}
disabled={props.disabled && props.disabled === true}
onChange={(value) => props.onChange(value)}
preventOnBlurFallback
min={field.range?.min}
max={field.range?.max}
/>
)}
</FieldLabel>
{field.range ? (
<Slider
min={field.range?.min ?? 0}
max={field.range?.max ?? 1}
step={field.range?.step ?? (field.range?.max ?? 1) / 100}
disabled={props.disabled && props.disabled === true}
value={[(config[field.id] as number) !== undefined ? (config[field.id] as number) : 0]}
onValueChange={(value) => {
props.onChange(value[0]);
}}
thumbTooltipText={(props.disabled && props.disabledText) || undefined}
/>
) : (
<NumberInput
className="w-full"
value={config[field.id] as number}
disabled={props.disabled && props.disabled === true}
onChange={(value) => props.onChange(value)}
preventOnBlurFallback
/>
)}
{invalid && (
<div className="text-content-error -mt-2">
Please keep {field.label} between {field.range?.min} to {field.range?.max}.
</div>
)}
</div>
);
}
const isInvalid = (value: number, range: { min: number; max: number }): boolean => {
if (value === undefined || value === null || range?.min === undefined) return false;
return isNaN(value) || value < range.min || value > range.max;
};

View File

@@ -0,0 +1,104 @@
import BooleanFieldView from "./booleanFieldView";
import JSONFieldView from "./jsonFieldView";
import NumberFieldView from "./numberFieldView";
import SelectFieldView from "./selectFieldView";
import TextArrayFieldView from "./textArrayFieldView";
import TextFieldView from "./textFieldView";
import { Parameter, ParameterType } from "./types";
interface Props {
field: Parameter;
parentField?: Parameter;
config: Record<string, unknown>;
onChange: (value: any, overrides?: Record<string, any>) => void;
disabled?: boolean;
onInvalid?: (invalid: boolean, field?: string) => void;
className?: string;
disabledText?: string;
forceHideFields?: string[];
}
export default function ParameterFieldView(props: Props) {
const { field, parentField, config, onInvalid } = props;
const hasValue = config[field.id] !== undefined;
const onClear = hasValue && !props.disabled ? () => props.onChange(undefined) : undefined;
const getField = () => {
if (field.hidden || (props.forceHideFields && props.forceHideFields.includes(field.id))) return null;
switch (field.type) {
case ParameterType.TEXT:
return (
<TextFieldView
field={field}
disabled={props.disabled}
config={config}
onChange={props.onChange}
onClear={onClear}
className={props.className}
/>
);
case ParameterType.ARRAY:
return (
<TextArrayFieldView
field={field}
disabled={props.disabled}
config={config}
onChange={props.onChange}
onInvalid={onInvalid}
onClear={onClear}
className={props.className}
/>
);
case ParameterType.NUMBER:
return (
<NumberFieldView
field={field}
disabled={props.disabled}
config={config}
onChange={props.onChange}
onInvalid={onInvalid}
onClear={onClear}
className={props.className}
disabledText={props.disabledText}
/>
);
case ParameterType.BOOLEAN:
return (
<BooleanFieldView
field={field}
disabled={props.disabled}
config={config}
onChange={props.onChange}
onClear={onClear}
className={props.className}
forceHideFields={props.forceHideFields}
/>
);
case ParameterType.SELECT:
return (
<SelectFieldView
field={field}
config={config}
onChange={props.onChange}
multiselect={field.multiple}
disabled={props.disabled}
onClear={onClear}
className={props.className}
/>
);
case ParameterType.JSON:
return (
<JSONFieldView
field={field}
parentField={parentField}
config={config}
onChange={props.onChange}
onClear={onClear}
disabled={props.disabled}
/>
);
}
};
return <>{getField()}</>;
}

View File

@@ -0,0 +1,91 @@
import ParameterFieldView from "./paramFieldView";
import { Parameter } from "./types";
import { cn } from "@/lib/utils";
import { ComboboxSelect } from "@/components/ui/combobox";
import FieldLabel from "./fieldLabel";
interface Props {
field: Parameter;
config: Record<string, unknown>;
onChange: (value: any, overrides?: Record<string, any>) => void;
disabled?: boolean;
multiselect?: boolean;
placeholder?: string;
isLoading?: boolean;
onClear?: () => void;
className?: string;
forceHideFields?: string[];
}
export default function SelectFieldView(props: Props) {
const { field, config } = props;
const value = field.accesorKey ? (config[field.id] as any)?.[field.accesorKey] || "" : config[field.id];
const onFieldChange = (fieldValue: string | null) => {
if (fieldValue === null) {
props.onChange(undefined);
return;
}
const res = field.accesorKey ? { [field.accesorKey]: fieldValue } : fieldValue;
props.onChange(res);
};
const onSubFieldChange = (subFieldId: string, subFieldValue: string) => {
if (field.accesorKey) {
const existing = config[field.id] && typeof config[field.id] === "object" ? (config[field.id] as Record<string, unknown>) : {};
props.onChange({
...existing,
[field.accesorKey]: value,
[subFieldId]: subFieldValue,
});
} else {
props.onChange(value, { [subFieldId]: subFieldValue });
}
};
const currentField = field.options?.find((f) => f.value === value);
return (
<div className={cn("flex flex-col gap-2", props.className)}>
<FieldLabel label={field.label} helpText={field.helpText} onClear={props.onClear} />
{props.multiselect ? (
<ComboboxSelect
multiple
options={field.options || []}
value={Array.isArray(value) ? value : []}
onValueChange={(vals) => props.onChange(field.accesorKey ? { [field.accesorKey]: vals } : vals)}
disabled={props.disabled}
placeholder={`Add ${field.label}`}
className="h-8"
/>
) : (
<ComboboxSelect
options={field.options || []}
value={(value as string) || null}
onValueChange={onFieldChange}
disabled={props.disabled}
placeholder="Select"
disableSearch
className="h-8"
/>
)}
{currentField?.subFields && (
<div className="mt-2">
{currentField.subFields.map((subField) => (
<ParameterFieldView
key={subField.id}
field={subField}
parentField={field}
config={config}
onChange={(fieldValue) => onSubFieldChange(subField.id, fieldValue)}
disabled={props.disabled && props.disabled === true}
forceHideFields={props.forceHideFields}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Trash } from "lucide-react";
import { useEffect, useState } from "react";
import FieldLabel from "./fieldLabel";
import { Parameter } from "./types";
interface Props {
field: Parameter;
config: Record<string, unknown>;
onChange: (value: any) => void;
disabled?: boolean;
onInvalid?: (invalid: boolean, field?: string) => void;
onClear?: () => void;
className?: string;
}
export default function TextArrayFieldView(props: Props) {
const [shouldFocus, setShouldFocus] = useState(false);
const { field, config } = props;
const fieldValue = (config[field.id] as string[]) || [];
const invalid = isInvalid(fieldValue.length as number, {
max: (field.array?.maxElements as number) || Infinity,
min: field.array?.minElements || 1,
});
useEffect(() => {
if (!props.onInvalid) return;
if (invalid) {
props.onInvalid(true, field.id);
} else {
props.onInvalid(false);
}
}, [props, invalid, field.id]);
return (
<div className={cn("flex flex-col items-start gap-2", props.className)}>
<FieldLabel label={field.label} helpText={field.helpText} onClear={props.onClear} />
<div className="flex flex-col gap-2">
{fieldValue?.map((_value, i) => (
<StringInput
key={i}
index={i}
onDelete={(index) => {
props.onChange(fieldValue?.filter((_, idx) => idx !== index) || []);
}}
onChange={(index, value) => {
props.onChange(fieldValue?.map((v, idx) => (idx === index ? value : v)) || []);
}}
value={_value}
onEnterPress={() => {
if ((fieldValue && !fieldValue[fieldValue.length - 1]) || invalid) return;
setShouldFocus(true);
props.onChange([...(fieldValue || []), ""]);
}}
autoFocus={shouldFocus && i === fieldValue.length - 1}
disabled={(invalid && i === fieldValue.length) || props.disabled}
/>
))}
</div>
{invalid && (
<div className="mt-1 text-red-600">
Please keep {field.label} between {field.array?.minElements} to {field.array?.maxElements}.
</div>
)}
<Button
variant={"link"}
disabled={invalid || props.disabled}
className="h-auto px-0 py-0"
onClick={() => {
if (invalid) return;
setShouldFocus(true);
props.onChange([...((config[field.id] as string[]) || []), ""]);
}}
>
Add string
</Button>
</div>
);
}
// TODO - @SURESH - move this to a UI Library - DO IT
const StringInput = (props: {
onDelete: (index: number) => void;
index: number;
onChange: (index: number, value: string) => void;
value?: string;
autoFocus?: boolean;
disabled?: boolean;
onEnterPress?: () => void;
}) => {
return (
<div className="relative w-full gap-1">
<Input
tabIndex={10}
className="ml-auto h-8 w-full"
value={props.value}
placeholder=""
disabled={props.disabled}
onChange={(e) => {
props.onChange(props.index, e.target.value);
}}
autoFocus={props.autoFocus}
onBlur={(e) => {
if (!e.target.value || e.target.value === "" || e.target.value.trim().length === 0) {
props.onDelete(props.index);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.code === "Enter") {
props.onEnterPress && props.onEnterPress();
}
}}
/>
<Trash
onClick={() => props.onDelete(props.index)}
className="text-content-error absolute top-1/2 right-2 h-3.5 w-3.5 -translate-y-1/2 cursor-pointer opacity-80 hover:opacity-100"
/>
</div>
);
};
const isInvalid = (value: number, range: { min: number; max: number }): boolean => {
if (!value) return false;
return isNaN(value) || value < range.min || value >= range.max;
};

View File

@@ -0,0 +1,31 @@
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import FieldLabel from "./fieldLabel";
import { Parameter } from "./types";
interface Props {
field: Parameter;
config: Record<string, unknown>;
onChange: (value: any) => void;
disabled?: boolean;
onClear?: () => void;
className?: string;
}
export default function TextFieldView(props: Props) {
const { field, config } = props;
return (
<div className={cn("flex flex-col gap-2", props.className)}>
<FieldLabel label={field.label} helpText={field.helpText} htmlFor={field.id} onClear={props.onClear} />
<Input
id={field.id}
className="mr-2 ml-auto h-8 w-full"
value={(config[field.id] as string) ?? ""}
disabled={props.disabled && props.disabled === true}
onChange={(e) => props.onChange(e.target.value || undefined)}
/>
</div>
);
}

View File

@@ -0,0 +1,56 @@
export enum ParameterType {
NUMBER = "number",
BOOLEAN = "boolean",
TEXT = "text",
SELECT = "select",
ARRAY = "array",
JSON = "json",
}
export type Parameter = {
id: string;
label: string;
helpText?: string;
type: ParameterType;
/**
* use when value is nested in object
* e.g. { config: { response_format: { type: "json_schema", json_schema: { type: "object" } } } }
* parameter.id = response_format
* parameter.accesorKey = response_format.json_schema
*/
accesorKey?: string;
default?: any;
multiple?: boolean;
range?: {
min: number;
max: number;
step?: number;
};
array?: {
type: ParameterType;
maxElements?: number;
minElements?: number;
};
options?: {
label: string;
value: string;
subFields?: Parameter[];
}[];
disabled?: boolean;
disabledText?: string;
trueValue?: unknown; // When using boolean field, if `trueValue` is set, then this will be passed as the value when the boolean is true
falseValue?: unknown; // When using boolean field, if `falseValue` is set, then this will be passed as the value when the boolean is false
removeFieldOnFalse?: boolean; // When using boolean field, if `removeFieldOnFalse` is set to true, then the field will be removed from the config when the boolean is false
disabledCondition?: {
paramId: string;
operator: "eq" | "neq";
value: any;
disabledText?: string;
setValue?: any;
};
/**
* When true, the parameter is completely excluded from UI rendering
* (see `paramFieldView.tsx` early return).
*/
hidden?: boolean;
};

View File

@@ -0,0 +1,299 @@
import { VariantProps, cva } from "class-variance-authority";
import React, { useCallback, useEffect, useRef, useState } from "react";
import "./input.css";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../tooltip";
import { cn } from "../utils";
const inputVariants = cva(
"flex h-8 w-full rounded-md bg-background-primary px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-md file:font-medium placeholder:text-content-disabled focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default: "border border-border-default focus-visible:border-border-focus",
ghost: "",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface NumberInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange" | "value" | "defaultValue">, VariantProps<typeof inputVariants> {
min?: number;
max?: number;
step?: number;
allowNegative?: boolean;
allowDecimal?: boolean;
decimalPlaces?: number;
defaultValue?: number;
value?: string | number;
onChange?: (value: number | undefined) => void;
onValueError?: (error: string) => void;
preventOnBlurFallback?: boolean;
hideWarnings?: boolean;
}
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
(
{
className,
variant,
min,
max,
step = 1,
defaultValue,
allowDecimal = true,
allowNegative = true,
decimalPlaces,
value,
onChange,
onValueError,
preventOnBlurFallback,
hideWarnings,
...props
},
ref,
) => {
// Internal state to handle intermediate values (like empty string or partial input)
const initialValue = value === undefined ? defaultValue : value;
const [internalValue, setInternalValue] = useState<string>(() => {
if (initialValue === undefined || initialValue === null) return "";
return String(initialValue);
});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fallbackValue = defaultValue ?? min ?? 0;
const prevValueRef = useRef<number | string | undefined>(value);
// Validate and format the number
const validateAndFormatNumber = useCallback(
(value: string): string => {
if (!value) return "";
let formattedValue = value;
// Handle decimal places
if (decimalPlaces !== undefined && value.includes(".")) {
const [whole, decimal] = value.split(".");
formattedValue = `${whole}.${decimal.slice(0, decimalPlaces)}`;
}
const numValue = Number(formattedValue);
// Validate min/max
if (min !== undefined && numValue < min) {
onValueError?.(`Value cannot be less than ${min}`);
setErrorMessage(`Value cannot be less than ${min}`);
return min.toString();
}
if (max !== undefined && numValue > max) {
onValueError?.(`Value cannot be greater than ${max}`);
setErrorMessage(`Value cannot be greater than ${max}`);
return max.toString();
}
return formattedValue;
},
[min, max, decimalPlaces, onValueError],
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
// Allow empty input
if (!newValue) {
setInternalValue("");
onChange?.(undefined);
return;
}
// Validate number format
if (!/^-?\d*\.?\d*$/.test(newValue)) return;
setInternalValue(newValue);
// Only call onChange with valid numbers
const parsed = Number(newValue);
if (!isNaN(parsed)) {
onChange?.(parsed);
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
props.onBlur?.(e);
if (!internalValue) {
if (preventOnBlurFallback) {
onChange?.(undefined);
} else {
const normalized = validateAndFormatNumber(String(fallbackValue));
const normalizedNum = normalized ? Number(normalized) : fallbackValue;
setInternalValue(String(normalizedNum));
prevValueRef.current = normalizedNum;
if (!errorMessage) setErrorMessage(`Value cannot be empty, replaced with ${normalizedNum}`);
onChange?.(normalizedNum);
}
return;
}
const formattedValue = validateAndFormatNumber(internalValue);
setInternalValue(formattedValue);
if (!errorMessage && !formattedValue) setErrorMessage(`Value cannot be empty, replaced with ${fallbackValue}`);
onChange?.(formattedValue ? Number(formattedValue) : fallbackValue);
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData("text");
// Check if the pasted content is a valid number format
if (!/^-?\d*\.?\d*$/.test(pastedText)) {
onValueError?.("Invalid number format");
setErrorMessage("Invalid number format");
return;
}
// Handle decimal restriction
if (!allowDecimal && pastedText.includes(".")) {
onValueError?.("Decimal numbers are not allowed");
setErrorMessage("Decimal numbers are not allowed");
return;
}
// Handle negative restriction
if (!allowNegative && pastedText.includes("-")) {
onValueError?.("Negative numbers are not allowed");
setErrorMessage("Negative numbers are not allowed");
return;
}
const newValue = validateAndFormatNumber(pastedText);
setInternalValue(newValue);
// Only call onChange with valid numbers
const parsed = Number(newValue);
if (!isNaN(parsed)) {
onChange?.(parsed);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
props.onKeyDown?.(e);
const target = e.target as HTMLInputElement;
// Allow select all
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
return;
}
// Allow copy/paste/undo shortcuts
if ((e.ctrlKey || e.metaKey) && (e.key === "c" || e.key === "v" || e.key === "x" || e.key === "z")) {
return;
}
// Allow control keys
const allowedKeys = ["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab", "Enter", "Home", "End", "ArrowUp", "ArrowDown"];
if (allowedKeys.includes(e.key)) {
if (e.key === "ArrowUp") {
e.preventDefault();
const currentValue = Number(target.value) || 0;
const newValue = validateAndFormatNumber((currentValue + step).toString());
setInternalValue(newValue);
onChange?.(Number(newValue));
} else if (e.key === "ArrowDown") {
e.preventDefault();
const currentValue = Number(target.value) || 0;
const newValue = validateAndFormatNumber((currentValue - step).toString());
setInternalValue(newValue);
onChange?.(Number(newValue));
}
return;
}
// Handle negative sign
if (!allowNegative && e.key === "-") {
e.preventDefault();
return;
}
// Handle decimal point
if (!allowDecimal && e.key === ".") {
e.preventDefault();
return;
}
// Allow negative sign at start
if (allowNegative && e.key === "-" && target.selectionStart === 0) {
if (!target.value.includes("-")) return;
e.preventDefault();
return;
}
// Allow decimal point if configured
if (allowDecimal && e.key === "." && !target.value.includes(".")) {
return;
}
// Allow only numbers
if (!/^\d$/.test(e.key)) {
e.preventDefault();
}
};
useEffect(() => {
if (!errorMessage) return;
const timer = setTimeout(() => {
setErrorMessage(null);
}, 2000);
return () => clearTimeout(timer);
}, [errorMessage]);
useEffect(() => {
// Only update if value prop actually changed
if (prevValueRef.current === value) return;
prevValueRef.current = value;
// Clear internal value when value becomes undefined/null/empty
if (value === undefined || value === null || value === "") {
setInternalValue("");
return;
}
// Update internal value when value changes to a number
const stringValue = String(value);
setInternalValue(stringValue);
}, [value]);
return (
<>
<TooltipProvider delayDuration={100} disableHoverableContent>
<Tooltip open={!!errorMessage && !hideWarnings}>
<TooltipTrigger asChild>
<input
ref={ref}
type="text"
inputMode={allowDecimal ? "decimal" : "numeric"}
value={internalValue}
className={cn(inputVariants({ variant, className }))}
{...props}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
/>
</TooltipTrigger>
<TooltipContent align="start" className="text-md">
{errorMessage}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
);
},
);
NumberInput.displayName = "NumberInput";
export default NumberInput;

View File

@@ -0,0 +1,679 @@
import { VariantProps, cva } from "class-variance-authority";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import TextareaAutosize from "react-textarea-autosize";
import { cn } from "../utils";
import { CustomDropdown, DropdownOption } from "./dropdown";
const textAreaVariants = cva(
"flex w-full h-full text-transparent caret-black rounded-md resize-none bg-transparent px-3 py-2 text-md placeholder:text-content-disabled disabled:cursor-not-allowed disabled:opacity-50 relative font-[inherit] focus-visible:outline-none whitespace-pre-wrap overflow-y-auto",
{
variants: {
variant: {
default: "border border-border-default focus-visible:border-border-focus",
ghost: "",
},
},
defaultVariants: {
variant: "default",
},
},
);
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement>, VariantProps<typeof textAreaVariants> {
innerRef?: React.Ref<HTMLTextAreaElement>;
autoFocus?: boolean;
focusAtTheEndOfTheValue?: boolean;
onFilePasted?: (files: File[]) => void;
suggestions?: DropdownOption[];
nestedSuggestions?: Record<string, any>;
suggestionsTrigger?: string;
highlightPatterns?: Array<{
pattern: RegExp;
className: string | ((part: string) => string);
validate: (part: string) => boolean;
enableVariableClickEdit?: boolean;
}>;
textAreaClassName?: string;
noSuggestionText?: string;
suggestionDropdownClassName?: string;
/**
* Disables the functionality of clicking on a variable to change it.
*/
enableVariableClickEdit?: boolean;
/**
* If true, selects all text by default.
*/
selectAllByDefault?: boolean;
/**
* Inline suggestion text to show when cursor is inside empty {{}} braces.
*/
inlineSuggestionText?: string;
}
const RichTextarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({
className,
variant,
innerRef,
enableVariableClickEdit,
suggestions,
nestedSuggestions,
value = "",
onChange,
highlightPatterns,
suggestionsTrigger: customCompletionsTrigger,
textAreaClassName,
noSuggestionText,
suggestionDropdownClassName,
selectAllByDefault,
inlineSuggestionText,
...props
}) => {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const preRef = useRef<HTMLPreElement>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
const [dropdownDirection, setDropdownDirection] = useState<"top" | "bottom">("bottom");
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
const [clickedVariable, setClickedVariable] = useState<{ start: number; end: number; text: string } | null>(null);
const [searchText, setSearchText] = useState("");
const [currentPath, setCurrentPath] = useState<string[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
// Combine all highlight patterns with default patterns
const highlightPattern: RegExp | undefined = useMemo(() => {
const patterns = highlightPatterns?.map((h) => `(${h.pattern.source})`);
if (!patterns || patterns.length === 0) return undefined;
const combinedPattern = patterns.join("|");
return new RegExp(combinedPattern, "g");
}, [highlightPatterns]);
const getNestedSuggestions = (path: string[]): DropdownOption[] | undefined => {
if (!nestedSuggestions || path.length === 0) return suggestions;
let current: any = nestedSuggestions;
// Navigate through the path
for (let i = 0; i < path.length; i++) {
if (!current[path[i]]) return undefined;
current = current[path[i]];
}
// Convert the nested object to DropdownOption format
if (typeof current === "object" && current !== null) {
// Get all keys except "description" which is handled separately
const keys = Object.keys(current).filter((key) => key !== "description");
// Special case: if the only key is __description, don't show it as an option
if (keys.length === 1 && keys[0] === "__description") {
return [];
}
return keys.map((key) => {
// Check if this object only has a __description key
const hasOnlySpecialDescription =
typeof current[key] === "object" &&
current[key] !== null &&
Object.keys(current[key]).length === 1 &&
Object.keys(current[key])[0] === "__description";
// Determine if it has children (but ignore the __description key when counting)
const hasChildren =
typeof current[key] === "object" &&
current[key] !== null &&
Object.keys(current[key]).filter((k) => k !== "description" && k !== "__description").length > 0;
// Get description - prioritize regular description, fall back to __description
let description;
if (current[key]?.description) {
description = current[key].description;
} else if (hasOnlySpecialDescription) {
description = current[key].__description;
}
return {
type: "item",
label: key,
value: hasChildren ? `{{${[...path, key].join(".")}.` : `{{${[...path, key].join(".")}}}`,
hasChildren,
description,
};
});
}
return undefined;
};
const filteredSuggestions: DropdownOption[] | undefined = useMemo(() => {
// Get suggestions based on the current path
const pathSuggestions = getNestedSuggestions(currentPath);
if (!pathSuggestions) return undefined;
const searchQuery = searchText ? searchText.toLowerCase().trim() : "";
return pathSuggestions
.map((opt) => {
if (opt.type === "group") {
// Always filter out hidden options from groups
const visibleOptions = opt.options?.filter((option: DropdownOption) => !option.hidden) ?? [];
// If no search query, return all visible options
if (!searchQuery.trim()) {
if (visibleOptions.length === 0) return false;
return {
...opt,
options: visibleOptions,
};
}
// With search query, filter by search text
const groupLabelMatches = opt.label?.toLowerCase().includes(searchQuery) ?? false;
// Filtering hidden options from display but keep for matching
const allMatchingOptions =
opt.options?.filter((option: DropdownOption) => option.label?.toLowerCase().includes(searchQuery) ?? false) ?? [];
const filteredSubOptions = allMatchingOptions.filter((option: DropdownOption) => !option.hidden);
if (groupLabelMatches) {
return {
...opt,
options: visibleOptions,
};
}
if (!allMatchingOptions || allMatchingOptions.length === 0) return false;
return {
...opt,
options: filteredSubOptions,
};
} else {
// Always filter out hidden items
if (opt.hidden) return false;
// If no search query, return the option
if (!searchQuery.trim()) return opt;
// With search query, filter by search text
return opt.label?.toLowerCase().includes(searchQuery) ? opt : false;
}
})
.filter(Boolean) as DropdownOption[];
}, [suggestions, nestedSuggestions, searchText, currentPath]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const cursorPos = e.target.selectionStart;
setCursorPosition(cursorPos);
// Find the last '{{' before cursor
const beforeCursor = newValue.slice(0, cursorPos);
const lastOpenBraceIndex = beforeCursor.lastIndexOf("{{");
if (lastOpenBraceIndex !== -1 && lastOpenBraceIndex < cursorPos) {
const textAfterBraces = beforeCursor.slice(lastOpenBraceIndex + 2);
// Only show dropdown if there's no closing braces in the text after '{{'
if (!textAfterBraces.includes("}}")) {
// Check for dot notation
const parts = textAfterBraces.split(".");
const searchPart = parts.pop() || "";
const path = parts.filter(Boolean);
setCurrentPath(path);
setSearchText(searchPart);
const rect = getCaretCoordinates();
if (rect) {
// Estimate dropdown height - can be adjusted based on actual content
const estimatedDropdownHeight = 200; // Default estimate
// Determine if there's enough space below
const shouldShowBelow = rect.spaceBelow >= estimatedDropdownHeight || rect.spaceBelow > rect.spaceAbove;
if (shouldShowBelow) {
setDropdownPosition({
top: rect.bottom,
left: rect.left,
});
setDropdownDirection("bottom");
} else {
// Position above the cursor
setDropdownPosition({
top: rect.bottom - 20, // Adjust for the cursor height
left: rect.left,
});
setDropdownDirection("top");
}
if (clickedVariable && enableVariableClickEdit) {
setClickedVariable(null);
}
setShowDropdown(true);
}
} else {
setShowDropdown(false);
setSearchText("");
setCurrentPath([]);
}
} else {
setShowDropdown(false);
setSearchText("");
setCurrentPath([]);
}
onChange?.({ target: { value: newValue } } as React.ChangeEvent<HTMLTextAreaElement>);
};
const getCaretCoordinates = () => {
const textarea = textareaRef.current;
if (!textarea) return null;
const position = textarea.selectionStart;
const text = textarea.value.substring(0, position);
const div = document.createElement("div");
const styles = window.getComputedStyle(textarea);
div.style.position = "absolute";
div.style.top = "0";
div.style.left = "0";
div.style.whiteSpace = "pre-wrap";
div.style.visibility = "hidden";
div.style.font = styles.font;
div.style.padding = styles.padding;
div.style.width = styles.width;
div.textContent = text;
const span = document.createElement("span");
div.appendChild(span);
document.body.appendChild(div);
const rect = span.getBoundingClientRect();
const textareaRect = textarea.getBoundingClientRect();
document.body.removeChild(div);
// Calculate available space above and below the cursor
const spaceBelow = window.innerHeight - (textareaRect.top + rect.top - textarea.scrollTop + 20);
const spaceAbove = textareaRect.top + rect.top - textarea.scrollTop;
return {
left: textareaRect.left + rect.left - textarea.scrollLeft,
bottom: textareaRect.top + rect.top - textarea.scrollTop + 20,
spaceBelow,
spaceAbove,
};
};
const setSelectionIfStillFocused = (position: number) => {
setTimeout(() => {
const textarea = textareaRef.current;
if (!textarea) return;
if (document.activeElement !== textarea) return;
textarea.selectionStart = position;
textarea.selectionEnd = position;
}, 0);
};
const handleSuggestionSelect = (option: DropdownOption) => {
if (!textareaRef.current) return;
const text = value as string;
let newValue: string;
const hasChildren = "hasChildren" in option && option.hasChildren;
// Check if the option value ends with a dot for suggestions array
const endsWithDot = option.value?.endsWith?.(".") ?? false;
if (clickedVariable && enableVariableClickEdit) {
// Replace clicked variable
newValue = text.slice(0, clickedVariable.start) + option.value + text.slice(clickedVariable.end);
setClickedVariable(null);
// Set cursor position after the inserted variable
setTimeout(() => {
if (textareaRef.current) {
if (!option.value) return;
const newPosition = clickedVariable.start + option.value.length;
textareaRef.current.selectionStart = newPosition;
textareaRef.current.selectionEnd = newPosition;
textareaRef.current.focus();
// If the option value ends with a dot, update dropdown for nested suggestions
if (endsWithDot) {
updateDropdownForChildren(newPosition);
}
}
}, 0);
} else if (cursorPosition !== null) {
// Handle normal typing suggestion
const beforeCursor = text.slice(0, cursorPosition);
const afterCursor = text.slice(cursorPosition);
const lastOpenBraceIndex = beforeCursor.lastIndexOf("{{");
// Check if there are closing braces immediately after the cursor
// to avoid duplicating them
let adjustedAfterCursor = afterCursor;
if (!hasChildren && !endsWithDot && afterCursor.trimStart().startsWith("}}")) {
// Find the position of the first "}}" after cursor
const closingBracePos = afterCursor.indexOf("}}");
// Remove the closing braces
adjustedAfterCursor = afterCursor.slice(0, closingBracePos) + afterCursor.slice(closingBracePos + 2);
}
// Replace everything from '{{' to cursor with the new value
newValue = text.slice(0, lastOpenBraceIndex) + option.value + adjustedAfterCursor;
// Set cursor position after the inserted variable
setTimeout(() => {
if (textareaRef.current) {
if (!option.value) return;
const newPosition = lastOpenBraceIndex + option.value.length;
textareaRef.current.selectionStart = newPosition;
textareaRef.current.selectionEnd = newPosition;
textareaRef.current.focus();
// If the option has children or ends with a dot, continue showing dropdown
if (hasChildren || endsWithDot) {
updateDropdownForChildren(newPosition);
}
}
}, 0);
} else {
return;
}
onChange?.({ target: { value: newValue } } as React.ChangeEvent<HTMLTextAreaElement>);
// Only hide dropdown if the option doesn't have children and doesn't end with a dot
if (!hasChildren && !endsWithDot) {
setShowDropdown(false);
setSearchText("");
setCurrentPath([]);
}
};
const handleVariableClick = (e: React.MouseEvent, start: number, end: number, text: string) => {
e.preventDefault();
e.stopPropagation();
const coordinates = getCaretCoordinates();
if (coordinates) {
// Estimate dropdown height - can be adjusted based on actual content
const estimatedDropdownHeight = 200; // Default estimate
// Determine if there's enough space below
const shouldShowBelow = coordinates.spaceBelow >= estimatedDropdownHeight || coordinates.spaceBelow > coordinates.spaceAbove;
if (shouldShowBelow) {
setDropdownDirection("bottom");
}
}
const rect = (e.target as HTMLElement).getBoundingClientRect();
setDropdownPosition({
top: rect.bottom,
left: rect.left,
});
setClickedVariable({ start, end, text });
setShowDropdown(true);
};
// Helper function to update dropdown for options with children
const updateDropdownForChildren = (cursorPosition: number) => {
// Update the current path based on the selected option
if (textareaRef.current) {
const text = textareaRef.current.value;
const beforeCursor = text.slice(0, cursorPosition);
const lastOpenBraceIndex = beforeCursor.lastIndexOf("{{");
const pathText = beforeCursor.slice(lastOpenBraceIndex + 2, cursorPosition - 1); // Remove '{{' and trailing dot
const path = pathText.split(".").filter(Boolean);
setCurrentPath(path);
setSearchText("");
setCursorPosition(cursorPosition);
// Update dropdown position at the new cursor location
setTimeout(() => {
const rect = getCaretCoordinates();
if (rect) {
// Estimate dropdown height
const estimatedDropdownHeight = 200; // Default estimate
// Determine if there's enough space below
const shouldShowBelow = rect.spaceBelow >= estimatedDropdownHeight || rect.spaceBelow > rect.spaceAbove;
if (shouldShowBelow) {
setDropdownPosition({
top: rect.bottom,
left: rect.left,
});
setDropdownDirection("bottom");
} else {
// Position above the cursor
setDropdownPosition({
top: rect.bottom - 20, // Adjust for the cursor height
left: rect.left,
});
setDropdownDirection("top");
}
setShowDropdown(true);
}
}, 10);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showDropdown && (e.key === "Escape" || e.key === "Tab")) {
e.preventDefault();
setShowDropdown(false);
}
};
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const textarea = e.target as HTMLTextAreaElement;
};
const handleBlur = () => {
setShowDropdown(false);
setSearchText("");
setCurrentPath([]);
};
const handleKeyDownCapture = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Space") {
setSearchText("");
setCurrentPath([]);
}
};
const syncScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
if (preRef.current) {
preRef.current.scrollTop = e.currentTarget.scrollTop;
preRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
};
const filePasteHandler = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData.items;
const files = Array.from(items).filter((item) => item.kind === "file");
if (files.length === 0) {
return;
}
e.preventDefault();
e.stopPropagation();
props.onFilePasted?.(files.map((file) => file.getAsFile() as File));
};
// Only run once when the prop toggles from false → true
const didSelectRef = useRef(false);
useEffect(() => {
if (selectAllByDefault && textareaRef.current && !didSelectRef.current) {
textareaRef.current.select();
didSelectRef.current = true;
}
if (!selectAllByDefault) {
didSelectRef.current = false;
}
}, [selectAllByDefault]);
useEffect(() => {
const handleWindowClick = (event: MouseEvent) => {
const dropdownPortal = document.getElementById("rich-textarea-dropdown-portal");
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node) &&
!dropdownPortal?.contains(event.target as Node)
) {
setShowDropdown(false);
setSearchText("");
setCurrentPath([]);
}
};
window.addEventListener("click", handleWindowClick);
return () => {
window.removeEventListener("click", handleWindowClick);
};
}, []);
return (
<div className={cn("relative h-full", className)} ref={containerRef}>
<TextareaAutosize
onPaste={props.onFilePasted ? filePasteHandler : undefined}
ref={textareaRef}
value={value}
onChange={handleChange}
onScroll={syncScroll}
onKeyDown={handleKeyDown}
onSelect={handleSelect}
onBlur={handleBlur}
onKeyDownCapture={handleKeyDownCapture}
className={cn(textAreaVariants({ variant, className: textAreaClassName }), "overscroll-auto break-words")}
minRows={1}
{...Object.fromEntries(Object.entries(props).filter(([key]) => !["onFilePasted", "style", "onPaste"].includes(key)))}
/>
<SuggestionDropdown
dropdownDirection={dropdownDirection}
dropdownPosition={dropdownPosition}
filteredSuggestions={filteredSuggestions}
handleSuggestionSelect={handleSuggestionSelect}
noSuggestionText={noSuggestionText}
suggestionDropdownClassName={suggestionDropdownClassName}
showDropdown={showDropdown}
/>
<pre
ref={preRef}
aria-hidden="true"
className={cn(
textAreaVariants({ variant, className: textAreaClassName }),
"no-scrollbar pointer-events-none absolute inset-0 m-0 block w-full bg-transparent font-[inherit] text-muted-foreground",
)}
style={{ wordBreak: "break-word" }}
>
{typeof value === "string" && (
<>
{highlightPattern ? (
(() => {
let cumulativePosition = 0;
return value.split(highlightPattern).map((part, i) => {
if (!part) return null;
const currentPartPosition = cumulativePosition;
cumulativePosition += part.length;
const matchedPattern = highlightPatterns?.find((pattern) => {
// Test if this part matches the pattern
const matches = part.match(pattern.pattern);
// Validate the match if a validation function is provided
return matches && (!pattern.validate || pattern.validate(part));
});
if (!matchedPattern) {
if (inlineSuggestionText && part === "{{}}" && textareaRef.current) {
const currentCursorPos = textareaRef.current.selectionStart;
const partStartPos = currentPartPosition;
const partEndPos = currentPartPosition + part.length;
if (currentCursorPos >= partStartPos && currentCursorPos <= partEndPos) {
// Position cursor 2 characters from the start of {{}} (inside the braces)
const updatedPosition = currentPartPosition + 2;
setSelectionIfStillFocused(updatedPosition);
return (
<React.Fragment key={i}>
{"{{"}
<span className="text-content-secondary opacity-60">{inlineSuggestionText}</span>
{"}}"}
</React.Fragment>
);
}
}
return <React.Fragment key={i}>{part}</React.Fragment>;
}
const className =
typeof matchedPattern.className === "function" ? matchedPattern.className(part) : matchedPattern.className;
return (
<mark key={i} className={cn("rounded-sm pb-0.5 italic outline", className)}>
{part}
</mark>
);
});
})()
) : (
<React.Fragment>{value}</React.Fragment>
)}
{value.endsWith("\n") ? " " : ""}
</>
)}
</pre>
</div>
);
},
);
RichTextarea.displayName = "RichTextarea";
const SuggestionDropdown = ({
dropdownDirection,
dropdownPosition,
filteredSuggestions,
handleSuggestionSelect,
noSuggestionText,
suggestionDropdownClassName,
portalContainer,
showDropdown,
}: {
dropdownDirection: "bottom" | "top";
dropdownPosition: { top: number; left: number };
filteredSuggestions?: DropdownOption[];
handleSuggestionSelect: (option: DropdownOption) => void;
noSuggestionText?: string;
suggestionDropdownClassName?: string;
portalContainer?: Element | null;
showDropdown: boolean;
}) => {
if (!showDropdown || !filteredSuggestions || !filteredSuggestions.length) return null;
return createPortal(
<div
style={{
position: "fixed",
top: dropdownDirection === "bottom" ? `${dropdownPosition.top}px` : "auto",
bottom: dropdownDirection === "top" ? `calc(100vh - ${dropdownPosition.top}px)` : "auto",
left: `${dropdownPosition.left}px`,
zIndex: 50,
}}
id="rich-textarea-dropdown-portal"
>
<CustomDropdown
options={filteredSuggestions}
onChange={(opt) => handleSuggestionSelect(opt as DropdownOption)}
className={cn("custom-scrollbar max-h-full min-w-[200px] bg-white p-1 shadow-lg", suggestionDropdownClassName)}
selectFirstOptionByDefault
style={{
maxHeight:
dropdownDirection === "bottom" ? `calc(100vh - ${dropdownPosition.top}px - 8px)` : `calc(${dropdownPosition.top}px - 8px)`,
}}
emptyViewText={noSuggestionText}
groupHeadingClassName="px-2"
/>
</div>,
portalContainer ?? document.body,
);
};
export { RichTextarea };

View File

@@ -0,0 +1,410 @@
import { cn } from "@/lib/utils";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import React, { useEffect, useMemo } from "react";
import { DateRange } from "react-day-picker";
import { Button } from "./button";
import { Calendar } from "./calendar";
import { Label } from "./label";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { TimePicker, TimeValue } from "./timePicker";
export type TimeRange = {
from: TimeValue;
to: TimeValue;
};
interface DatePickerWithRangeProps extends React.HTMLAttributes<HTMLDivElement> {
buttonClassName?: string;
triggerLabel?: string;
onTrigger?: (
e: React.MouseEvent<HTMLButtonElement>,
range: {
from: { date?: Date; time: TimeValue };
to: { date: Date; time: TimeValue };
},
) => void;
}
interface DateTimePickerWithRangeProps extends DatePickerWithRangeProps {
popupAlignment?: "start" | "end" | "center";
onDateTimeUpdate?: (date: DateRange) => void;
onPredefinedPeriodChange?: (period: string | undefined) => void;
dateTime?: DateRange;
preDefinedPeriods?: { label: string; value: string }[];
predefinedPeriod?: string;
disabledBefore?: Date;
disabledAfter?: Date;
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Optional data-testid for the trigger button (e.g. for E2E tests) */
triggerTestId?: string;
}
export function DateTimePickerWithRange(props: DateTimePickerWithRangeProps) {
const { className, buttonClassName, triggerLabel, onTrigger, dateTime } = props;
const [date, setDate] = React.useState<DateRange | undefined>(dateTime);
const [timeValue, setTimeValue] = React.useState<TimeRange>({
from: dateTime?.from ? { hour: dateTime.from.getHours(), minute: dateTime.from.getMinutes() } : { hour: 0, minute: 0 },
to: dateTime?.to ? { hour: dateTime.to.getHours(), minute: dateTime.to.getMinutes() } : { hour: 23, minute: 59 },
});
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const [predefinedPeriod, setPredefinedPeriod] = React.useState<string | undefined>(props.predefinedPeriod);
const disabledDateRange = useMemo(() => {
if (!props.disabledBefore && !props.disabledAfter) return undefined;
let range: any = {};
if (props.disabledBefore) range["before"] = props.disabledBefore;
if (props.disabledAfter) range["after"] = props.disabledAfter;
return range;
}, [props.disabledBefore, props.disabledAfter]);
const printTimeValue = (timeObj: TimeValue): string => {
// Validate input
if (!timeObj || timeObj.hour < 0 || timeObj.hour >= 24 || timeObj.minute < 0 || timeObj.minute >= 60) {
return "";
}
let hour = ((timeObj.hour + 11) % 12) + 1; // Convert hour to 12-hour format
let period = timeObj.hour >= 12 ? "PM" : "AM"; // Determine AM/PM
let minute = timeObj.minute.toString().padStart(2, "0"); // Ensure the minute has two digits
return `${hour}:${minute} ${period}`;
};
const getDateTime = (date: Date | undefined, time: TimeValue | undefined | null): Date | undefined => {
if (!date || !time) return undefined;
// Create a date object from the selected calendar date (which is at midnight local time)
const localDate = new Date(date);
// Manually set the time in the local timezone. This is more robust than using `new Date(y,m,d,h,m)`.
localDate.setHours(time.hour, time.minute, 0, 0);
// new Date(year, month, day, hour, minute) can be problematic.
// A more robust way is to get the epoch time for the date at midnight,
// then add the hours and minutes in milliseconds.
const dateAtMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const epochTime = dateAtMidnight.getTime() + time.hour * 60 * 60 * 1000 + time.minute * 60 * 1000;
// Create a new Date object from the calculated epoch time.
return new Date(epochTime);
};
useEffect(() => {
setDate(dateTime);
setTimeValue({
from: dateTime?.from ? { hour: dateTime.from.getHours(), minute: dateTime.from.getMinutes() } : { hour: 0, minute: 0 },
to: dateTime?.to ? { hour: dateTime.to.getHours(), minute: dateTime.to.getMinutes() } : { hour: 23, minute: 59 },
});
}, [dateTime]);
useEffect(() => {
setPredefinedPeriod(props.predefinedPeriod);
}, [props.predefinedPeriod]);
return (
<div className={cn("grid gap-2", className)}>
<Popover
open={props.open !== undefined ? props.open : isOpen}
onOpenChange={(open) => {
setIsOpen(open);
props.onOpenChange && props.onOpenChange(open);
}}
>
<PopoverTrigger asChild>
<Button
id="date"
variant="outline"
data-testid={props.triggerTestId}
className={cn(
"justify-start text-left font-normal",
!date && "text-content-disabled",
buttonClassName,
isOpen && "border-black",
)}
>
<CalendarIcon className="h-4 w-4" strokeWidth={1.5} />
{predefinedPeriod ? (
<span>{props.preDefinedPeriods?.find((p) => p.value === predefinedPeriod)?.label}</span>
) : (
<>
{dateTime?.from ? (
dateTime.to ? (
<>
{format(dateTime.from, "LLL dd, y")} {printTimeValue(timeValue?.from)} - {format(dateTime.to, "LLL dd, y")}{" "}
{printTimeValue(timeValue?.to)}
</>
) : (
format(dateTime.from, "LLL dd, y")
)
) : (
<span>Pick a date</span>
)}
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align={props.popupAlignment ? props.popupAlignment : "start"}>
<div className="flex flex-row gap-2">
<div>
<Calendar
autoFocus
mode="range"
disabled={disabledDateRange}
defaultMonth={date?.from}
selected={date}
onSelect={(range) => {
if (!range) return;
if (!range.to) {
// here user has selected single date
range.to = range.from;
}
setDate(range);
setPredefinedPeriod(undefined);
// Checking if range is different than props.dateTime
if (
range.from?.toISOString() !== props.dateTime?.from?.toISOString() ||
range.to?.toISOString() !== props.dateTime?.to?.toISOString()
) {
props.onPredefinedPeriodChange && props.onPredefinedPeriodChange(undefined);
// Checking if range is valid
props.onDateTimeUpdate &&
props.onDateTimeUpdate({
from: getDateTime(range.from, timeValue?.from),
to: getDateTime(range.to, timeValue?.to),
});
}
}}
numberOfMonths={2}
/>
<div className="-mt-1 flex flex-row items-center px-2 pb-1">
<div className="m-1 flex flex-1 flex-col gap-1">
<Label className="ml-0.5">From Time</Label>
<TimePicker
aria-label="From Time"
className=""
value={timeValue?.from}
onChange={(v) => {
if (!date || !date.from) return;
if (v) setTimeValue({ from: v, to: timeValue.to });
const from = new Date(date.from);
if (v) from.setHours(v.hour, v.minute);
setDate({ from: from, to: date.to });
if (from.toISOString() !== props.dateTime?.from?.toISOString()) {
// Checking if range is valid
props.onDateTimeUpdate &&
props.onDateTimeUpdate({
from: getDateTime(from, v),
to: getDateTime(date.to, timeValue?.to),
});
}
}}
/>
</div>
<div className="m-1 flex flex-1 flex-col gap-1">
<Label className="ml-0.5">To Time</Label>
<TimePicker
aria-label="To Time"
className=""
value={timeValue?.to}
onChange={(v) => {
if (!date || !date.to) return;
if (v) setTimeValue({ ...timeValue, to: v });
const to = new Date(date.to);
if (v) to.setHours(v.hour, v.minute);
setDate({ from: date.from, to: to });
if (to.toISOString() !== props.dateTime?.to?.toISOString()) {
props.onDateTimeUpdate &&
props.onDateTimeUpdate({
from: getDateTime(date.from, timeValue?.from),
to: getDateTime(to, v),
});
}
}}
/>
</div>
</div>
</div>
{props.preDefinedPeriods && (
<div className="flex w-[150px] flex-col gap-1 border-l py-2 pr-3 pl-2">
{props.preDefinedPeriods.map((period) => (
<Button
className={cn("w-full text-start text-sm", predefinedPeriod === period.value && "bg-primary text-primary-foreground")}
variant="ghost"
key={period.value}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setPredefinedPeriod(period.value);
props.onPredefinedPeriodChange && props.onPredefinedPeriodChange(period.value);
}}
>
{period.label}
</Button>
))}
</div>
)}
</div>
{triggerLabel && onTrigger && (
<div className="mt-1 mb-2 flex w-full px-3">
<Button
className="ml-auto"
onClick={(e) => {
if (!date || !date.from || !date.to) return;
onTrigger(e, {
from: { date: date.from, time: timeValue.from },
to: { date: date.to, time: timeValue.to },
});
}}
>
{triggerLabel}
</Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}
interface DateTimePickerProps extends React.HTMLAttributes<HTMLDivElement> {
buttonClassName?: string;
triggerLabel?: string;
onTrigger?: (e: React.MouseEvent<HTMLButtonElement>, dateTime: { date?: Date; time: TimeValue }) => void;
popupAlignment?: "start" | "end" | "center";
onDateTimeUpdate?: (dateTime: Date) => void;
dateTime?: Date;
disabledBefore?: Date;
disabledAfter?: Date;
}
export function DateTimePicker(props: DateTimePickerProps) {
const { className, buttonClassName, triggerLabel, onTrigger, dateTime } = props;
const initialDate = dateTime ? new Date(dateTime) : new Date();
const [date, setDate] = React.useState<Date | undefined>(initialDate);
const [timeValue, setTimeValue] = React.useState<TimeValue>({ hour: initialDate.getHours(), minute: initialDate.getMinutes() });
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const disabledDateRange = useMemo(() => {
if (!props.disabledBefore && !props.disabledAfter) return undefined;
let range: any = {};
if (props.disabledBefore) range["before"] = props.disabledBefore;
if (props.disabledAfter) range["after"] = props.disabledAfter;
return range;
}, [props.disabledBefore, props.disabledAfter]);
const printTimeValue = (timeObj: TimeValue): string => {
// Validate input
if (!timeObj || timeObj.hour < 0 || timeObj.hour >= 24 || timeObj.minute < 0 || timeObj.minute >= 60) {
return "";
}
let hour = ((timeObj.hour + 11) % 12) + 1; // Convert hour to 12-hour format
let period = timeObj.hour >= 12 ? "PM" : "AM"; // Determine AM/PM
let minute = timeObj.minute.toString().padStart(2, "0"); // Ensure the minute has two digits
return `${hour}:${minute} ${period}`;
};
const getDateTime = (date: Date | undefined | null, time: TimeValue | undefined | null): Date | undefined => {
if (!date) return undefined;
const dateTime = new Date(date);
if (time) dateTime.setHours(time.hour, time.minute);
return dateTime;
};
useEffect(() => {
if (dateTime) {
const newDate = new Date(dateTime);
setDate(newDate);
setTimeValue({ hour: newDate.getHours(), minute: newDate.getMinutes() });
}
}, [dateTime]);
return (
<div className={cn("grid gap-2", className)}>
<Popover
modal={true}
onOpenChange={(open) => {
setIsOpen(open);
}}
>
<PopoverTrigger asChild>
<Button
id="date"
variant="default"
className={cn(
"w-max justify-start text-left font-normal",
!date && "text-content-disabled",
buttonClassName,
isOpen && "border-black",
)}
>
<CalendarIcon className="h-4 w-4" strokeWidth={1.5} />
{date ? (
<>
{format(date, "LLL dd, y")} {printTimeValue(timeValue)}
</>
) : (
<span>Pick a date and time</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align={props.popupAlignment ? props.popupAlignment : "start"}>
<div className="p-2">
<Calendar
autoFocus
mode="single"
disabled={disabledDateRange}
defaultMonth={date}
selected={date}
onSelect={(selectedDate) => {
if (!selectedDate) return;
setDate(selectedDate);
const newDateTime = getDateTime(selectedDate, timeValue);
if (newDateTime?.toISOString() !== props.dateTime?.toISOString()) {
props.onDateTimeUpdate && newDateTime && props.onDateTimeUpdate(newDateTime);
}
}}
/>
<div className="mt-3 flex flex-col gap-1 px-2 pb-2">
<Label className="ml-0.5">Time</Label>
<TimePicker
aria-label="Time"
className=""
value={timeValue}
onChange={(v) => {
if (v) setTimeValue(v);
const newDateTime = getDateTime(date, v);
if (newDateTime?.toISOString() !== props.dateTime?.toISOString()) {
props.onDateTimeUpdate && newDateTime && props.onDateTimeUpdate(newDateTime);
}
}}
/>
</div>
</div>
{triggerLabel && onTrigger && (
<div className="mt-1 mb-2 flex w-full px-3">
<Button
className="ml-auto"
onClick={(e) =>
onTrigger(e, {
date: date,
time: timeValue,
})
}
>
{triggerLabel}
</Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}

106
ui/components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,106 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
disableOutsideClick = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
disableOutsideClick?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"dark:bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-white p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
onInteractOutside={disableOutsideClick ? (e) => e.preventDefault() : undefined}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-6 right-4 z-10 cursor-pointer rounded-xs opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="dialog-header" className={cn("dark:bg-card flex flex-col gap-2 pb-4 text-center sm:text-left", className)} {...props} />
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end", className)} {...props} />
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return <DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props} />;
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props} />
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,206 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-sm border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
);
}
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-sm border p-1 shadow-none",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@@ -0,0 +1,250 @@
import { useCallback, useMemo } from "react";
import { AsyncMultiSelect } from "./asyncMultiselect";
import { Option } from "./multiselectUtils";
import { cn } from "./utils";
// Entity types supported by this component
export type EntityType = "virtualKey" | "team" | "customer" | "user" | "provider" | "apiKey";
// Generic entity option
export interface EntityOption {
id: string | number;
label: string;
description?: string;
metadata?: Record<string, unknown>;
}
// Meta type for AsyncMultiSelect
interface EntityOptionMeta {
id: string | number;
description?: string;
metadata?: Record<string, unknown>;
}
interface EntityAssociationSelectProps {
/** The type of entity being selected */
entityType: EntityType;
/** Currently selected entity IDs */
value: (string | number)[];
/** Callback when selection changes */
onChange: (ids: (string | number)[]) => void;
/** Placeholder text */
placeholder?: string;
/** Whether the component is disabled */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
/**
* Custom reload function for fetching options.
* If provided, this will be used instead of the default behavior.
* The function should call the callback with filtered options.
*/
customReload?: (query: string, callback: (options: Option<EntityOptionMeta>[]) => void) => void;
/**
* Static options to use when customReload is not provided.
* This is useful for simple use cases where all options are already available.
*/
options?: EntityOption[];
/**
* Whether to allow creating new options
*/
isCreatable?: boolean;
/**
* Callback when a new option is created
*/
onCreateOption?: (value: string) => void;
/**
* Format function for creating new option labels
*/
formatCreateLabel?: (inputValue: string) => string;
/**
* Message to display when no options are available
*/
noOptionsMessage?: () => string;
}
// Default placeholder text for each entity type
const defaultPlaceholders: Record<EntityType, string> = {
virtualKey: "Add virtual key names...",
team: "Add team names...",
customer: "Add customer names...",
user: "Add user names...",
provider: "Add provider names...",
apiKey: "Add API key names...",
};
// Default no options messages for each entity type
const defaultNoOptionsMessages: Record<EntityType, string> = {
virtualKey: "No virtual keys found",
team: "No teams found",
customer: "No customers found",
user: "No users found",
provider: "No providers found",
apiKey: "No API keys found",
};
// Label text for each entity type
export const entityTypeLabels: Record<EntityType, string> = {
virtualKey: "Virtual Keys",
team: "Teams",
customer: "Customers",
user: "Users",
provider: "Providers",
apiKey: "API Keys",
};
export function EntityAssociationSelect({
entityType,
value,
onChange,
placeholder,
disabled = false,
className,
customReload,
options = [],
isCreatable = false,
onCreateOption,
formatCreateLabel,
noOptionsMessage,
}: EntityAssociationSelectProps) {
// Convert static options to AsyncMultiSelect format using meta for complex data
const defaultOptions = useMemo((): Option<EntityOptionMeta>[] => {
return options.map((opt) => ({
label: opt.label,
value: String(opt.id),
meta: {
id: opt.id,
description: opt.description,
metadata: opt.metadata,
},
}));
}, [options]);
// Convert selected IDs to Option format
const selectedValues = useMemo((): Option<EntityOptionMeta>[] => {
return value.map((id) => {
// Try to find the option in the provided options
const existingOption = options.find((opt) => opt.id === id);
if (existingOption) {
return {
label: existingOption.label,
value: String(existingOption.id),
meta: {
id: existingOption.id,
description: existingOption.description,
metadata: existingOption.metadata,
},
};
}
// If not found, create a basic option
return {
label: String(id),
value: String(id),
meta: {
id,
},
};
});
}, [value, options]);
// Filter options based on query
const filterOptions = useCallback(
(query: string, callback: (options: Option<EntityOptionMeta>[]) => void) => {
const lowerQuery = query.toLowerCase();
const filtered = defaultOptions.filter(
(opt) => opt.label.toLowerCase().includes(lowerQuery) || opt.meta?.description?.toLowerCase().includes(lowerQuery),
);
callback(filtered);
},
[defaultOptions],
);
// Use custom reload if provided, otherwise use local filter
const reload = customReload || filterOptions;
// Handle selection change
const handleChange = useCallback(
(selected: Option<EntityOptionMeta>[]) => {
const ids = selected.map((opt) => opt.meta?.id ?? opt.value);
onChange(ids);
},
[onChange],
);
// Handle creating new option
const handleCreateOption = useCallback(
(inputValue: string) => {
if (onCreateOption) {
onCreateOption(inputValue);
}
},
[onCreateOption],
);
return (
<div className={cn("w-full", className)}>
<AsyncMultiSelect<EntityOptionMeta>
placeholder={placeholder || defaultPlaceholders[entityType]}
disabled={disabled}
defaultOptions={defaultOptions}
reload={reload}
debounce={200}
onChange={handleChange}
value={selectedValues}
isClearable
closeMenuOnSelect={false}
hideSelectedOptions={false}
isCreatable={isCreatable}
onCreateOption={handleCreateOption}
formatCreateLabel={formatCreateLabel || ((value) => `Add "${value}"`)}
noOptionsMessage={noOptionsMessage || (() => defaultNoOptionsMessages[entityType])}
views={{
option: (props) => {
// Access data as Option<EntityOptionMeta> since that's the actual runtime type
const data = props.data as unknown as Option<EntityOptionMeta>;
return (
<div
className={cn(
"flex w-full cursor-pointer flex-col gap-0.5 rounded-sm p-2 text-sm",
props.isFocused && "bg-background-highlight-primary/60",
props.isSelected && "bg-background-highlight-primary/40",
)}
onClick={() => props.selectOption(props.data)}
>
<div className="flex items-center justify-between">
<span className="text-content-primary font-medium">{data.label}</span>
{props.isSelected && <span className="text-primary text-xs">Selected</span>}
</div>
{data.meta?.description && <span className="text-content-tertiary line-clamp-1 text-xs">{data.meta.description}</span>}
</div>
);
},
}}
/>
</div>
);
}
/**
* Helper function to create EntityOption from simple ID list
* Useful when you just have IDs without full entity info
*/
export function createOptionsFromIds(ids: (string | number)[]): EntityOption[] {
return ids.map((id) => ({
id,
label: String(id),
}));
}
/**
* Helper function to create EntityOption with label mapping
*/
export function createOptionsWithLabels(
items: { id: string | number; name?: string; label?: string; description?: string }[],
): EntityOption[] {
return items.map((item) => ({
id: item.id,
label: item.name || item.label || String(item.id),
description: item.description,
}));
}

View File

@@ -0,0 +1,113 @@
import { EnvVar } from "@/lib/types/schemas";
import { cn } from "@/lib/utils";
import * as React from "react";
import { useEffect, useRef } from "react";
import { Badge } from "./badge";
type BaseEnvVarInputProps = {
value?: EnvVar;
onChange?: (value: EnvVar) => void;
inputClassName?: string;
variant?: "input" | "textarea";
rows?: number;
};
type InputVariantProps = BaseEnvVarInputProps & {
variant?: "input";
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange">;
type TextareaVariantProps = BaseEnvVarInputProps & {
variant: "textarea";
} & Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange">;
export type EnvVarInputProps = InputVariantProps | TextareaVariantProps;
export const EnvVarInput = React.forwardRef<HTMLInputElement | HTMLTextAreaElement, EnvVarInputProps>(
({ className, value, onChange, inputClassName, variant = "input", rows, ...props }, ref) => {
// Extract display value from EnvVar object
const displayValue = value?.value ?? "";
const hasChanged = useRef(false);
const isUserChange = useRef(false);
// Reset hasChanged when value prop changes externally (save/switch items)
useEffect(() => {
if (!isUserChange.current) {
// External change (save/switch) - reset hasChanged
hasChanged.current = false;
}
// Reset the flag for the next change
isUserChange.current = false;
}, [value]);
// Show badge when value is from env (server-synced or user-typed)
const showBadge = value?.from_env && value?.env_var;
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = e.target.value;
hasChanged.current = true;
isUserChange.current = true;
// Auto-detect env var prefix
if (newValue.startsWith("env.")) {
onChange?.({ value: newValue, env_var: newValue, from_env: true });
} else {
onChange?.({ value: newValue, env_var: "", from_env: false });
}
};
// Show hint when user is typing an env var (from_env is true but no resolved value yet)
const showEnvHint = value?.from_env && value?.env_var && hasChanged.current;
const isTextarea = variant === "textarea";
const sharedClassName = cn(
"placeholder:text-muted-foreground/70 selection:bg-primary selection:text-primary-foreground w-full min-w-0 bg-transparent px-3 py-1 text-base shadow-none outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
inputClassName,
);
const containerClassName = cn(
"dark:bg-input/30 border-input focus-within:border-primary flex w-full items-center rounded-sm border bg-transparent transition-[color,box-shadow]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
isTextarea ? "min-h-[80px] items-end" : "h-9",
className,
);
return (
<div className="w-full">
<div className={containerClassName}>
{isTextarea ? (
<textarea
ref={ref as React.Ref<HTMLTextAreaElement>}
data-slot="textarea"
className={cn(sharedClassName, "h-full resize-none py-2")}
value={displayValue}
onChange={handleChange}
rows={rows ?? 4}
{...(props as Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange">)}
/>
) : (
<input
type={(props as React.InputHTMLAttributes<HTMLInputElement>).type}
ref={ref as React.Ref<HTMLInputElement>}
data-slot="input"
className={cn(
sharedClassName,
"file:text-foreground flex h-full file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
)}
value={displayValue}
onChange={handleChange}
{...(props as Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange">)}
/>
)}
{showBadge && (
<Badge variant="success" className={cn("mr-2 whitespace-nowrap", isTextarea && "mb-2")}>
{value?.env_var}
</Badge>
)}
</div>
{showEnvHint && <p className="mt-1.5 text-xs text-orange-400">The resolved value will appear after saving</p>}
</div>
);
},
);
EnvVarInput.displayName = "EnvVarInput";

126
ui/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,126 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div data-slot="form-item" className={cn("grid gap-2", className)} {...props} />
</FormItemContext.Provider>
);
}
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return <p data-slot="form-description" id={formDescriptionId} className={cn("text-muted-foreground text-sm", className)} {...props} />;
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p data-slot="form-message" id={formMessageId} className={cn("text-destructive text-sm", className)} {...props}>
{body}
</p>
);
}
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };

View File

@@ -0,0 +1,71 @@
import React, { useState } from "react";
import { Input } from "./input";
import { Label } from "./label";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
import { Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { ValidationConfig, validateField } from "@/lib/utils/validation";
interface FormFieldProps {
label?: string;
validation?: ValidationConfig;
onChange?: (value: string) => void;
className?: string;
tooltipSide?: "top" | "right" | "bottom" | "left";
value?: string;
[key: string]: any; // Allow any additional input props
}
export function FormField({
label,
validation,
onChange,
className,
tooltipSide = "right",
value: initialValue = "",
...props
}: FormFieldProps) {
const [touched, setTouched] = useState(false);
const [value, setValue] = useState(initialValue);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setValue(newValue);
onChange?.(newValue);
};
const handleBlur = () => {
setTouched(true);
};
const validationResult = validation ? validateField(value, validation, touched) : { isValid: true, message: "", showTooltip: false };
return (
<div className={cn("space-y-2", className)}>
{label && (
<div className="flex items-center space-x-2">
<Label>{label}</Label>
{validation && (validationResult.showTooltip || validation.showAlways) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className={cn("h-4 w-4", validationResult.isValid ? "text-muted-foreground" : "text-destructive")} />
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p>{validationResult.message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
<Input
{...props}
value={value}
onChange={handleChange}
onBlur={handleBlur}
className={cn(!validationResult.isValid && touched && "border-destructive", props.className)}
/>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { cn } from "@/lib/utils";
function GradientHeader({ title, className }: { title: string; className?: string }) {
return (
<div className={cn("from-primary bg-gradient-to-r to-green-600 bg-clip-text pb-2 text-5xl font-bold text-transparent", className)}>
{title}
</div>
);
}
export default GradientHeader;

Some files were not shown because too many files have changed in this diff Show More