From 48537bb3f8530758111bdf07e01d1c474176ad77 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sun, 10 May 2026 01:52:57 +0530 Subject: [PATCH 01/14] feat(revamp): document, configuration and keystore --- app/(main)/configurations/page.tsx | 160 ++--- .../configurations/prompt-editor/page.tsx | 71 +- app/(main)/keystore/page.tsx | 666 +++++++----------- app/components/ConfigCard.tsx | 454 +++++------- app/components/ConfigLibrarySkeleton.tsx | 36 + app/components/Field.tsx | 3 + app/components/MultiSelect.tsx | 69 +- app/components/PageHeader.tsx | 16 +- app/components/Sidebar.tsx | 2 +- app/components/configurations/VersionPill.tsx | 36 + app/components/index.ts | 1 + .../prompt-editor/ConfigEditorPane.tsx | 311 ++++---- app/components/prompt-editor/DiffView.tsx | 448 +++++------- .../prompt-editor/GuardrailsSection.tsx | 112 +-- app/components/prompt-editor/Header.tsx | 27 +- .../prompt-editor/HistorySidebar.tsx | 310 ++------ .../prompt-editor/PromptEditorPane.tsx | 81 ++- .../prompt-editor/SaveConfigModal.tsx | 87 +++ 18 files changed, 1252 insertions(+), 1638 deletions(-) create mode 100644 app/components/ConfigLibrarySkeleton.tsx create mode 100644 app/components/configurations/VersionPill.tsx create mode 100644 app/components/prompt-editor/SaveConfigModal.tsx diff --git a/app/(main)/configurations/page.tsx b/app/(main)/configurations/page.tsx index a36ab484..269a84fe 100644 --- a/app/(main)/configurations/page.tsx +++ b/app/(main)/configurations/page.tsx @@ -9,10 +9,11 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; -import { colors } from "@/app/lib/colors"; -import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; +import { Button } from "@/app/components"; +import Loader from "@/app/components/Loader"; import ConfigCard from "@/app/components/ConfigCard"; -import Loader, { LoaderBox } from "@/app/components/Loader"; +import ConfigLibrarySkeleton from "@/app/components/ConfigLibrarySkeleton"; +import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; import { EvalJob } from "@/app/lib/types/evaluation"; import { ConfigPublic, @@ -68,7 +69,6 @@ export default function ConfigLibraryPage() { isLoading: isLoading || isLoadingMore, }); - // Responsive column count (matches Tailwind lg/xl breakpoints) useEffect(() => { const update = () => { if (window.innerWidth >= 1280) setColumnCount(3); @@ -80,7 +80,6 @@ export default function ConfigLibraryPage() { return () => window.removeEventListener("resize", update); }, []); - // Distribute configs into fixed columns so items never shift between columns const columns = useMemo(() => { const cols: ConfigPublic[][] = Array.from( { length: columnCount }, @@ -99,13 +98,16 @@ export default function ConfigLibraryPage() { }, [searchInput]); useEffect(() => { + if (!isAuthenticated || !apiKey) return; + + let cancelled = false; const fetchEvaluationCounts = async () => { - if (!isAuthenticated) return; try { const data = await apiFetch( "/api/evaluations", apiKey, ); + if (cancelled) return; const jobs: EvalJob[] = Array.isArray(data) ? data : data.data || []; const counts: Record = {}; jobs.forEach((job) => { @@ -115,11 +117,20 @@ export default function ConfigLibraryPage() { }); setEvaluationCounts(counts); } catch (e) { - console.error("Failed to fetch evaluation counts:", e); + if (!cancelled) { + // Non-critical enrichment — log a warning instead of an error so it + // doesn't surface as a red console banner when the evals endpoint is + // briefly unavailable (e.g. backend warm-up, 5xx). + console.warn("Could not fetch evaluation counts:", e); + } } }; fetchEvaluationCounts(); - }, [activeKey]); + + return () => { + cancelled = true; + }; + }, [apiKey, isAuthenticated]); const loadVersionsForConfig = useCallback( async (configId: string) => { @@ -187,88 +198,44 @@ export default function ConfigLibraryPage() { }; return ( -
+
- {/* Toolbar */} -
+
- + setSearchInput(e.target.value)} placeholder="Search configs..." - className="w-full pl-10 pr-4 py-2 rounded-md text-sm focus:outline-none transition-colors" - style={{ - backgroundColor: colors.bg.secondary, - border: `1px solid ${colors.border}`, - color: colors.text.primary, - }} + className="w-full pl-11 pr-4 py-2 rounded-full bg-bg-secondary text-text-primary text-sm placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-accent-primary/20 focus:bg-bg-primary transition-colors" />
- +
{isLoading ? ( - + ) : error ? ( -
- -

{error}

+
+ +

+ {error} +

) : configs.length === 0 ? ( -
+
{debouncedQuery ? ( <> - -

- No configs match "{debouncedQuery}" +

+ +
+

+ No configs match “{debouncedQuery}”

) : ( <> - -

+

+ +
+

No configurations yet

-

- Create your first configuration to get started +

+ Create your first configuration to start building prompts + and model setups.

- + + Create Configuration + )}
diff --git a/app/(main)/configurations/prompt-editor/page.tsx b/app/(main)/configurations/prompt-editor/page.tsx index 19677150..86cce60e 100644 --- a/app/(main)/configurations/prompt-editor/page.tsx +++ b/app/(main)/configurations/prompt-editor/page.tsx @@ -7,7 +7,6 @@ import React, { useState, useEffect, Suspense } from "react"; import { useSearchParams } from "next/navigation"; import Sidebar from "@/app/components/Sidebar"; -import { colors } from "@/app/lib/colors"; import { ConfigBlob, Tool } from "@/app/lib/types/promptEditor"; import { hasConfigChanges } from "@/app/lib/promptEditorUtils"; import Header from "@/app/components/prompt-editor/Header"; @@ -78,8 +77,6 @@ function PromptEditorContent() { ); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [commitMessage, setCommitMessage] = useState(""); - const [showHistorySidebar, setShowHistorySidebar] = useState(true); - const [showConfigPane, setShowConfigPane] = useState(true); const [selectedVersion, setSelectedVersion] = useState( null, ); @@ -379,6 +376,52 @@ function PromptEditorContent() { } }; + const handleRenameConfig = async ( + configId: string, + newName: string, + ): Promise => { + const apiKey = activeKey?.key ?? ""; + if (!isAuthenticated) { + toast.error("Please log in to rename configurations."); + return false; + } + const trimmed = newName.trim(); + if (!trimmed) { + toast.error("Name cannot be empty."); + return false; + } + if (trimmed === currentConfigName) return true; + + const conflict = allConfigMeta.find( + (m) => m.name === trimmed && m.id !== configId, + ); + if (conflict) { + toast.error(`A configuration named "${trimmed}" already exists.`); + return false; + } + + try { + const data = await apiFetch<{ success: boolean; error?: string }>( + `/api/configs/${configId}`, + apiKey, + { method: "PATCH", body: JSON.stringify({ name: trimmed }) }, + ); + if (!data.success) { + toast.error(`Failed to rename: ${data.error || "Unknown error"}`); + return false; + } + setCurrentConfigName(trimmed); + invalidateConfigCache(); + await refetchConfigs(true); + toast.success(`Renamed to "${trimmed}"`); + return true; + } catch (e) { + console.error("Failed to rename config:", e); + toast.error("Failed to rename configuration."); + return false; + } + }; + return (
@@ -400,16 +443,8 @@ function PromptEditorContent() {
{!editorReady ? ( -
-
-
-

- Loading configuration... -

-
+
+
) : ( <> @@ -419,8 +454,6 @@ function PromptEditorContent() { currentConfigId={currentConfigParentId || undefined} expandedConfigs={expandedConfigs} setExpandedConfigs={setExpandedConfigs} - collapsed={!showHistorySidebar} - onToggle={() => setShowHistorySidebar(!showHistorySidebar)} onSelectVersion={(version) => { setSelectedVersion(version); setCompareWith(null); @@ -450,7 +483,7 @@ function PromptEditorContent() { loadSingleVersionForConfig={loadSingleVersion} /> - {showHistorySidebar && selectedVersion ? ( + {selectedVersion ? (
setShowConfigPane(!showConfigPane)} apiKey={activeKey?.key ?? ""} />
diff --git a/app/(main)/keystore/page.tsx b/app/(main)/keystore/page.tsx index fcd20e93..27018523 100644 --- a/app/(main)/keystore/page.tsx +++ b/app/(main)/keystore/page.tsx @@ -1,52 +1,69 @@ /** - * KaapiKeystore.tsx - API Key Management Interface - * - * Allows users to securely store and manage API keys for various LLM providers + * Keystore: API Key Management Interface + * Allows users to securely store and manage API keys for various LLM providers. */ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import Sidebar from "@/app/components/Sidebar"; -import PageHeader from "@/app/components/PageHeader"; +import { Button, Field, Modal, PageHeader } from "@/app/components"; +import Select from "@/app/components/Select"; +import { + EyeIcon, + EyeOffIcon, + CopyIcon, + TrashIcon, + KeyIcon, + InfoIcon, + PlusIcon, + CheckLineIcon, +} from "@/app/components/icons"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; +import { useToast } from "@/app/components/Toast"; import { APIKey } from "@/app/lib/types/credentials"; export const STORAGE_KEY = "kaapi_api_keys"; +const PROVIDERS = [{ value: "Kaapi", label: "Kaapi" }]; + export default function KaapiKeystore() { const { sidebarCollapsed } = useApp(); - const [isModalOpen, setIsModalOpen] = useState(false); const { apiKeys, addKey, removeKey: removeApiKey } = useAuth(); + const toast = useToast(); + + const [isModalOpen, setIsModalOpen] = useState(false); const [newKeyLabel, setNewKeyLabel] = useState(""); const [newKeyValue, setNewKeyValue] = useState(""); const [newKeyProvider, setNewKeyProvider] = useState("Kaapi"); const [visibleKeys, setVisibleKeys] = useState>(new Set()); + const [copiedKeyId, setCopiedKeyId] = useState(null); - const providers = ["Kaapi"]; + const resetForm = () => { + setNewKeyLabel(""); + setNewKeyValue(""); + setNewKeyProvider("Kaapi"); + }; const handleAddKey = () => { if (!newKeyLabel.trim() || !newKeyValue.trim()) { - alert("Please provide both label and API key"); + toast.error("Please provide both a label and an API key"); return; } const newKey: APIKey = { id: Date.now().toString(), - label: newKeyLabel, - key: newKeyValue, + label: newKeyLabel.trim(), + key: newKeyValue.trim(), provider: newKeyProvider, createdAt: new Date().toISOString(), }; addKey(newKey); - setNewKeyLabel(""); - setNewKeyValue(""); - setNewKeyProvider("Kaapi"); - - // Close modal after adding + resetForm(); setIsModalOpen(false); + toast.success("API key added successfully"); }; const handleDeleteKey = (id: string) => { @@ -56,44 +73,51 @@ export default function KaapiKeystore() { next.delete(id); return next; }); + toast.success("API key removed"); }; const toggleKeyVisibility = (id: string) => { setVisibleKeys((prev) => { const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }; - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - alert("API key copied to clipboard!"); + const copyToClipboard = async (text: string, id: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedKeyId(id); + toast.success("API key copied to clipboard"); + setTimeout(() => setCopiedKeyId(null), 1500); + } catch { + toast.error("Failed to copy API key"); + } + }; + + const closeAddModal = () => { + setIsModalOpen(false); + resetForm(); }; return ( -
+
- {/* Sidebar */} - {/* Main Content */}
- {/* Content Area */} -
-
- +
+
- {/* Add Key Modal */} - {isModalOpen && ( - setIsModalOpen(false)} - /> - )} +
); } -// ============ STORED KEYS TAB ============ -interface StoredKeysTabProps { +interface KeysCardProps { apiKeys: APIKey[]; visibleKeys: Set; + copiedKeyId: string | null; onToggleVisibility: (id: string) => void; - onCopy: (text: string) => void; + onCopy: (text: string, id: string) => void; onDelete: (id: string) => void; onAddNew: () => void; } -function StoredKeysTab({ +function KeysCard({ apiKeys, visibleKeys, + copiedKeyId, onToggleVisibility, onCopy, onDelete, onAddNew, -}: StoredKeysTabProps) { +}: KeysCardProps) { return ( - <> - {/* Stored Keys List */} -
-
-

- Your API Key -

- {apiKeys.length === 0 && ( - - )} -
+
+
+

+ Your API Key +

+
- {apiKeys.length === 0 ? ( -
- - - -

- No API key stored yet -

-

- Add your API key to get started with evaluations -

- + {apiKeys.length === 0 ? ( +
+
+
- ) : ( -
- {/* Info Message */} -
-
- - - -

- Only one API key can be stored at a time. Delete this key to - add a different one. -

-
-
+

+ No API key stored yet +

+

+ Add your API key to get started with evaluations. +

+ +
+ ) : ( +
+ + Only one API key can be stored at a time. Delete this key to add a + different one. + - {apiKeys.map((apiKey) => ( -
-
-
-
- - {apiKey.provider} - -

- {apiKey.label} -

-
-
- - {visibleKeys.has(apiKey.id) - ? apiKey.key - : "•".repeat(32)} - -
-

- Added {new Date(apiKey.createdAt).toLocaleDateString()} -

-
-
- - - + {apiKeys.map((apiKey) => ( +
+
+
+
+ + {apiKey.provider} + +

+ {apiKey.label} +

+ + {visibleKeys.has(apiKey.id) ? apiKey.key : "•".repeat(32)} + +

+ Added {new Date(apiKey.createdAt).toLocaleDateString()} +

+
+
+ onToggleVisibility(apiKey.id)} + title={visibleKeys.has(apiKey.id) ? "Hide" : "Show"} + > + {visibleKeys.has(apiKey.id) ? : } + + onCopy(apiKey.key, apiKey.id)} + title={copiedKeyId === apiKey.id ? "Copied" : "Copy"} + > + {copiedKeyId === apiKey.id ? ( + + ) : ( + + )} + + onDelete(apiKey.id)} + tone="danger" + title="Delete" + > + +
- ))} -
- )} -
- - {/* Info Card */} -
-
- - - -
-

- Security Note -

-

- API keys are stored in your browser's local storage. For - production use, consider implementing secure server-side storage. -

-
+
+ ))}
+ )} +
+ ); +} + +function InlineNotice({ children }: { children: React.ReactNode }) { + return ( +
+
+ +

{children}

- +
+ ); +} + +function IconButton({ + onClick, + title, + tone = "default", + children, +}: { + onClick: () => void; + title: string; + tone?: "default" | "danger"; + children: React.ReactNode; +}) { + const toneClass = + tone === "danger" + ? "border-status-error-border bg-bg-primary text-status-error-text hover:bg-status-error-bg" + : "border-border bg-bg-primary text-text-secondary hover:bg-neutral-50 hover:text-text-primary"; + + return ( + ); } -// ============ ADD KEY MODAL ============ interface AddKeyModalProps { + open: boolean; newKeyLabel: string; newKeyValue: string; newKeyProvider: string; - providers: string[]; onLabelChange: (value: string) => void; onValueChange: (value: string) => void; onProviderChange: (value: string) => void; @@ -393,177 +299,77 @@ interface AddKeyModalProps { } function AddKeyModal({ + open, newKeyLabel, newKeyValue, newKeyProvider, - providers, onLabelChange, onValueChange, onProviderChange, onAddKey, onClose, }: AddKeyModalProps) { - // Handle backdrop click - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - - // Handle escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - window.addEventListener("keydown", handleEscape); - return () => window.removeEventListener("keydown", handleEscape); - }, [onClose]); + const isDisabled = !newKeyLabel.trim() || !newKeyValue.trim(); return ( -
-
e.stopPropagation()} - > - {/* Modal Header */} -
-

- Add New API Key -

- -
+
+

+ Add a new API key to use in your evaluation workflows. Keys are stored + locally in your browser. +

- {/* Modal Body */} -
-

- Add a new API key to use in your evaluation workflows. All keys are - stored securely in your browser. -

- -
-
- - -
+
+
+ + onLabelChange(e.target.value)} - placeholder="e.g., Production Key" - className="w-full px-4 py-2 rounded-md border focus:outline-none focus:ring-2 border-[hsl(0,0%,85%)] bg-[hsl(0,0%,100%)] text-[hsl(330,3%,19%)]" - /> -
+ -
- - onValueChange(e.target.value)} - placeholder="Paste your API key here" - className="w-full px-4 py-2 rounded-md border focus:outline-none focus:ring-2 font-mono text-sm border-[hsl(0,0%,85%)] bg-[hsl(0,0%,100%)] text-[hsl(330,3%,19%)]" - /> -
-
+ +
- {/* Info Card */} -
-
- - - -

- API keys are stored in your browser's local storage. -

-
+
+
+ +

+ API keys are stored in your browser's local storage. +

+
- {/* Modal Footer */} -
- - -
+
+ +
-
+ ); } diff --git a/app/components/ConfigCard.tsx b/app/components/ConfigCard.tsx index 6b3ff9fb..0847a33d 100644 --- a/app/components/ConfigCard.tsx +++ b/app/components/ConfigCard.tsx @@ -7,10 +7,17 @@ import { useState, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { colors } from "@/app/lib/colors"; import { SavedConfig, ConfigPublic } from "@/app/lib/types/configs"; import { formatRelativeTime } from "@/app/lib/utils"; -import { CheckIcon, CopyIcon } from "@/app/components/icons"; +import { + CheckIcon, + CopyIcon, + ChevronDownIcon, + EditIcon, + PlayIcon, +} from "@/app/components/icons"; +import { Button, VersionPill } from "@/app/components"; +import Loader from "@/app/components/Loader"; import { useToast } from "@/app/hooks/useToast"; interface ConfigCardProps { @@ -38,6 +45,22 @@ export default function ConfigCard({ const [showTools, setShowTools] = useState(false); const [showVectorStores, setShowVectorStores] = useState(false); const [copiedId, setCopiedId] = useState(false); + const [copiedKbId, setCopiedKbId] = useState(null); + + const handleCopyKbId = useCallback( + async (e: React.MouseEvent, kbId: string) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(kbId); + setCopiedKbId(kbId); + toast.success("Knowledge Base ID copied to clipboard"); + setTimeout(() => setCopiedKbId(null), 1500); + } catch { + toast.error("Failed to copy"); + } + }, + [toast], + ); const handleCopyId = useCallback( async (e: React.MouseEvent) => { @@ -60,7 +83,6 @@ export default function ConfigCard({ return; } - // If we already loaded the details, just expand if (latestVersion) { setExpanded(true); return; @@ -70,11 +92,8 @@ export default function ConfigCard({ setExpanded(true); try { - // 1. Fetch the version list for this config await onLoadVersions(config.id); - // 2. Read the cached version items to find the latest version number - // (onLoadVersions populates configState.versionItemsCache) const { configState } = await import("@/app/lib/store/configStore"); const versionItems = configState.versionItemsCache[config.id]; if (!versionItems || versionItems.length === 0) { @@ -88,7 +107,6 @@ export default function ConfigCard({ b.version > a.version ? b : a, ); - // 3. Fetch the full details for the latest version const detail = await onLoadSingleVersion(config.id, latestItem.version); if (detail) { setLatestVersion(detail); @@ -100,7 +118,8 @@ export default function ConfigCard({ } }, [expanded, latestVersion, config.id, onLoadVersions, onLoadSingleVersion]); - const handleEdit = () => { + const handleEdit = (e?: React.MouseEvent) => { + e?.stopPropagation(); router.push( latestVersion ? `/configurations/prompt-editor?config=${config.id}&version=${latestVersion.version}` @@ -118,79 +137,63 @@ export default function ConfigCard({ return (
-
-
- {latestVersion && ( -
- v{latestVersion.version} -
- )} - +
+ {latestVersion && } + +
- {/* Collapsed meta row */} -
Updated {formatRelativeTime(config.updated_at)} {totalVersions > 0 && ( <> - | + | {totalVersions} version{totalVersions !== 1 ? "s" : ""} @@ -198,43 +201,20 @@ export default function ConfigCard({ )} {evaluationCount > 0 && ( <> - | + | {evaluationCount} evaluation{evaluationCount !== 1 ? "s" : ""} )} -
- + +
- {/* Expanded Details */} {expanded && ( -
+
{isLoadingDetails ? ( -
- - - - - Loading details... - +
+
) : latestVersion ? (
@@ -253,6 +233,7 @@ export default function ConfigCard({ onClick={handleCopyId} className="ml-auto p-1 rounded-md transition-colors shrink-0 cursor-pointer hover:bg-neutral-200" title="Copy Config ID" + aria-label="Copy Config ID" > {copiedId ? ( @@ -262,117 +243,64 @@ export default function ConfigCard({
- {/* Config Details */} -
-
- - Provider:{" "} - - - {latestVersion.provider} - -
-
- Type: - - {latestVersion.type === "text" && "Text"} - {latestVersion.type === "stt" && "STT"} - {latestVersion.type === "tts" && "TTS"} - -
-
- Model: - - {latestVersion.modelName} - -
+
+ + + {latestVersion.temperature != null && ( -
- Temp: - - {latestVersion.temperature.toFixed(2)} - -
+ )}
- {/* Tools Dropdown */} {latestVersion.tools && latestVersion.tools.length > 0 && (
{showTools && ( -
+
{latestVersion.tools.map((tool, idx) => (
-
+
{tool.type}
{tool.knowledge_base_ids && tool.knowledge_base_ids.length > 0 && ( -
+
Knowledge Bases:{" "} {tool.knowledge_base_ids.length}
)} {tool.max_num_results && ( -
+
Max Results: {tool.max_num_results}
)}
))} - {/* Vector Stores Section inside Tools */} {(() => { const allVectorStoreIds = latestVersion .tools!.flatMap( @@ -382,63 +310,52 @@ export default function ConfigCard({ return ( allVectorStoreIds.length > 0 && ( -
+
{showVectorStores && ( -
+
{allVectorStoreIds.map((id, idx) => (
- {id} + + {id} + +
))}
@@ -452,98 +369,36 @@ export default function ConfigCard({
)} - {/* Prompt */} -
-

+

+

{latestVersion.instructions || "No instructions set"}

- {/* Actions */}
- - +
) : (
-

+

No version details available

@@ -553,3 +408,12 @@ export default function ConfigCard({
); } + +function MetaPill({ label, value }: { label: string; value: string }) { + return ( +
+ {label}: + {value} +
+ ); +} diff --git a/app/components/ConfigLibrarySkeleton.tsx b/app/components/ConfigLibrarySkeleton.tsx new file mode 100644 index 00000000..c80464fc --- /dev/null +++ b/app/components/ConfigLibrarySkeleton.tsx @@ -0,0 +1,36 @@ +interface ConfigLibrarySkeletonProps { + columnCount?: number; + rows?: number; +} + +export default function ConfigLibrarySkeleton({ + columnCount = 3, + rows = 2, +}: ConfigLibrarySkeletonProps) { + const total = columnCount * rows; + return ( +
+ {Array.from({ length: total }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/app/components/Field.tsx b/app/components/Field.tsx index 7d90b0b8..a5956fde 100644 --- a/app/components/Field.tsx +++ b/app/components/Field.tsx @@ -11,6 +11,7 @@ interface FieldProps { error?: string; type?: string; disabled?: boolean; + autoFocus?: boolean; className?: string; } @@ -22,6 +23,7 @@ export default function Field({ error, type = "text", disabled = false, + autoFocus = false, className = "", }: FieldProps) { const [showPassword, setShowPassword] = useState(false); @@ -40,6 +42,7 @@ export default function Field({ onChange={(e) => onChange(e.target.value)} placeholder={placeholder} disabled={disabled} + autoFocus={autoFocus} className={`w-full px-3 py-2 rounded-lg border text-sm text-text-primary bg-white placeholder:text-neutral-400 focus:outline-none focus:ring-accent-primary/20 focus:border-accent-primary transition-colors ${ isPassword ? "pr-10" : "" } ${error ? "border-red-400" : "border-border"} ${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`} diff --git a/app/components/MultiSelect.tsx b/app/components/MultiSelect.tsx index ddf52e58..8bfc7710 100644 --- a/app/components/MultiSelect.tsx +++ b/app/components/MultiSelect.tsx @@ -1,27 +1,54 @@ "use client"; -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { ChevronDownIcon, ChevronUpIcon } from "@/app/components/icons"; +export type MultiSelectOption = string | { value: string; label: string }; + interface MultiSelectProps { - options: string[]; + options: MultiSelectOption[]; value: string[]; onChange: (value: string[]) => void; placeholder?: string; } +const getValue = (opt: MultiSelectOption) => + typeof opt === "string" ? opt : opt.value; +const getLabel = (opt: MultiSelectOption) => + typeof opt === "string" ? opt : opt.label; + export default function MultiSelect({ options, value, onChange, placeholder, }: MultiSelectProps) { + const labelFor = (val: string) => { + const match = options.find((o) => getValue(o) === val); + return match ? getLabel(match) : val; + }; const listboxId = useId(); const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState<"bottom" | "top">("bottom"); const containerRef = useRef(null); const listRef = useRef(null); const triggerRef = useRef(null); + const DROPDOWN_MAX_HEIGHT = 208; // matches max-h-52 + const FLIP_MARGIN = 8; + + useLayoutEffect(() => { + if (!open || !triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom - FLIP_MARGIN; + const spaceAbove = rect.top - FLIP_MARGIN; + if (spaceBelow >= DROPDOWN_MAX_HEIGHT || spaceBelow >= spaceAbove) { + setPlacement("bottom"); + } else { + setPlacement("top"); + } + }, [open]); + useEffect(() => { const handler = (e: MouseEvent) => { if ( @@ -90,7 +117,7 @@ export default function MultiSelect({ } }; - const unselected = options.filter((o) => !value.includes(o)); + const unselected = options.filter((o) => !value.includes(getValue(o))); return (
@@ -115,7 +142,7 @@ export default function MultiSelect({ className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-bg-secondary text-text-primary border border-border" onClick={(e) => e.stopPropagation()} > - {v} + {labelFor(v)} {unselected.length === 0 ? (

All options selected

) : ( - unselected.map((opt) => ( - - )) + unselected.map((opt) => { + const optValue = getValue(opt); + const optLabel = getLabel(opt); + return ( + + ); + }) )}
)} diff --git a/app/components/PageHeader.tsx b/app/components/PageHeader.tsx index 3a733afe..177b2f34 100644 --- a/app/components/PageHeader.tsx +++ b/app/components/PageHeader.tsx @@ -30,31 +30,33 @@ export default function PageHeader({ return ( <> -
-
+
+
{sidebarCollapsed && ( )} {children ?? ( -
+
{title && ( -

+

{title}

)} {subtitle && ( -

{subtitle}

+

+ {subtitle} +

)}
)}
-
+
{actions} {!isAuthenticated && ( - )} +
+
+

+ Configuration +

- {collapsed && ( -
- - Configuration - -
- )} - - {!collapsed && ( + {
@@ -314,20 +322,25 @@ export default function ConfigEditorPane({ {selectedConfig ? (
- + {selectedConfig.name} - - v{selectedConfig.version} - +
-
+
{selectedConfig.provider}/{selectedConfig.modelName} •{" "} {selectedConfig.type}
) : ( - + + New Configuration )} @@ -429,9 +442,10 @@ export default function ConfigEditorPane({ >
- - v{item.version} - + {item.commit_message || "No message"} @@ -465,25 +479,90 @@ export default function ConfigEditorPane({ )}
-
- - onConfigNameChange(e.target.value)} - placeholder="e.g., my-config" - className={inputClass} - /> - {configName.trim() && ( -

- {existingConfigForHint - ? `💡 Will create a new version for "${configName}"` - : `✨ Will create a new config "${configName}"`} + {isBoundToSavedConfig && !isRenaming && ( +

+ +
+
+ {configName || "Untitled"} +
+ +
+
+ )} + + {isBoundToSavedConfig && isRenaming && ( +
+ +
+ + +
+

+ ✏️ Renames the configuration metadata only — no new version is + created.

- )} -
+
+ )} + + {!isBoundToSavedConfig && ( +
+ + {configName.trim() && ( +

+ {existingConfigForHint + ? `💡 Will create a new version for "${configName}"` + : `✨ Will create a new config "${configName}"`} +

+ )} +
+ )}
-
- - + - handleUpdateTool(index, "knowledge_base_ids", [ - e.target.value, - ]) + onChange={(v) => + handleUpdateTool(index, "knowledge_base_ids", [v]) } placeholder="vs_abc123" - className="w-full px-2 py-1 rounded text-xs focus:outline-none border border-gray-300 bg-bg-primary text-text-primary" />
{!isGpt5 && (
-
); } diff --git a/app/components/prompt-editor/DiffView.tsx b/app/components/prompt-editor/DiffView.tsx index 928ed72e..64b0d10d 100644 --- a/app/components/prompt-editor/DiffView.tsx +++ b/app/components/prompt-editor/DiffView.tsx @@ -1,7 +1,9 @@ import { useMemo, useState } from "react"; -import { colors } from "@/app/lib/colors"; import PromptDiffPane from "./PromptDiffPane"; import ConfigDiffPane from "./ConfigDiffPane"; +import { Button, VersionPill } from "@/app/components"; +import Select, { SelectOption } from "@/app/components/Select"; +import { ArrowLeftIcon, ChevronRightIcon } from "@/app/components/icons"; import { SavedConfig, ConfigVersionItems } from "@/app/lib/types/configs"; import { formatRelativeTime } from "@/app/lib/utils"; @@ -11,21 +13,33 @@ interface DiffViewProps { commits: SavedConfig[]; onCompareChange: (commit: SavedConfig | null) => void; onLoadVersion: (config: SavedConfig) => void; - loadVersionsForConfig?: (config_id: string) => Promise; // lightweight version list for a given config_id - versionItemsMap?: Record; // Lightweight version items per config_id. When provided, the compare dropdown shows ALL version + loadVersionsForConfig?: (config_id: string) => Promise; + versionItemsMap?: Record; onFetchVersionDetail?: ( config_id: string, version: number, - ) => Promise; // Fetches a single version's full details + ) => Promise; } -// Group configs by name for the dropdown interface ConfigGroupForCompare { config_id: string; name: string; items: ConfigVersionItems[]; } +const encodeValue = (config_id: string, version: number) => + `${config_id}:${version}`; +const decodeValue = ( + val: string, +): { config_id: string; version: number } | null => { + const idx = val.lastIndexOf(":"); + if (idx === -1) return null; + return { + config_id: val.slice(0, idx), + version: parseInt(val.slice(idx + 1), 10), + }; +}; + export default function DiffView({ selectedCommit, compareWith, @@ -38,7 +52,6 @@ export default function DiffView({ }: DiffViewProps) { const [isLoadingCompare, setIsLoadingCompare] = useState(false); - // Build groups for the compare dropdown. const configGroups = useMemo((): ConfigGroupForCompare[] => { if (versionItemsMap && Object.keys(versionItemsMap).length > 0) { return Object.entries(versionItemsMap).map(([config_id, items]) => { @@ -71,19 +84,27 @@ export default function DiffView({ }); }, [commits, versionItemsMap]); - // Encode option value as "config_id:version" so we can look up or fetch on select - const encodeValue = (config_id: string, version: number) => - `${config_id}:${version}`; - const decodeValue = ( - val: string, - ): { config_id: string; version: number } | null => { - const idx = val.lastIndexOf(":"); - if (idx === -1) return null; - return { - config_id: val.slice(0, idx), - version: parseInt(val.slice(idx + 1), 10), - }; - }; + const selectOptions: SelectOption[] = useMemo(() => { + const opts: SelectOption[] = []; + configGroups.forEach((group) => { + group.items + .filter( + (v) => + !( + v.config_id === selectedCommit.config_id && + v.version === selectedCommit.version + ), + ) + .forEach((item) => { + opts.push({ + value: encodeValue(item.config_id, item.version), + label: `${group.name} • v${item.version} — ${item.commit_message || "No message"} (${formatRelativeTime(item.inserted_at)})`, + }); + }); + }); + return opts; + }, [configGroups, selectedCommit]); + const currentValue = compareWith ? encodeValue(compareWith.config_id, compareWith.version) : ""; @@ -97,7 +118,6 @@ export default function DiffView({ if (!decoded) return; const { config_id, version } = decoded; - // Fast path: already loaded let detail = commits.find( (c) => c.config_id === config_id && c.version === version, ); @@ -114,32 +134,55 @@ export default function DiffView({ onCompareChange(detail ?? null); }; + const sameConfig = compareWith?.name === selectedCommit.name; + const compareLabelLeft = compareWith + ? sameConfig + ? `Load v${compareWith.version}` + : `Load ${compareWith.name} v${compareWith.version}` + : ""; + const compareLabelRight = compareWith + ? sameConfig + ? `Load v${selectedCommit.version}` + : `Load ${selectedCommit.name} v${selectedCommit.version}` + : ""; + return ( -
-
+
+ {/* Header card */} +
-
- {selectedCommit.name} v{selectedCommit.version} +
+

+ {selectedCommit.name} +

+
-
+
{formatRelativeTime(selectedCommit.timestamp)} •{" "} {selectedCommit.provider}/{selectedCommit.modelName} {selectedCommit.commit_message && ` • ${selectedCommit.commit_message}`}
-
-
- { if (loadVersionsForConfig) { configGroups.forEach((g) => @@ -147,122 +190,72 @@ export default function DiffView({ ); } }} - onChange={(e) => { - handleCompareSelect(e.target.value); - }} - value={currentValue} - disabled={isLoadingCompare} - className="px-3 py-2 rounded-md text-sm min-w-[300px]" - style={{ - border: `1px solid ${colors.border}`, - backgroundColor: colors.bg.primary, - color: colors.text.primary, - outline: "none", - opacity: isLoadingCompare ? 0.6 : 1, - }} - > - - {configGroups.map((group) => ( - - {group.items - .filter( - (v) => - !( - v.config_id === selectedCommit.config_id && - v.version === selectedCommit.version - ), - ) - .map((item) => ( - - ))} - - ))} - - {compareWith && ( -
- - -
- )} + onChange={(e) => handleCompareSelect(e.target.value)} + />
+ {compareWith && ( -
- {compareWith.name === selectedCommit.name - ? `Comparing v${compareWith.version} → v${selectedCommit.version}` - : `Comparing ${compareWith.name} v${compareWith.version} → ${selectedCommit.name} v${selectedCommit.version}`} +
+ +
)}
+ + {compareWith && ( +
+ {sameConfig ? ( + + ) : ( + + + {compareWith.name} + + + + )} + + {sameConfig ? ( + + ) : ( + + + {selectedCommit.name} + + + + )} +
+ )}
{compareWith ? (
-
+
-
+
) : ( -
-
-
-
-
-

- Prompt -

-
-
-
-                    {selectedCommit.promptContent}
-                  
-
-
+
+
+
+

+ Prompt +

-
-
-
-

- Configuration -

-
-
-
-
-
- Provider -
-
- {selectedCommit.provider} -
-
-
-
- Model -
-
- {selectedCommit.modelName} -
+
+
+                {selectedCommit.promptContent}
+              
+
+
+
+
+

+ Configuration +

+
+
+
+ + + {selectedCommit.temperature != null && ( + + )} + {selectedCommit.tools && selectedCommit.tools.length > 0 && ( +
+
+ Tools
- {selectedCommit.temperature != null && ( -
-
- Temperature -
+
+ {selectedCommit.tools.map((tool, idx) => (
- {selectedCommit.temperature} -
-
- )} - {selectedCommit.tools && - selectedCommit.tools.length > 0 && ( -
-
- Tools -
-
- {selectedCommit.tools.map((tool, idx) => ( -
-
- {tool.type} -
- {tool.knowledge_base_ids && ( -
- Knowledge Base: {tool.knowledge_base_ids[0]} -
- )} -
- ))} +
+ {tool.type}
+ {tool.knowledge_base_ids && ( +
+ Knowledge Base: {tool.knowledge_base_ids[0]} +
+ )}
- )} + ))} +
-
+ )}
@@ -402,3 +327,14 @@ export default function DiffView({
); } + +function ReadOnlyField({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} diff --git a/app/components/prompt-editor/GuardrailsSection.tsx b/app/components/prompt-editor/GuardrailsSection.tsx index 76777aea..99b228c6 100644 --- a/app/components/prompt-editor/GuardrailsSection.tsx +++ b/app/components/prompt-editor/GuardrailsSection.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import MultiSelect from "@/app/components/MultiSelect"; import { GuardrailRef } from "@/app/lib/types/configs"; interface ValidatorConfigOption { @@ -27,7 +28,6 @@ export default function GuardrailsSection({ }: GuardrailsSectionProps) { const [validators, setValidators] = useState([]); const [loading, setLoading] = useState(false); - const [dropdownOpen, setDropdownOpen] = useState(false); useEffect(() => { setValidators([]); @@ -60,93 +60,43 @@ export default function GuardrailsSection({ return () => controller.abort(); }, [queryString, apiKey]); - const addedIds = new Set(guardrails.map((g) => g.validator_config_id)); - const available = validators.filter( - (v) => !addedIds.has(v.id) && (!v.stage || v.stage === stage), + const stageValidators = useMemo( + () => validators.filter((v) => !v.stage || v.stage === stage), + [validators, stage], ); - const addDisabled = loading || available.length === 0; - const handleAdd = (id: string) => { - onChange([...guardrails, { validator_config_id: id }]); - setDropdownOpen(false); - }; + const options = useMemo( + () => + stageValidators.map((v) => ({ + value: v.id, + label: v.name || v.type, + })), + [stageValidators], + ); - const handleRemove = (id: string) => { - onChange(guardrails.filter((g) => g.validator_config_id !== id)); - }; + const selectedIds = guardrails.map((g) => g.validator_config_id); - const getValidatorName = (id: string) => { - const v = validators.find( - (v) => v.id === id && (!v.stage || v.stage === stage), - ); - return v ? v.name || v.type : id.slice(0, 8) + "…"; + const handleChange = (ids: string[]) => { + onChange(ids.map((id) => ({ validator_config_id: id }))); }; + const placeholder = loading + ? "Loading validators…" + : stageValidators.length === 0 + ? "No validators available" + : "Select validators…"; + return (
-
- -
- - {dropdownOpen && available.length > 0 && ( - <> -
setDropdownOpen(false)} - /> -
- {available.map((v) => ( - - ))} -
- - )} -
-
- {guardrails.length === 0 ? ( -

No validators added

- ) : ( -
- {guardrails.map((g) => ( -
- - {getValidatorName(g.validator_config_id)} - - -
- ))} -
- )} + +
); } diff --git a/app/components/prompt-editor/Header.tsx b/app/components/prompt-editor/Header.tsx index a8dc29ea..0b035b30 100644 --- a/app/components/prompt-editor/Header.tsx +++ b/app/components/prompt-editor/Header.tsx @@ -1,5 +1,5 @@ import { useRouter } from "next/navigation"; -import PageHeader from "@/app/components/PageHeader"; +import { PageHeader, VersionPill } from "@/app/components"; import { ChevronRightIcon, CheckCircleIcon } from "@/app/components/icons"; interface HeaderProps { @@ -23,32 +23,33 @@ export default function Header({ return ( -