first commit
This commit is contained in:
42
ui/components/chat/ImageMessage.tsx
Normal file
42
ui/components/chat/ImageMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
728
ui/components/devProfiler.tsx
Normal file
728
ui/components/devProfiler.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
795
ui/components/filters/logsFilterSidebar.tsx
Normal file
795
ui/components/filters/logsFilterSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
375
ui/components/filters/mcpFilterSidebar.tsx
Normal file
375
ui/components/filters/mcpFilterSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
ui/components/formFooter.tsx
Normal file
49
ui/components/formFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
ui/components/fullPageLoader.tsx
Normal file
11
ui/components/fullPageLoader.tsx
Normal 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
14
ui/components/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
ui/components/loggingDisabledView.tsx
Normal file
44
ui/components/loggingDisabledView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
ui/components/noPermissionView.tsx
Normal file
30
ui/components/noPermissionView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
ui/components/notAvailableBanner.tsx
Normal file
35
ui/components/notAvailableBanner.tsx
Normal 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;
|
||||
34
ui/components/progressBar.tsx
Normal file
34
ui/components/progressBar.tsx
Normal 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;
|
||||
71
ui/components/prompts/components/alerts.tsx
Normal file
71
ui/components/prompts/components/alerts.tsx
Normal 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 "{deleteFolderDialog.folder?.name}"? 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 "{deletePromptDialog.prompt?.name}"? 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>
|
||||
);
|
||||
}
|
||||
92
ui/components/prompts/components/apiKeySelectorView.tsx
Normal file
92
ui/components/prompts/components/apiKeySelectorView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
ui/components/prompts/components/emptyState.tsx
Normal file
73
ui/components/prompts/components/emptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
183
ui/components/prompts/components/messagesView/toolCallView.tsx
Normal file
183
ui/components/prompts/components/messagesView/toolCallView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
270
ui/components/prompts/components/newMessageInputView.tsx
Normal file
270
ui/components/prompts/components/newMessageInputView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
369
ui/components/prompts/components/promptsViewHeader.tsx
Normal file
369
ui/components/prompts/components/promptsViewHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
ui/components/prompts/components/sheets.tsx
Normal file
42
ui/components/prompts/components/sheets.tsx
Normal 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 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
ui/components/prompts/components/variablesTableView.tsx
Normal file
58
ui/components/prompts/components/variablesTableView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
623
ui/components/prompts/context.tsx
Normal file
623
ui/components/prompts/context.tsx
Normal 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>;
|
||||
}
|
||||
14
ui/components/prompts/fragments/playgroundPanel.tsx
Normal file
14
ui/components/prompts/fragments/playgroundPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
ui/components/prompts/fragments/settingsPanel.tsx
Normal file
232
ui/components/prompts/fragments/settingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
584
ui/components/prompts/fragments/sidebar.tsx
Normal file
584
ui/components/prompts/fragments/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
ui/components/prompts/promptsView.tsx
Normal file
83
ui/components/prompts/promptsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
ui/components/prompts/sheets/commitVersionSheet.tsx
Normal file
219
ui/components/prompts/sheets/commitVersionSheet.tsx
Normal 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., "Added error handling instructions")
|
||||
</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>
|
||||
);
|
||||
}
|
||||
131
ui/components/prompts/sheets/folderSheet.tsx
Normal file
131
ui/components/prompts/sheets/folderSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
ui/components/prompts/sheets/promptSheet.tsx
Normal file
117
ui/components/prompts/sheets/promptSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
ui/components/prompts/utils/attachment.ts
Normal file
42
ui/components/prompts/utils/attachment.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
183
ui/components/prompts/utils/executor.ts
Normal file
183
ui/components/prompts/utils/executor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
ui/components/provider.tsx
Normal file
16
ui/components/provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
ui/components/rateLimitDisplay.tsx
Normal file
122
ui/components/rateLimitDisplay.tsx
Normal 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
1272
ui/components/sidebar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
58
ui/components/table/columnConfigDropdown.tsx
Normal file
58
ui/components/table/columnConfigDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
ui/components/table/columnPinning.ts
Normal file
94
ui/components/table/columnPinning.ts
Normal 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)]";
|
||||
130
ui/components/table/draggableColumnHeader.tsx
Normal file
130
ui/components/table/draggableColumnHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
ui/components/table/hooks/useColumnConfig.ts
Normal file
143
ui/components/table/hooks/useColumnConfig.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
5
ui/components/table/index.ts
Normal file
5
ui/components/table/index.ts
Normal 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";
|
||||
6
ui/components/themeProvider.tsx
Normal file
6
ui/components/themeProvider.tsx
Normal 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>;
|
||||
}
|
||||
30
ui/components/themeToggle.tsx
Normal file
30
ui/components/themeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
ui/components/ui/accordion.tsx
Normal file
56
ui/components/ui/accordion.tsx
Normal 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 };
|
||||
43
ui/components/ui/alert.tsx
Normal file
43
ui/components/ui/alert.tsx
Normal 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 };
|
||||
92
ui/components/ui/alertDialog.tsx
Normal file
92
ui/components/ui/alertDialog.tsx
Normal 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,
|
||||
};
|
||||
7
ui/components/ui/aspectRatio.tsx
Normal file
7
ui/components/ui/aspectRatio.tsx
Normal 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 };
|
||||
747
ui/components/ui/asyncMultiselect.tsx
Normal file
747
ui/components/ui/asyncMultiselect.tsx
Normal 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} />;
|
||||
}
|
||||
30
ui/components/ui/avatar.tsx
Normal file
30
ui/components/ui/avatar.tsx
Normal 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 };
|
||||
48
ui/components/ui/badge.tsx
Normal file
48
ui/components/ui/badge.tsx
Normal 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 };
|
||||
73
ui/components/ui/breadcrumb.tsx
Normal file
73
ui/components/ui/breadcrumb.tsx
Normal 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 };
|
||||
81
ui/components/ui/button.tsx
Normal file
81
ui/components/ui/button.tsx
Normal 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 };
|
||||
145
ui/components/ui/calendar.tsx
Normal file
145
ui/components/ui/calendar.tsx
Normal 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
50
ui/components/ui/card.tsx
Normal 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 };
|
||||
24
ui/components/ui/checkbox.tsx
Normal file
24
ui/components/ui/checkbox.tsx
Normal 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 };
|
||||
240
ui/components/ui/codeEditor.tsx
Normal file
240
ui/components/ui/codeEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
ui/components/ui/collapsible.tsx
Normal file
15
ui/components/ui/collapsible.tsx
Normal 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 };
|
||||
520
ui/components/ui/combobox.tsx
Normal file
520
ui/components/ui/combobox.tsx
Normal 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 };
|
||||
112
ui/components/ui/command.tsx
Normal file
112
ui/components/ui/command.tsx
Normal 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 };
|
||||
21
ui/components/ui/configSyncAlert.tsx
Normal file
21
ui/components/ui/configSyncAlert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
ui/components/ui/custom/celBuilder/actionButton.tsx
Normal file
40
ui/components/ui/custom/celBuilder/actionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
ui/components/ui/custom/celBuilder/celRuleBuilder.tsx
Normal file
185
ui/components/ui/custom/celBuilder/celRuleBuilder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
ui/components/ui/custom/celBuilder/combinatorSelector.tsx
Normal file
29
ui/components/ui/custom/celBuilder/combinatorSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
ui/components/ui/custom/celBuilder/fieldSelector.tsx
Normal file
110
ui/components/ui/custom/celBuilder/fieldSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
ui/components/ui/custom/celBuilder/index.ts
Normal file
2
ui/components/ui/custom/celBuilder/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CELRuleBuilder } from "./celRuleBuilder";
|
||||
export type { CELRuleBuilderProps, CELFieldDefinition, CELOperatorDefinition } from "./celRuleBuilder";
|
||||
34
ui/components/ui/custom/celBuilder/operatorSelector.tsx
Normal file
34
ui/components/ui/custom/celBuilder/operatorSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
ui/components/ui/custom/celBuilder/queryBuilderWrapper.css
Normal file
97
ui/components/ui/custom/celBuilder/queryBuilderWrapper.css
Normal 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%);
|
||||
}
|
||||
15
ui/components/ui/custom/celBuilder/queryBuilderWrapper.tsx
Normal file
15
ui/components/ui/custom/celBuilder/queryBuilderWrapper.tsx
Normal 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>;
|
||||
}
|
||||
321
ui/components/ui/custom/celBuilder/valueEditor.tsx
Normal file
321
ui/components/ui/custom/celBuilder/valueEditor.tsx
Normal 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]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
169
ui/components/ui/custom/dropdown/dropdown.tsx
Normal file
169
ui/components/ui/custom/dropdown/dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
ui/components/ui/custom/dropdown/dropdownGroup.tsx
Normal file
53
ui/components/ui/custom/dropdown/dropdownGroup.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
ui/components/ui/custom/dropdown/dropdownItem.tsx
Normal file
66
ui/components/ui/custom/dropdown/dropdownItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
ui/components/ui/custom/dropdown/index.tsx
Normal file
13
ui/components/ui/custom/dropdown/index.tsx
Normal 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,
|
||||
};
|
||||
158
ui/components/ui/custom/dropdown/searchableDropdown.tsx
Normal file
158
ui/components/ui/custom/dropdown/searchableDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
ui/components/ui/custom/dropdown/types.ts
Normal file
31
ui/components/ui/custom/dropdown/types.ts
Normal 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;
|
||||
};
|
||||
14
ui/components/ui/custom/input.css
Normal file
14
ui/components/ui/custom/input.css
Normal 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;
|
||||
}
|
||||
107
ui/components/ui/custom/modelParameters/booleanFieldView.tsx
Normal file
107
ui/components/ui/custom/modelParameters/booleanFieldView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
ui/components/ui/custom/modelParameters/fieldLabel.tsx
Normal file
48
ui/components/ui/custom/modelParameters/fieldLabel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
ui/components/ui/custom/modelParameters/index.tsx
Normal file
82
ui/components/ui/custom/modelParameters/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
ui/components/ui/custom/modelParameters/jsonFieldView.tsx
Normal file
57
ui/components/ui/custom/modelParameters/jsonFieldView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
ui/components/ui/custom/modelParameters/numberFieldView.tsx
Normal file
84
ui/components/ui/custom/modelParameters/numberFieldView.tsx
Normal 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;
|
||||
};
|
||||
104
ui/components/ui/custom/modelParameters/paramFieldView.tsx
Normal file
104
ui/components/ui/custom/modelParameters/paramFieldView.tsx
Normal 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()}</>;
|
||||
}
|
||||
91
ui/components/ui/custom/modelParameters/selectFieldView.tsx
Normal file
91
ui/components/ui/custom/modelParameters/selectFieldView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
ui/components/ui/custom/modelParameters/textArrayFieldView.tsx
Normal file
132
ui/components/ui/custom/modelParameters/textArrayFieldView.tsx
Normal 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;
|
||||
};
|
||||
31
ui/components/ui/custom/modelParameters/textFieldView.tsx
Normal file
31
ui/components/ui/custom/modelParameters/textFieldView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
ui/components/ui/custom/modelParameters/types.ts
Normal file
56
ui/components/ui/custom/modelParameters/types.ts
Normal 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;
|
||||
};
|
||||
299
ui/components/ui/custom/number.tsx
Normal file
299
ui/components/ui/custom/number.tsx
Normal 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;
|
||||
679
ui/components/ui/custom/richTextarea.tsx
Normal file
679
ui/components/ui/custom/richTextarea.tsx
Normal 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 };
|
||||
410
ui/components/ui/datePickerWithRange.tsx
Normal file
410
ui/components/ui/datePickerWithRange.tsx
Normal 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
106
ui/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
206
ui/components/ui/dropdownMenu.tsx
Normal file
206
ui/components/ui/dropdownMenu.tsx
Normal 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,
|
||||
};
|
||||
250
ui/components/ui/entityAssociationSelect.tsx
Normal file
250
ui/components/ui/entityAssociationSelect.tsx
Normal 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,
|
||||
}));
|
||||
}
|
||||
113
ui/components/ui/envVarInput.tsx
Normal file
113
ui/components/ui/envVarInput.tsx
Normal 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
126
ui/components/ui/form.tsx
Normal 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 };
|
||||
71
ui/components/ui/formField.tsx
Normal file
71
ui/components/ui/formField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
ui/components/ui/gradientHeader.tsx
Normal file
11
ui/components/ui/gradientHeader.tsx
Normal 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
Reference in New Issue
Block a user