From 88d55de9f2736cdad10c2460b31849cb7234a35d Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Sun, 22 Mar 2026 23:13:10 +0530
Subject: [PATCH 01/10] perf(configs): fix N+1 API calls and refactor config
data layer
---
app/components/ConfigCard.tsx | 3 +-
app/components/ConfigSelector.tsx | 21 +-
.../prompt-editor/ConfigDiffPane.tsx | 4 +-
.../prompt-editor/ConfigEditorPane.tsx | 18 +-
app/components/prompt-editor/DiffView.tsx | 116 ++++-
.../prompt-editor/HistorySidebar.tsx | 231 +++++++--
.../prompt-editor/PromptDiffPane.tsx | 3 +-
app/configurations/page.tsx | 65 ++-
app/configurations/prompt-editor/page.tsx | 24 +-
app/hooks/useConfigs.ts | 390 ++++++++++++++
app/lib/configFetchers.ts | 273 ++++++++++
app/lib/constants.ts | 15 +
app/lib/store/configStore.ts | 93 ++++
app/lib/types/configs.ts | 66 +++
app/lib/useConfigs.ts | 490 ------------------
app/lib/utils.ts | 143 +++++
16 files changed, 1362 insertions(+), 593 deletions(-)
create mode 100644 app/hooks/useConfigs.ts
create mode 100644 app/lib/configFetchers.ts
create mode 100644 app/lib/constants.ts
create mode 100644 app/lib/store/configStore.ts
create mode 100644 app/lib/types/configs.ts
delete mode 100644 app/lib/useConfigs.ts
diff --git a/app/components/ConfigCard.tsx b/app/components/ConfigCard.tsx
index 096d180..73a0444 100644
--- a/app/components/ConfigCard.tsx
+++ b/app/components/ConfigCard.tsx
@@ -8,7 +8,8 @@
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { colors } from '@/app/lib/colors';
-import { ConfigGroup, SavedConfig, formatRelativeTime } from '@/app/lib/useConfigs';
+import { ConfigGroup, SavedConfig } from '@/app/lib/types/configs';
+import { formatRelativeTime } from '@/app/lib/utils';
interface ConfigCardProps {
configGroup: ConfigGroup;
diff --git a/app/components/ConfigSelector.tsx b/app/components/ConfigSelector.tsx
index 5e47493..3c3a82a 100644
--- a/app/components/ConfigSelector.tsx
+++ b/app/components/ConfigSelector.tsx
@@ -8,8 +8,10 @@
import { useState, useRef, useLayoutEffect } from 'react';
import { useRouter } from 'next/navigation';
import { colors } from '@/app/lib/colors';
-import { useConfigs, SavedConfig, formatRelativeTime } from '@/app/lib/useConfigs';
+import { useConfigs } from '@/app/hooks/useConfigs';
+import { SavedConfig } from '@/app/lib/types/configs';
import { ChevronUpIcon, ChevronDownIcon, EditIcon, GearIcon, CheckIcon } from '@/app/components/icons';
+import { formatRelativeTime } from '@/app/lib/utils';
interface ConfigSelectorProps {
selectedConfigId: string;
@@ -33,7 +35,7 @@ export default function ConfigSelector({
experimentName,
}: ConfigSelectorProps) {
const router = useRouter();
- const { configs, configGroups, isLoading, error } = useConfigs();
+ const { configs, configGroups, isLoading, error, loadVersionsForConfig } = useConfigs();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [promptExpanded, setPromptExpanded] = useState(false);
@@ -75,6 +77,19 @@ export default function ConfigSelector({
setSearchQuery(''); // Clear search on close
};
+ // When opening the dropdown, lazily load full version history for all configs
+ const handleOpenDropdown = () => {
+ if (disabled) return;
+ if (!isDropdownOpen) {
+ configGroups.forEach(group => {
+ if (!group.versionsFullyLoaded) {
+ loadVersionsForConfig(group.config_id);
+ }
+ });
+ }
+ setIsDropdownOpen(prev => !prev);
+ };
+
// Build URL params preserving evaluation context
const buildEditorUrl = (configId?: string, version?: number) => {
const params = new URLSearchParams();
@@ -243,7 +258,7 @@ export default function ConfigSelector({
/* Button when dropdown is closed */
!disabled && setIsDropdownOpen(!isDropdownOpen)}
+ onClick={handleOpenDropdown}
disabled={disabled}
className="w-full px-3 py-2 pr-8 rounded-md border text-sm text-left transition-colors cursor-pointer disabled:cursor-not-allowed"
style={{
diff --git a/app/components/prompt-editor/ConfigDiffPane.tsx b/app/components/prompt-editor/ConfigDiffPane.tsx
index 0eae5a4..7711478 100644
--- a/app/components/prompt-editor/ConfigDiffPane.tsx
+++ b/app/components/prompt-editor/ConfigDiffPane.tsx
@@ -1,7 +1,5 @@
-import React from 'react';
import { colors } from '@/app/lib/colors';
-import { ConfigBlob } from '@/app/configurations/prompt-editor/types';
-import { SavedConfig } from '@/app/lib/useConfigs';
+import { SavedConfig } from '@/app/lib/types/configs';
interface ConfigDiffPaneProps {
selectedCommit: SavedConfig;
diff --git a/app/components/prompt-editor/ConfigEditorPane.tsx b/app/components/prompt-editor/ConfigEditorPane.tsx
index 0c43c9c..931ed40 100644
--- a/app/components/prompt-editor/ConfigEditorPane.tsx
+++ b/app/components/prompt-editor/ConfigEditorPane.tsx
@@ -1,7 +1,8 @@
-import React, { useState, useMemo } from 'react';
+import { useState, useMemo } from 'react';
import { colors } from '@/app/lib/colors';
import { ConfigBlob, Tool } from '@/app/configurations/prompt-editor/types';
-import { SavedConfig, formatRelativeTime } from '@/app/lib/useConfigs';
+import { SavedConfig } from '@/app/lib/types/configs';
+import { formatRelativeTime } from '@/app/lib/utils';
interface ConfigEditorPaneProps {
configBlob: ConfigBlob;
@@ -19,6 +20,8 @@ interface ConfigEditorPaneProps {
// Collapse functionality
collapsed?: boolean;
onToggle?: () => void;
+ /** Lazily loads all historical versions for a given config_id */
+ loadVersionsForConfig?: (config_id: string) => Promise;
}
// Group configs by name for nested dropdown
@@ -64,8 +67,17 @@ export default function ConfigEditorPane({
isSaving = false,
collapsed = false,
onToggle,
+ loadVersionsForConfig,
}: ConfigEditorPaneProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+ const handleOpenLoadDropdown = () => {
+ // When opening, trigger lazy loading for any config that only has its latest version
+ if (!isDropdownOpen && loadVersionsForConfig) {
+ configGroups.forEach(group => loadVersionsForConfig(group.config_id));
+ }
+ setIsDropdownOpen(prev => !prev);
+ };
const [showTooltip, setShowTooltip] = useState(null);
const provider = configBlob.completion.provider;
@@ -284,7 +296,7 @@ export default function ConfigEditorPane({
Load Configuration
setIsDropdownOpen(!isDropdownOpen)}
+ onClick={handleOpenLoadDropdown}
className="w-full px-3 py-2.5 rounded-md text-left flex items-center justify-between transition-colors"
style={{
backgroundColor: colors.bg.primary,
diff --git a/app/components/prompt-editor/DiffView.tsx b/app/components/prompt-editor/DiffView.tsx
index a11304d..aefaf60 100644
--- a/app/components/prompt-editor/DiffView.tsx
+++ b/app/components/prompt-editor/DiffView.tsx
@@ -1,8 +1,9 @@
-import React, { useMemo } from 'react';
+import { useMemo, useState } from 'react';
import { colors } from '@/app/lib/colors';
import PromptDiffPane from './PromptDiffPane';
import ConfigDiffPane from './ConfigDiffPane';
-import { SavedConfig, formatRelativeTime } from '@/app/lib/useConfigs';
+import { SavedConfig, ConfigVersionItems } from '@/app/lib/types/configs';
+import { formatRelativeTime } from '@/app/lib/utils';
interface DiffViewProps {
selectedCommit: SavedConfig;
@@ -10,13 +11,26 @@ interface DiffViewProps {
commits: SavedConfig[];
onCompareChange: (commit: SavedConfig | null) => void;
onLoadVersion: (versionId: string) => void;
+ /** Lazily loads the lightweight version list for a given config_id (1 call or no-op) */
+ loadVersionsForConfig?: (config_id: string) => Promise;
+ /**
+ * Lightweight version items per config_id. When provided, the compare dropdown
+ * shows ALL versions (not just loaded ones); full details are fetched on selection.
+ */
+ versionItemsMap?: Record;
+ /**
+ * Fetches a single version's full details on demand (1 GET call).
+ * Called when the user picks a version that isn't yet in `commits`.
+ */
+ onFetchVersionDetail?: (config_id: string, version: number) => Promise;
}
// Group configs by name for the dropdown
interface ConfigGroupForCompare {
config_id: string;
name: string;
- versions: SavedConfig[];
+ /** Lightweight items used for the option list */
+ items: ConfigVersionItems[];
}
export default function DiffView({
@@ -24,10 +38,26 @@ export default function DiffView({
compareWith,
commits,
onCompareChange,
- onLoadVersion
+ onLoadVersion,
+ loadVersionsForConfig,
+ versionItemsMap,
+ onFetchVersionDetail,
}: DiffViewProps) {
- // Group configs by config_id for nested dropdown
- const configGroups = useMemo(() => {
+ const [isLoadingCompare, setIsLoadingCompare] = useState(false);
+
+ // Build groups for the compare dropdown.
+ // Prefer the lightweight versionItemsMap (full history, no config_blob) over
+ // the loaded commits (which may only have the latest version per config).
+ const configGroups = useMemo((): ConfigGroupForCompare[] => {
+ if (versionItemsMap && Object.keys(versionItemsMap).length > 0) {
+ // Use lightweight items as the authoritative list; derive config names from commits
+ return Object.entries(versionItemsMap).map(([config_id, items]) => {
+ const nameFallback = commits.find(c => c.config_id === config_id)?.name ?? config_id;
+ const sorted = [...items].sort((a, b) => b.version - a.version);
+ return { config_id, name: nameFallback, items: sorted };
+ });
+ }
+ // Fallback: use loaded commits
const grouped = new Map();
commits.forEach((config) => {
const existing = grouped.get(config.config_id) || [];
@@ -35,14 +65,49 @@ export default function DiffView({
grouped.set(config.config_id, existing);
});
return Array.from(grouped.entries()).map(([config_id, versions]) => {
- const sortedVersions = versions.sort((a, b) => b.version - a.version);
+ const sorted = versions.sort((a, b) => b.version - a.version);
return {
config_id,
- name: sortedVersions[0].name,
- versions: sortedVersions,
- } as ConfigGroupForCompare;
+ name: sorted[0].name,
+ items: sorted.map(v => ({
+ id: v.id,
+ config_id: v.config_id,
+ version: v.version,
+ commit_message: v.commit_message ?? null,
+ inserted_at: v.timestamp,
+ updated_at: v.timestamp,
+ })),
+ };
});
- }, [commits]);
+ }, [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 currentValue = compareWith ? encodeValue(compareWith.config_id, compareWith.version) : '';
+
+ const handleCompareSelect = async (rawValue: string) => {
+ if (!rawValue) { onCompareChange(null); return; }
+ const decoded = decodeValue(rawValue);
+ 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);
+ if (detail) { onCompareChange(detail); return; }
+
+ // Fetch on demand — 1 API call
+ if (onFetchVersionDetail) {
+ setIsLoadingCompare(true);
+ detail = (await onFetchVersionDetail(config_id, version)) ?? undefined;
+ setIsLoadingCompare(false);
+ }
+ onCompareChange(detail ?? null);
+ };
// Format timestamp
const formatTimestamp = (timestamp: string) => {
@@ -67,27 +132,32 @@ export default function DiffView({
{
- const commit = commits.find(c => c.id === e.target.value);
- onCompareChange(commit || null);
+ onFocus={() => {
+ // Ensure lightweight version lists are populated for all configs
+ if (loadVersionsForConfig) {
+ configGroups.forEach(g => loadVersionsForConfig(g.config_id));
+ }
}}
- value={compareWith?.id || ''}
+ 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'
+ outline: 'none',
+ opacity: isLoadingCompare ? 0.6 : 1,
}}
>
- Select version to compare...
+ {isLoadingCompare ? 'Loading…' : 'Select version to compare...'}
{configGroups.map(group => (
-
- {group.versions
- .filter(v => v.id !== selectedCommit.id)
- .map(version => (
-
- v{version.version} - {version.commit_message || 'No message'} ({formatTimestamp(version.timestamp)})
+
+ {group.items
+ .filter(v => !(v.config_id === selectedCommit.config_id && v.version === selectedCommit.version))
+ .map(item => (
+
+ v{item.version} - {item.commit_message || 'No message'} ({formatTimestamp(item.inserted_at)})
))}
diff --git a/app/components/prompt-editor/HistorySidebar.tsx b/app/components/prompt-editor/HistorySidebar.tsx
index a1dc66d..c3d5cd9 100644
--- a/app/components/prompt-editor/HistorySidebar.tsx
+++ b/app/components/prompt-editor/HistorySidebar.tsx
@@ -1,6 +1,6 @@
-import React, { useState } from 'react';
+import { useState } from 'react';
import { colors } from '@/app/lib/colors';
-import { SavedConfig } from '@/app/lib/useConfigs';
+import { SavedConfig, ConfigVersionItems } from '@/app/lib/types/configs';
interface HistorySidebarProps {
savedConfigs: SavedConfig[];
@@ -12,6 +12,17 @@ interface HistorySidebarProps {
collapsed: boolean; // Whether the sidebar is collapsed
isLoading?: boolean;
currentConfigId?: string; // To filter versions for current config only
+ /**
+ * Lightweight version list for the current config (no config_blob).
+ * When provided, this is used as the authoritative list for history display
+ * instead of the loaded SavedConfigs, which may only have the latest version.
+ */
+ versionItems?: ConfigVersionItems[];
+ /**
+ * Called when a version's full details are needed but not yet loaded.
+ * Returns the full SavedConfig (1 API call) or null on failure.
+ */
+ onFetchVersionDetail?: (version: number) => Promise;
}
export default function HistorySidebar({
@@ -23,9 +34,13 @@ export default function HistorySidebar({
onToggle,
collapsed,
isLoading = false,
- currentConfigId
+ currentConfigId,
+ versionItems,
+ onFetchVersionDetail,
}: HistorySidebarProps) {
const [expandedConfigs, setExpandedConfigs] = useState>(new Set());
+ /** Which version number is currently being fetched on-demand (for loading indicator) */
+ const [fetchingVersion, setFetchingVersion] = useState(null);
// Toggle expand/collapse
const toggleExpand = (configName: string) => {
@@ -57,6 +72,19 @@ export default function HistorySidebar({
groupedConfigs[name].sort((a, b) => b.version - a.version);
});
+ // When versionItems is provided for the current config, use it as the
+ // authoritative (lightweight) list; the fully-loaded SavedConfig entries
+ // are still looked up per-entry for action callbacks.
+ // Sort lightweight items newest-first too.
+ const sortedVersionItems = versionItems
+ ? [...versionItems].sort((a, b) => b.version - a.version)
+ : null;
+
+ // Total version count for the header subtitle
+ const totalVersionCount = currentConfigId
+ ? (sortedVersionItems?.length ?? filteredConfigs.length)
+ : filteredConfigs.length;
+
// Format timestamp - calculate relative time from UTC timestamps
const formatTimestamp = (timestamp: string) => {
const now = Date.now();
@@ -103,7 +131,7 @@ export default function HistorySidebar({
{titleText}
- {filteredConfigs.length} version{filteredConfigs.length !== 1 ? 's' : ''}
+ {totalVersionCount} version{totalVersionCount !== 1 ? 's' : ''}
{!currentConfigId && ` • ${Object.keys(groupedConfigs).length} config${Object.keys(groupedConfigs).length !== 1 ? 's' : ''}`}
@@ -188,7 +216,7 @@ export default function HistorySidebar({
Fetching config history from backend
- ) : Object.keys(groupedConfigs).length === 0 ? (
+ ) : Object.keys(groupedConfigs).length === 0 && !sortedVersionItems?.length ? (
No saved configurations yet
@@ -199,7 +227,140 @@ export default function HistorySidebar({
) : (
- {Object.entries(groupedConfigs).map(([configName, versions]) => {
+ {/*
+ When versionItems + currentConfigId are provided, render the lightweight
+ list directly (no config_blob required for display). Full details are
+ fetched on-demand when the user clicks Load or Compare.
+ Otherwise fall back to the loaded SavedConfig groups.
+ */}
+ {currentConfigId && sortedVersionItems ? (() => {
+ // Find the config name from loaded data (latest version is always loaded)
+ const configName = savedConfigs.find(c => c.config_id === currentConfigId)?.name ?? '';
+ const isExpanded = expandedConfigs.has(configName || currentConfigId);
+
+ return (
+
+ {/* Config header */}
+
toggleExpand(configName || currentConfigId)}
+ className="p-3 cursor-pointer"
+ style={{ backgroundColor: colors.bg.secondary, transition: 'all 0.15s ease' }}
+ onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
+ onMouseLeave={(e) => e.currentTarget.style.backgroundColor = colors.bg.secondary}
+ >
+
+
+ {isExpanded ? '▼' : '▶'}
+
+
+
+ {configName || 'Config'}
+
+
+ {sortedVersionItems.length} version{sortedVersionItems.length !== 1 ? 's' : ''} • Latest: v{sortedVersionItems[0]?.version}
+
+
+
+
+
+ {/* Lightweight version entries */}
+ {isExpanded && (
+
+ {sortedVersionItems.map((item, idx) => {
+ // Look up the already-loaded full version if available
+ const full = savedConfigs.find(
+ c => c.config_id === currentConfigId && c.version === item.version
+ );
+ const isSelected = selectedVersion?.config_id === currentConfigId &&
+ selectedVersion?.version === item.version;
+ const isFetchingThis = fetchingVersion === item.version;
+
+ const handleAction = async (action: 'load' | 'compare') => {
+ let detail = full;
+ if (!detail && onFetchVersionDetail) {
+ setFetchingVersion(item.version);
+ detail = (await onFetchVersionDetail(item.version)) ?? undefined;
+ setFetchingVersion(null);
+ }
+ if (!detail) return;
+ if (action === 'load') onLoadVersion(detail);
+ else onSelectVersion(detail);
+ };
+
+ return (
+
0 ? `1px solid ${colors.border}` : 'none',
+ transition: 'all 0.15s ease',
+ }}
+ >
+
+
+ v{item.version}
+
+ {idx === 0 && (
+
+ Latest
+
+ )}
+
+
+ {item.commit_message && (
+
+ {item.commit_message}
+
+ )}
+
+
+ {formatTimestamp(item.inserted_at)}
+ {full ? ` • ${full.provider}/${full.modelName}` : ''}
+
+
+ {/* Action buttons */}
+
+ { e.stopPropagation(); handleAction('load'); }}
+ disabled={isFetchingThis}
+ className="px-2 py-1 rounded text-xs font-medium transition-colors"
+ style={{ backgroundColor: colors.accent.primary, color: '#ffffff', border: 'none', opacity: isFetchingThis ? 0.6 : 1 }}
+ onMouseEnter={(e) => { if (!isFetchingThis) e.currentTarget.style.opacity = '0.85'; }}
+ onMouseLeave={(e) => { if (!isFetchingThis) e.currentTarget.style.opacity = '1'; }}
+ >
+ {isFetchingThis ? '…' : 'Load'}
+
+ { e.stopPropagation(); handleAction('compare'); }}
+ disabled={isFetchingThis}
+ className="px-2 py-1 rounded text-xs font-medium transition-colors"
+ style={{ backgroundColor: colors.bg.secondary, color: colors.text.secondary, border: `1px solid ${colors.border}`, opacity: isFetchingThis ? 0.6 : 1 }}
+ onMouseEnter={(e) => { if (!isFetchingThis) { e.currentTarget.style.backgroundColor = colors.bg.primary; e.currentTarget.style.color = colors.text.primary; } }}
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = colors.bg.secondary; e.currentTarget.style.color = colors.text.secondary; }}
+ >
+ Compare
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+ })() : Object.entries(groupedConfigs).map(([configName, versions]) => {
const isExpanded = expandedConfigs.has(configName);
const latestVersion = versions[0];
@@ -207,19 +368,13 @@ export default function HistorySidebar({
{/* Config Header */}
toggleExpand(configName)}
className="p-3 cursor-pointer"
- style={{
- backgroundColor: colors.bg.secondary,
- transition: 'all 0.15s ease'
- }}
+ style={{ backgroundColor: colors.bg.secondary, transition: 'all 0.15s ease' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = colors.bg.secondary}
>
@@ -238,7 +393,7 @@ export default function HistorySidebar({
- {/* Versions List */}
+ {/* Versions List (already-loaded SavedConfigs) */}
{isExpanded && (
{versions.map((version, idx) => (
@@ -250,29 +405,21 @@ export default function HistorySidebar({
borderLeftColor: selectedVersion?.id === version.id ? colors.status.success : idx === 0 ? colors.accent.primary : colors.border,
marginLeft: '12px',
borderTop: idx > 0 ? `1px solid ${colors.border}` : 'none',
- transition: 'all 0.15s ease'
+ transition: 'all 0.15s ease',
}}
>
v{version.version}
{idx === 0 && (
Latest
@@ -293,40 +440,20 @@ export default function HistorySidebar({
{/* Action buttons */}
{
- e.stopPropagation();
- onLoadVersion(version);
- }}
+ onClick={(e) => { e.stopPropagation(); onLoadVersion(version); }}
className="px-2 py-1 rounded text-xs font-medium transition-colors"
- style={{
- backgroundColor: colors.accent.primary,
- color: '#ffffff',
- border: 'none'
- }}
+ style={{ backgroundColor: colors.accent.primary, color: '#ffffff', border: 'none' }}
onMouseEnter={(e) => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={(e) => e.currentTarget.style.opacity = '1'}
>
Load
{
- e.stopPropagation();
- onSelectVersion(version);
- }}
+ onClick={(e) => { e.stopPropagation(); onSelectVersion(version); }}
className="px-2 py-1 rounded text-xs font-medium transition-colors"
- style={{
- backgroundColor: colors.bg.secondary,
- color: colors.text.secondary,
- border: `1px solid ${colors.border}`
- }}
- onMouseEnter={(e) => {
- e.currentTarget.style.backgroundColor = colors.bg.primary;
- e.currentTarget.style.color = colors.text.primary;
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.backgroundColor = colors.bg.secondary;
- e.currentTarget.style.color = colors.text.secondary;
- }}
+ style={{ backgroundColor: colors.bg.secondary, color: colors.text.secondary, border: `1px solid ${colors.border}` }}
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = colors.bg.primary; e.currentTarget.style.color = colors.text.primary; }}
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = colors.bg.secondary; e.currentTarget.style.color = colors.text.secondary; }}
>
Compare
diff --git a/app/components/prompt-editor/PromptDiffPane.tsx b/app/components/prompt-editor/PromptDiffPane.tsx
index e6e3f2c..1168852 100644
--- a/app/components/prompt-editor/PromptDiffPane.tsx
+++ b/app/components/prompt-editor/PromptDiffPane.tsx
@@ -1,6 +1,5 @@
-import React from 'react';
import { colors } from '@/app/lib/colors';
-import { SavedConfig } from '@/app/lib/useConfigs';
+import { SavedConfig } from '@/app/lib/types/configs';
interface PromptDiffPaneProps {
selectedCommit: SavedConfig;
diff --git a/app/configurations/page.tsx b/app/configurations/page.tsx
index f2942cd..76fa41d 100644
--- a/app/configurations/page.tsx
+++ b/app/configurations/page.tsx
@@ -9,11 +9,13 @@
*/
"use client"
-import React, { useState, useEffect } from 'react';
+
+import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/app/components/Sidebar';
import { colors } from '@/app/lib/colors';
-import { useConfigs, ConfigGroup, SavedConfig } from '@/app/lib/useConfigs';
+import { useConfigs } from '@/app/hooks/useConfigs';
+import { ConfigGroup, SavedConfig } from '@/app/lib/types/configs';
import ConfigCard from '@/app/components/ConfigCard';
import { LoaderBox } from '@/app/components/Loader';
import { EvalJob } from '@/app/components/types';
@@ -21,7 +23,7 @@ import { EvalJob } from '@/app/components/types';
export default function ConfigLibraryPage() {
const router = useRouter();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
- const { configGroups, isLoading, error, refetch, isCached } = useConfigs();
+ const { configGroups, isLoading, error, refetch, isCached, loadMoreConfigs, hasMoreConfigs, isLoadingMore } = useConfigs({ pageSize: 10 });
const [searchQuery, setSearchQuery] = useState('');
const [evaluationCounts, setEvaluationCounts] = useState>({});
@@ -316,16 +318,53 @@ export default function ConfigLibraryPage() {
)}
) : (
-
- {filteredConfigs.map((configGroup) => (
-
- ))}
-
+ <>
+
+ {filteredConfigs.map((configGroup) => (
+
+ ))}
+
+ {hasMoreConfigs && !searchQuery && (
+
+
{
+ if (!isLoadingMore) {
+ e.currentTarget.style.backgroundColor = colors.bg.secondary;
+ e.currentTarget.style.color = colors.text.primary;
+ }
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = colors.bg.primary;
+ e.currentTarget.style.color = colors.text.secondary;
+ }}
+ >
+ {isLoadingMore ? (
+ <>
+
+
+
+ Loading...
+ >
+ ) : (
+ 'Load More'
+ )}
+
+
+ )}
+ >
)}
diff --git a/app/configurations/prompt-editor/page.tsx b/app/configurations/prompt-editor/page.tsx
index 9d269b2..bf55003 100644
--- a/app/configurations/prompt-editor/page.tsx
+++ b/app/configurations/prompt-editor/page.tsx
@@ -25,7 +25,9 @@ import ConfigEditorPane from '@/app/components/prompt-editor/ConfigEditorPane';
import DiffView from '@/app/components/prompt-editor/DiffView';
import { useToast } from '@/app/components/Toast';
import Loader from '@/app/components/Loader';
-import { useConfigs, invalidateConfigCache, SavedConfig } from '@/app/lib/useConfigs';
+import { useConfigs } from '@/app/hooks/useConfigs';
+import { SavedConfig } from '@/app/lib/types/configs';
+import { invalidateConfigCache } from '@/app/lib/utils';
function PromptEditorContent() {
const toast = useToast();
@@ -58,7 +60,7 @@ function PromptEditorContent() {
};
// Use shared configs hook with caching
- const { configs: savedConfigs, isLoading, refetch: refetchConfigs } = useConfigs();
+ const { configs: savedConfigs, isLoading, refetch: refetchConfigs, loadVersionsForConfig, loadSingleVersion, versionItemsMap } = useConfigs();
const [isSaving, setIsSaving] = useState
(false);
const initialLoadComplete = !isLoading && savedConfigs.length >= 0;
@@ -117,6 +119,10 @@ function PromptEditorContent() {
// If a specific config/version is requested via URL params
if (urlConfigId) {
+ // Eagerly load all versions for this config so history sidebar and version
+ // picker are fully populated (only the latest version is fetched on initial load)
+ loadVersionsForConfig(urlConfigId);
+
// Find the config by config_id and optionally version
let targetConfig: SavedConfig | undefined;
@@ -163,7 +169,7 @@ function PromptEditorContent() {
}
}
}
- }, [initialLoadComplete, savedConfigs, urlConfigId, urlVersion, showHistory, isNewConfig]);
+ }, [initialLoadComplete, savedConfigs, urlConfigId, urlVersion, showHistory, isNewConfig, loadVersionsForConfig]);
// Detect unsaved changes
useEffect(() => {
@@ -335,6 +341,9 @@ function PromptEditorContent() {
const config = savedConfigs.find(c => c.id === configId);
if (!config) return;
+ // Lazily load all versions for this config (history sidebar needs them)
+ loadVersionsForConfig(config.config_id);
+
setCurrentContent(config.promptContent);
setCurrentConfigBlob({
completion: {
@@ -395,6 +404,11 @@ function PromptEditorContent() {
setCompareWith(null);
}}
isLoading={isLoading}
+ versionItems={currentConfigParentId ? (versionItemsMap[currentConfigParentId] ?? []) : undefined}
+ onFetchVersionDetail={currentConfigParentId
+ ? (version) => loadSingleVersion(currentConfigParentId, version)
+ : undefined
+ }
/>
{/* Show DiffView only when comparing versions (sidebar open + version selected) */}
@@ -404,6 +418,9 @@ function PromptEditorContent() {
compareWith={compareWith}
commits={savedConfigs}
onCompareChange={setCompareWith}
+ loadVersionsForConfig={loadVersionsForConfig}
+ versionItemsMap={versionItemsMap}
+ onFetchVersionDetail={loadSingleVersion}
onLoadVersion={(versionId) => {
handleLoadConfigById(versionId);
setSelectedVersion(null);
@@ -441,6 +458,7 @@ function PromptEditorContent() {
isSaving={isSaving}
collapsed={!showConfigPane}
onToggle={() => setShowConfigPane(!showConfigPane)}
+ loadVersionsForConfig={loadVersionsForConfig}
/>
diff --git a/app/hooks/useConfigs.ts b/app/hooks/useConfigs.ts
new file mode 100644
index 0000000..e9c3c02
--- /dev/null
+++ b/app/hooks/useConfigs.ts
@@ -0,0 +1,390 @@
+'use client';
+
+/**
+ * useConfigs — shared React hook for fetching and managing configurations.
+ * Used by Config Library, Prompt Editor, and Evaluations pages.
+ *
+ * Responsibilities (hook-only layer):
+ * - Manages React state (configs, loading flags, error)
+ * - Reads from / writes to the shared in-memory + localStorage cache
+ * - Exposes stable callbacks for lazy-loading version lists and individual versions
+ * - Schedules background cache validation after serving cached data
+ *
+ * Heavy lifting (API calls, cache I/O, pure transforms) lives in:
+ * lib/configFetchers.ts, lib/store.ts, lib/utils.ts
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import { ConfigPublic, ConfigVersionItems, ConfigVersionResponse } from '../lib/configTypes';
+import { SavedConfig, ConfigGroup, ConfigCache } from '../lib/types/configs';
+import { CACHE_MAX_AGE_MS, CACHE_INVALIDATED_EVENT, PAGE_SIZE } from '../lib/constants';
+import {
+ configState,
+ pendingVersionLoads,
+ pendingSingleVersionLoads,
+ loadCache,
+ saveCache,
+} from '../lib/store/configStore';
+import {
+ fetchAllConfigs,
+ fetchNextConfigBatch,
+ scheduleBackgroundValidation,
+} from '../lib/configFetchers';
+import { getApiKey, flattenConfigVersion, groupConfigs } from '../lib/utils';
+
+export interface UseConfigsResult {
+ configs: SavedConfig[];
+ configGroups: ConfigGroup[];
+ isLoading: boolean;
+ error: string | null;
+ refetch: (force?: boolean) => Promise;
+ isCached: boolean;
+ /**
+ * Ensures the lightweight version list (no config_blob) is cached for a config.
+ * O(1) – either a no-op (already cached) or a single GET /versions call.
+ * Does NOT fetch full version details; use loadSingleVersion for that.
+ */
+ loadVersionsForConfig: (config_id: string) => Promise;
+ /** Load the next batch of configs (only relevant when pageSize option is used) */
+ loadMoreConfigs: () => Promise;
+ /** True when there are more configs available that haven't been loaded yet */
+ hasMoreConfigs: boolean;
+ /** True while loadMoreConfigs is in progress */
+ isLoadingMore: boolean;
+ /**
+ * Fetches the full details (config_blob) for a single version on demand.
+ * Returns the SavedConfig immediately if already loaded; makes 1 GET call otherwise.
+ * Safe to call concurrently – duplicate in-flight requests are coalesced.
+ */
+ loadSingleVersion: (config_id: string, version: number) => Promise;
+ /** Lightweight version items per config, indexed by config_id. */
+ versionItemsMap: Record;
+}
+
+export function useConfigs(options?: { pageSize?: number }): UseConfigsResult {
+ const pageSize = options?.pageSize;
+ const [configs, setConfigs] = useState([]);
+ const [versionCounts, setVersionCounts] = useState>({});
+ const [versionItemsMap, setVersionItemsMap] = useState>({});
+ const [isLoading, setIsLoading] = useState(true);
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+ const [isCached, setIsCached] = useState(false);
+ const [totalKnownCount, setTotalKnownCount] = useState(0);
+
+ const fetchConfigs = useCallback(async (force: boolean = false) => {
+ const apiKey = getApiKey();
+ if (!apiKey) {
+ setError('No API key found. Please add an API key in the Keystore.');
+ setIsLoading(false);
+ return;
+ }
+
+ // ── Fast paths (skipped when force=true) ──
+ if (!force) {
+ // In-memory cache (fastest — no I/O)
+ if (configState.inMemoryCache) {
+ const cacheAge = Date.now() - configState.inMemoryCache.cachedAt;
+ if (cacheAge < CACHE_MAX_AGE_MS) {
+ const cacheUsable = !configState.inMemoryCache.partialFetch || pageSize !== undefined;
+ if (cacheUsable) {
+ setConfigs(configState.inMemoryCache.configs);
+ setVersionCounts(configState.inMemoryCache.versionCounts || {});
+ setVersionItemsMap({ ...configState.versionItemsCache });
+ setTotalKnownCount(
+ configState.inMemoryCache.totalConfigCount ?? configState.inMemoryCache.configs.length,
+ );
+ setIsCached(true);
+ setIsLoading(false);
+ scheduleBackgroundValidation(configState.inMemoryCache, apiKey);
+ return;
+ }
+ }
+ }
+
+ // localStorage cache
+ const lsCache = loadCache();
+ if (lsCache) {
+ const cacheAge = Date.now() - lsCache.cachedAt;
+ if (cacheAge < CACHE_MAX_AGE_MS) {
+ const cacheUsable = !lsCache.partialFetch || pageSize !== undefined;
+ if (cacheUsable) {
+ setConfigs(lsCache.configs);
+ setVersionCounts(lsCache.versionCounts || {});
+ // versionItemsCache may be empty on cold start; loadVersionsForConfig will
+ // populate it on demand (1 GET /versions call) when a config is opened.
+ setVersionItemsMap({ ...configState.versionItemsCache });
+ setTotalKnownCount(lsCache.totalConfigCount ?? lsCache.configs.length);
+ setIsCached(true);
+ setIsLoading(false);
+ configState.inMemoryCache = lsCache;
+ scheduleBackgroundValidation(lsCache, apiKey);
+ return;
+ }
+ }
+ }
+ }
+
+ // ── Deduplication: join an existing in-flight fetch ───────────────────
+ if (configState.pendingFetch) {
+ setIsLoading(true);
+ await configState.pendingFetch.catch(() => { /* error handled by originator */ });
+ if (configState.inMemoryCache) {
+ setConfigs(configState.inMemoryCache.configs);
+ setVersionCounts(configState.inMemoryCache.versionCounts || {});
+ setVersionItemsMap({ ...configState.versionItemsCache });
+ setTotalKnownCount(
+ configState.inMemoryCache.totalConfigCount ?? configState.inMemoryCache.configs.length,
+ );
+ setIsCached(false);
+ } else {
+ setError('Failed to load configurations. Please try again.');
+ }
+ setIsLoading(false);
+ return;
+ }
+
+ // ── Primary fetch ──
+ setIsLoading(true);
+ setError(null);
+ setIsCached(false);
+
+ configState.pendingFetch = (async () => {
+ const result = await fetchAllConfigs(apiKey, pageSize);
+ const newCache: ConfigCache = {
+ configs: result.configs,
+ configMeta: result.configMeta,
+ cachedAt: Date.now(),
+ versionCounts: result.versionCounts,
+ totalConfigCount: result.totalConfigCount,
+ partialFetch: result.partialFetch,
+ };
+ saveCache(newCache);
+ configState.inMemoryCache = newCache;
+ setConfigs(result.configs);
+ setVersionCounts(result.versionCounts);
+ setVersionItemsMap({ ...configState.versionItemsCache });
+ setTotalKnownCount(result.totalConfigCount);
+ })().finally(() => {
+ configState.pendingFetch = null;
+ });
+
+ try {
+ await configState.pendingFetch;
+ } catch {
+ setError('Failed to load configurations. Please try again.');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [pageSize]);
+
+ // ── Lazy-load lightweight version list ──
+
+ /**
+ * Ensures the lightweight version list (ConfigVersionItems, no config_blob) is
+ * cached for a given config_id and reflected in versionItemsMap state.
+ *
+ * Cost: 0 network calls when already cached, otherwise exactly 1 GET /versions.
+ * Does NOT fetch full version details — use loadSingleVersion for that.
+ */
+ const loadVersionsForConfig = useCallback(async (config_id: string) => {
+ if (configState.versionItemsCache[config_id]) {
+ setVersionItemsMap(prev =>
+ prev[config_id] ? prev : { ...prev, [config_id]: configState.versionItemsCache[config_id] },
+ );
+ return;
+ }
+
+ const existing = pendingVersionLoads.get(config_id);
+ if (existing) {
+ await existing;
+ return;
+ }
+
+ const apiKey = getApiKey();
+ if (!apiKey) return;
+
+ const loadPromise = (async () => {
+ const versionsResponse = await fetch(`/api/configs/${config_id}/versions`, {
+ headers: { 'X-API-KEY': apiKey },
+ });
+ const versionsData = await versionsResponse.json();
+ if (!versionsData.success || !versionsData.data) return;
+
+ configState.versionItemsCache[config_id] = versionsData.data;
+ setVersionItemsMap(prev => ({ ...prev, [config_id]: versionsData.data }));
+ setVersionCounts(prev => ({ ...prev, [config_id]: versionsData.data.length }));
+ })().finally(() => {
+ pendingVersionLoads.delete(config_id);
+ });
+
+ pendingVersionLoads.set(config_id, loadPromise);
+ try {
+ await loadPromise;
+ } catch {
+ console.error(`Failed to load version list for config ${config_id}`);
+ }
+ }, []);
+
+ // ── On-demand single version detail fetch ──
+
+ /**
+ * Fetches the full details (config_blob) for a single version on demand.
+ * Returns an already-loaded SavedConfig immediately (0 network calls).
+ * Otherwise makes exactly 1 GET /versions/{version} call.
+ * Concurrent calls for the same config_id:version share one in-flight request.
+ */
+ const loadSingleVersion = useCallback(async (
+ config_id: string,
+ version: number,
+ ): Promise => {
+ const loaded = configs.find(c => c.config_id === config_id && c.version === version);
+ if (loaded) return loaded;
+
+ const key = `${config_id}:${version}`;
+ const existing = pendingSingleVersionLoads.get(key);
+ if (existing) return existing;
+
+ const apiKey = getApiKey();
+ if (!apiKey) return null;
+
+ const configSource = configs.find(c => c.config_id === config_id);
+ if (!configSource) return null;
+
+ const configPublic: ConfigPublic = {
+ id: config_id,
+ name: configSource.name,
+ description: configSource.description ?? null,
+ project_id: 0,
+ inserted_at: '',
+ updated_at: '',
+ };
+
+ const loadPromise: Promise = (async () => {
+ try {
+ const versionResponse = await fetch(
+ `/api/configs/${config_id}/versions/${version}`,
+ { headers: { 'X-API-KEY': apiKey } },
+ );
+ const versionData: ConfigVersionResponse = await versionResponse.json();
+ if (!versionData.success || !versionData.data) return null;
+
+ const savedConfig = flattenConfigVersion(configPublic, versionData.data);
+
+ setConfigs(prev => {
+ if (prev.some(c => c.config_id === config_id && c.version === version)) return prev;
+ const updated = [...prev, savedConfig];
+ if (configState.inMemoryCache) {
+ configState.inMemoryCache = { ...configState.inMemoryCache, configs: updated };
+ saveCache(configState.inMemoryCache);
+ }
+ return updated;
+ });
+
+ return savedConfig;
+ } catch (e) {
+ console.error(`Failed to fetch version ${version} for config ${config_id}:`, e);
+ return null;
+ }
+ })().finally(() => {
+ pendingSingleVersionLoads.delete(key);
+ });
+
+ pendingSingleVersionLoads.set(key, loadPromise);
+ return loadPromise;
+ }, [configs]);
+
+ // ── Paginated load more ──
+
+ /**
+ * Loads the next batch of configs (version list + latest detail) for configs
+ * not yet represented in the loaded set. Used by the Config Library Load More button.
+ */
+ const loadMoreConfigs = useCallback(async () => {
+ if (!configState.allConfigMeta || configState.allConfigMeta.length === 0) return;
+ const apiKey = getApiKey();
+ if (!apiKey) return;
+
+ const loadedIds = new Set(
+ (configState.inMemoryCache?.configs ?? configs).map(c => c.config_id),
+ );
+ const remaining = configState.allConfigMeta.filter(c => !loadedIds.has(c.id));
+ if (remaining.length === 0) return;
+
+ if (configState.pendingLoadMore) {
+ await configState.pendingLoadMore;
+ return;
+ }
+
+ setIsLoadingMore(true);
+
+ configState.pendingLoadMore = (async () => {
+ const { newVersions, newVersionCounts, newConfigMeta } = await fetchNextConfigBatch(
+ apiKey,
+ loadedIds,
+ pageSize ?? PAGE_SIZE,
+ );
+
+ setConfigs(prev => {
+ const merged = [...prev, ...newVersions];
+ if (configState.inMemoryCache) {
+ const mergedIds = new Set(merged.map(c => c.config_id));
+ const stillPartial = configState.allConfigMeta!.some(c => !mergedIds.has(c.id));
+ configState.inMemoryCache = {
+ ...configState.inMemoryCache,
+ configs: merged,
+ versionCounts: { ...configState.inMemoryCache.versionCounts, ...newVersionCounts },
+ configMeta: { ...configState.inMemoryCache.configMeta, ...newConfigMeta },
+ partialFetch: stillPartial,
+ };
+ saveCache(configState.inMemoryCache);
+ }
+ return merged;
+ });
+ setVersionCounts(prev => ({ ...prev, ...newVersionCounts }));
+ })().finally(() => {
+ configState.pendingLoadMore = null;
+ setIsLoadingMore(false);
+ });
+
+ try {
+ await configState.pendingLoadMore;
+ } catch {
+ console.error('Failed to load more configs');
+ setIsLoadingMore(false);
+ }
+ }, [configs, pageSize]);
+
+ // ── Effects ──
+
+ useEffect(() => {
+ fetchConfigs();
+ }, [fetchConfigs]);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const handler = () => fetchConfigs(true);
+ window.addEventListener(CACHE_INVALIDATED_EVENT, handler);
+ return () => window.removeEventListener(CACHE_INVALIDATED_EVENT, handler);
+ }, [fetchConfigs]);
+
+ // ── Return ──
+
+ const loadedConfigIds = new Set(configs.map(c => c.config_id));
+ const hasMoreConfigs = totalKnownCount > 0 && loadedConfigIds.size < totalKnownCount;
+
+ return {
+ configs,
+ configGroups: groupConfigs(configs, versionCounts, versionItemsMap),
+ isLoading,
+ error,
+ refetch: fetchConfigs,
+ isCached,
+ loadVersionsForConfig,
+ loadMoreConfigs,
+ hasMoreConfigs,
+ isLoadingMore,
+ loadSingleVersion,
+ versionItemsMap,
+ };
+}
diff --git a/app/lib/configFetchers.ts b/app/lib/configFetchers.ts
new file mode 100644
index 0000000..4845309
--- /dev/null
+++ b/app/lib/configFetchers.ts
@@ -0,0 +1,273 @@
+/**
+ * API fetch helpers for Config Management.
+ * Contains all network logic — no React, no UI state.
+ */
+
+import {
+ ConfigPublic,
+ ConfigVersionItems,
+ ConfigListResponse,
+ ConfigVersionListResponse,
+ ConfigVersionResponse,
+} from './configTypes';
+import { SavedConfig, ConfigCache, FetchResult } from './types/configs';
+import { CACHE_INVALIDATED_EVENT } from './constants';
+import { configState, saveCache } from './store/configStore';
+import { flattenConfigVersion } from './utils';
+
+/**
+ * Schedules a single background validation pass.
+ * Only one validation runs at a time; subsequent calls while one is in progress
+ * are silently dropped.
+ *
+ * Uses a single GET /api/configs call and compares only updated_at timestamps —
+ * NO per-config GET /api/configs/{id}/versions calls.
+ * When a new version is created the backend bumps the parent config's updated_at,
+ * so the updated_at check is sufficient to detect all changes.
+ */
+export function scheduleBackgroundValidation(cache: ConfigCache, apiKey: string): void {
+ if (configState.validationInProgress) return;
+ configState.validationInProgress = true;
+
+ (async () => {
+ try {
+ const response = await fetch('/api/configs', { headers: { 'X-API-KEY': apiKey } });
+ const data: ConfigListResponse = await response.json();
+ if (!data.success || !data.data) return;
+
+ let needsRefresh = false;
+ const currentMeta = cache.configMeta;
+
+ // Check for new or updated configs (updated_at covers version additions too)
+ for (const config of data.data) {
+ const cached = currentMeta[config.id];
+ if (!cached || cached.updated_at !== config.updated_at) {
+ needsRefresh = true;
+ break;
+ }
+ }
+
+ // Check for deleted configs
+ if (!needsRefresh) {
+ const currentIds = new Set(data.data.map(c => c.id));
+ for (const cachedId of Object.keys(currentMeta)) {
+ if (!currentIds.has(cachedId)) {
+ needsRefresh = true;
+ break;
+ }
+ }
+ }
+
+ if (needsRefresh) {
+ configState.inMemoryCache = null;
+ configState.versionItemsCache = {};
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new Event(CACHE_INVALIDATED_EVENT));
+ }
+ }
+ } catch {
+ // Silent — background validation failure is non-critical
+ } finally {
+ configState.validationInProgress = false;
+ }
+ })();
+}
+
+/**
+ * Fetches configs and their LATEST version detail.
+ * Reduces API calls from 1 + N + N*M → 1 + 2N
+ * (configs list) + (versions list per config) + (one latest-version detail per config)
+ *
+ * When pageSize is provided, only the first N configs get version details fetched.
+ * The full lightweight config list is always stored in configState.allConfigMeta.
+ */
+export async function fetchAllConfigs(apiKey: string, pageSize?: number): Promise {
+ const response = await fetch('/api/configs', {
+ headers: { 'X-API-KEY': apiKey },
+ });
+ const data: ConfigListResponse = await response.json();
+
+ if (!data.success || !data.data) {
+ throw new Error(data.error || 'Failed to fetch configs');
+ }
+
+ // Always store the full lightweight list so loadMoreConfigs knows what's available
+ configState.allConfigMeta = data.data;
+ const totalConfigCount = data.data.length;
+
+ const allVersions: SavedConfig[] = [];
+ const configMeta: Record = {};
+ const versionCounts: Record = {};
+
+ const configsToFetch = pageSize !== undefined ? data.data.slice(0, pageSize) : data.data;
+
+ const BATCH_SIZE = 5;
+
+ for (let i = 0; i < configsToFetch.length; i += BATCH_SIZE) {
+ const batch = configsToFetch.slice(i, i + BATCH_SIZE);
+ const batchPromises = batch.map(async (config) => {
+ try {
+ const versionsResponse = await fetch(`/api/configs/${config.id}/versions`, {
+ headers: { 'X-API-KEY': apiKey },
+ });
+ const versionsData: ConfigVersionListResponse = await versionsResponse.json();
+
+ if (versionsData.success && versionsData.data && versionsData.data.length > 0) {
+ const versionCount = versionsData.data.length;
+ configMeta[config.id] = { updated_at: config.updated_at, version_count: versionCount };
+ versionCounts[config.id] = versionCount;
+ // Cache the lightweight version list so loadVersionsForConfig doesn't re-fetch it
+ configState.versionItemsCache[config.id] = versionsData.data;
+
+ const latestItem = versionsData.data.reduce((a, b) => (b.version > a.version ? b : a));
+ try {
+ const versionResponse = await fetch(
+ `/api/configs/${config.id}/versions/${latestItem.version}`,
+ { headers: { 'X-API-KEY': apiKey } },
+ );
+ const versionData: ConfigVersionResponse = await versionResponse.json();
+ if (versionData.success && versionData.data) {
+ return [flattenConfigVersion(config, versionData.data)];
+ }
+ } catch (e) {
+ console.error(`Failed to fetch latest version for config ${config.id}:`, e);
+ }
+ }
+ } catch (e) {
+ console.error(`Failed to fetch versions for config ${config.id}:`, e);
+ }
+ return [];
+ });
+
+ const batchResults = await Promise.all(batchPromises);
+ batchResults.forEach(versions => allVersions.push(...versions));
+ }
+
+ return {
+ configs: allVersions,
+ configMeta,
+ versionCounts,
+ totalConfigCount,
+ partialFetch: configsToFetch.length < totalConfigCount,
+ };
+}
+
+/**
+ * Lazily fetches all remaining version details for a specific config.
+ * Uses in-memory caches to avoid re-fetching the config metadata or the
+ * version-list endpoint — those were already called during the initial load.
+ */
+export async function fetchRemainingVersions(
+ config_id: string,
+ apiKey: string,
+ alreadyLoaded: SavedConfig[],
+ configSource: SavedConfig,
+): Promise {
+ const config: ConfigPublic = {
+ id: config_id,
+ name: configSource.name,
+ description: configSource.description ?? null,
+ project_id: 0,
+ inserted_at: '',
+ updated_at: '',
+ };
+
+ // Use the cached version-items list, falling back to a fresh API call only if
+ // the cache was invalidated (e.g. after a force-refetch).
+ let versionItems: ConfigVersionItems[] | undefined = configState.versionItemsCache[config_id];
+ if (!versionItems) {
+ const versionsResponse = await fetch(`/api/configs/${config_id}/versions`, {
+ headers: { 'X-API-KEY': apiKey },
+ });
+ const versionsData: ConfigVersionListResponse = await versionsResponse.json();
+ if (!versionsData.success || !versionsData.data) return [];
+ versionItems = versionsData.data;
+ configState.versionItemsCache[config_id] = versionItems;
+ }
+
+ const loadedVersionNumbers = new Set(alreadyLoaded.map(v => v.version));
+ const missingVersions = versionItems.filter(v => !loadedVersionNumbers.has(v.version));
+
+ if (missingVersions.length === 0) return [];
+
+ const fetchedVersions = await Promise.all(
+ missingVersions.map(async (versionItem) => {
+ try {
+ const versionResponse = await fetch(
+ `/api/configs/${config_id}/versions/${versionItem.version}`,
+ { headers: { 'X-API-KEY': apiKey } },
+ );
+ const versionData: ConfigVersionResponse = await versionResponse.json();
+ if (versionData.success && versionData.data) {
+ return flattenConfigVersion(config, versionData.data);
+ }
+ } catch (e) {
+ console.error(`Failed to fetch version ${versionItem.version} for config ${config_id}:`, e);
+ }
+ return null;
+ }),
+ );
+
+ return fetchedVersions.filter((v): v is SavedConfig => v !== null);
+}
+
+/**
+ * Fetches version details for the next batch of configs not yet loaded.
+ * Used by the Config Library "Load More" feature.
+ * Mutates configState.inMemoryCache directly and returns new versions + counts.
+ */
+export async function fetchNextConfigBatch(
+ apiKey: string,
+ loadedIds: Set,
+ batchSize: number,
+): Promise<{
+ newVersions: SavedConfig[];
+ newVersionCounts: Record;
+ newConfigMeta: Record;
+}> {
+ const allMeta = configState.allConfigMeta;
+ if (!allMeta) return { newVersions: [], newVersionCounts: {}, newConfigMeta: {} };
+
+ const remaining = allMeta.filter(c => !loadedIds.has(c.id));
+ const batch = remaining.slice(0, batchSize);
+
+ const newVersions: SavedConfig[] = [];
+ const newVersionCounts: Record = {};
+ const newConfigMeta: Record = {};
+
+ const INNER_BATCH = 5;
+ for (let i = 0; i < batch.length; i += INNER_BATCH) {
+ const subBatch = batch.slice(i, i + INNER_BATCH);
+ const results = await Promise.all(
+ subBatch.map(async (config) => {
+ try {
+ const versionsResponse = await fetch(`/api/configs/${config.id}/versions`, {
+ headers: { 'X-API-KEY': apiKey },
+ });
+ const versionsData: ConfigVersionListResponse = await versionsResponse.json();
+ if (versionsData.success && versionsData.data && versionsData.data.length > 0) {
+ const versionCount = versionsData.data.length;
+ newConfigMeta[config.id] = { updated_at: config.updated_at, version_count: versionCount };
+ newVersionCounts[config.id] = versionCount;
+ configState.versionItemsCache[config.id] = versionsData.data;
+ const latestItem = versionsData.data.reduce((a, b) => (b.version > a.version ? b : a));
+ const versionResponse = await fetch(
+ `/api/configs/${config.id}/versions/${latestItem.version}`,
+ { headers: { 'X-API-KEY': apiKey } },
+ );
+ const versionData: ConfigVersionResponse = await versionResponse.json();
+ if (versionData.success && versionData.data) {
+ return [flattenConfigVersion(config, versionData.data)];
+ }
+ }
+ } catch (e) {
+ console.error(`Failed to load more config ${config.id}:`, e);
+ }
+ return [];
+ }),
+ );
+ results.forEach(v => newVersions.push(...v));
+ }
+
+ return { newVersions, newVersionCounts, newConfigMeta };
+}
diff --git a/app/lib/constants.ts b/app/lib/constants.ts
new file mode 100644
index 0000000..0e44cbb
--- /dev/null
+++ b/app/lib/constants.ts
@@ -0,0 +1,15 @@
+/**
+ * Constants for Management
+ */
+
+/** localStorage key for the config cache */
+export const CACHE_KEY = 'kaapi_configs_cache';
+
+/** Cache is considered stale after 5 minutes */
+export const CACHE_MAX_AGE_MS = 5 * 60 * 1000;
+
+/** Number of configs to load per page on the Config Library page */
+export const PAGE_SIZE = 10;
+
+/** Custom event dispatched when background validation invalidates the in-memory cache */
+export const CACHE_INVALIDATED_EVENT = 'kaapi:config-cache-invalidated';
diff --git a/app/lib/store/configStore.ts b/app/lib/store/configStore.ts
new file mode 100644
index 0000000..30ceb1c
--- /dev/null
+++ b/app/lib/store/configStore.ts
@@ -0,0 +1,93 @@
+/**
+ * Module-level mutable state and localStorage cache utilities for Config Management.
+ *
+ * All shared state is held on a single exported object (`configState`) so that
+ * other modules (configFetchers, the hook) can mutate it via property assignment—
+ * which works correctly with ES module semantics, unlike reassigning named exports.
+ */
+
+import { ConfigPublic, ConfigVersionItems } from '@/app/lib/configTypes';
+import { ConfigCache, SavedConfig } from '@/app/lib/types/configs';
+import { CACHE_KEY } from '@/app/lib/constants';
+
+/**
+ * All module-level mutable singletons in one object.
+ * Properties can be read and reassigned from any importing module.
+ */
+export const configState = {
+ /** In-memory cache for the current session (avoids localStorage reads). */
+ inMemoryCache: null as ConfigCache | null,
+
+ /**
+ * In-memory lightweight version-item list per config (no config_blob).
+ * Populated during initial fetchAllConfigs so loadVersionsForConfig can skip
+ * the extra GET /api/configs/{id}/versions re-fetches.
+ */
+ versionItemsCache: {} as Record,
+
+ /**
+ * Full lightweight config list from GET /api/configs (no version details).
+ * Used by loadMoreConfigs to know which configs still need version details fetched.
+ */
+ allConfigMeta: null as ConfigPublic[] | null,
+
+ /** Deduplication guard for concurrent loadMoreConfigs calls. */
+ pendingLoadMore: null as Promise | null,
+
+ /**
+ * A single in-flight fetch promise shared by every useConfigs() instance.
+ * When a second component calls fetchConfigs() while a fetch is already running,
+ * it awaits this promise instead of starting its own request.
+ */
+ pendingFetch: null as Promise | null,
+
+ /**
+ * Prevents concurrent background validations.
+ * A single GET /api/configs call is all that's needed.
+ */
+ validationInProgress: false,
+};
+
+/**
+ * Per-version in-flight fetch promises for single on-demand version detail loads.
+ * Key is `${config_id}:${version}`. Prevents duplicate fetches when the user
+ * rapidly clicks the same history entry.
+ */
+export const pendingSingleVersionLoads = new Map>();
+
+/**
+ * Per-config in-flight version-load promises.
+ * Deduplicates concurrent loadVersionsForConfig(config_id) calls.
+ */
+export const pendingVersionLoads = new Map>();
+
+export const loadCache = (): ConfigCache | null => {
+ if (typeof window === 'undefined') return null;
+ try {
+ const cached = localStorage.getItem(CACHE_KEY);
+ if (cached) return JSON.parse(cached);
+ } catch (e) {
+ console.error('Failed to load config cache:', e);
+ }
+ return null;
+};
+
+export const saveCache = (cache: ConfigCache): void => {
+ if (typeof window === 'undefined') return;
+ try {
+ localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
+ } catch (e) {
+ console.error('Failed to save config cache:', e);
+ }
+};
+
+export const clearConfigCache = (): void => {
+ if (typeof window === 'undefined') return;
+ try {
+ localStorage.removeItem(CACHE_KEY);
+ configState.inMemoryCache = null;
+ configState.versionItemsCache = {};
+ } catch (e) {
+ console.error('Failed to clear config cache:', e);
+ }
+};
diff --git a/app/lib/types/configs.ts b/app/lib/types/configs.ts
new file mode 100644
index 0000000..3332a27
--- /dev/null
+++ b/app/lib/types/configs.ts
@@ -0,0 +1,66 @@
+/**
+ * UI-specific types for Config Management
+ * These types represent configs as they are consumed by the frontend,
+ * flattened from the raw API response shapes in configTypes.ts.
+ */
+
+import { ConfigVersionItems, Tool } from '@/app/lib/configTypes';
+
+// Re-export so consumers don't need to reach into configTypes directly
+export type { ConfigVersionItems, Tool };
+
+// UI representation of a config version (flattened for easier display)
+export interface SavedConfig {
+ id: string; // version id
+ config_id: string; // parent config id
+ name: string;
+ description?: string | null;
+ version: number;
+ timestamp: string; // ISO datetime from backend
+ instructions: string;
+ promptContent: string; // Same as instructions for compatibility
+ modelName: string;
+ provider: string;
+ type: 'text' | 'stt' | 'tts'; // Config type - always present in UI (defaults to 'text')
+ temperature: number;
+ vectorStoreIds: string;
+ tools?: Tool[];
+ commit_message?: string | null;
+}
+
+// Config grouped by config_id with all its versions
+export interface ConfigGroup {
+ config_id: string;
+ name: string;
+ description?: string | null;
+ versions: SavedConfig[]; // fully-loaded entries (have config_blob)
+ latestVersion: SavedConfig;
+ totalVersions: number;
+ /** True once all historical versions have been fetched (lazy-loaded on demand) */
+ versionsFullyLoaded: boolean;
+ /** Lightweight version list (no config_blob). Populated after initial load; used for history display. */
+ versionItems: ConfigVersionItems[];
+}
+
+// Cache structure stored in localStorage
+export interface ConfigCache {
+ configs: SavedConfig[];
+ // Map of config_id -> { updated_at, version_count }
+ configMeta: Record;
+ cachedAt: number; // timestamp when cache was created
+ // Actual total version count per config (populated on first fetch)
+ versionCounts: Record;
+ /** Total number of configs available on the server (from GET /api/configs) */
+ totalConfigCount?: number;
+ /** True when only a subset of configs have had their version details fetched */
+ partialFetch?: boolean;
+}
+
+// Result shape returned by fetchAllConfigs
+export interface FetchResult {
+ configs: SavedConfig[];
+ configMeta: Record;
+ versionCounts: Record;
+ totalConfigCount: number;
+ partialFetch: boolean;
+}
diff --git a/app/lib/useConfigs.ts b/app/lib/useConfigs.ts
deleted file mode 100644
index 7618088..0000000
--- a/app/lib/useConfigs.ts
+++ /dev/null
@@ -1,490 +0,0 @@
-/**
- * Shared hook for fetching and managing configurations
- * Used by Config Library, Prompt Editor, and Evaluations pages
- *
- * Features:
- * - localStorage caching for fast subsequent loads
- * - Cache invalidation based on config updated_at timestamps
- * - In-memory cache to avoid redundant fetches within same session
- */
-
-import { useState, useEffect, useCallback, useRef } from 'react';
-import {
- ConfigPublic,
- ConfigVersionPublic,
- Tool,
- ConfigListResponse,
- ConfigVersionListResponse,
- ConfigVersionResponse,
-} from './configTypes';
-
-// ============ TYPES ============
-
-// UI representation of a config version (flattened for easier display)
-export interface SavedConfig {
- id: string; // version id
- config_id: string; // parent config id
- name: string;
- description?: string | null;
- version: number;
- timestamp: string; // ISO datetime from backend
- instructions: string;
- promptContent: string; // Same as instructions for compatibility
- modelName: string;
- provider: string;
- type: 'text' | 'stt' | 'tts'; // Config type - always present in UI (defaults to 'text')
- temperature: number;
- vectorStoreIds: string;
- tools?: Tool[];
- commit_message?: string | null;
-}
-
-// Config grouped by name with all its versions
-export interface ConfigGroup {
- config_id: string;
- name: string;
- description?: string | null;
- versions: SavedConfig[];
- latestVersion: SavedConfig;
- totalVersions: number;
-}
-
-// Cache structure stored in localStorage
-interface ConfigCache {
- configs: SavedConfig[];
- // Map of config_id -> { updated_at, version_count }
- configMeta: Record;
- cachedAt: number; // timestamp when cache was created
-}
-
-// ============ CONSTANTS ============
-
-const CACHE_KEY = 'kaapi_configs_cache';
-const CACHE_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes - cache is considered stale after this
-
-// ============ HELPER FUNCTIONS ============
-
-// Get API key from localStorage
-const getApiKey = (): string | null => {
- if (typeof window === 'undefined') return null;
- try {
- const stored = localStorage.getItem('kaapi_api_keys');
- if (stored) {
- const keys = JSON.parse(stored);
- return keys.length > 0 ? keys[0].key : null;
- }
- } catch (e) {
- console.error('Failed to get API key:', e);
- }
- return null;
-};
-
-// Flatten config version for UI
-const flattenConfigVersion = (
- config: ConfigPublic,
- version: ConfigVersionPublic
-): SavedConfig => {
- const blob = version.config_blob;
- const params = blob.completion.params;
-
- // Backend sends flattened fields (knowledge_base_ids, max_num_results) directly on params
- // Convert to tools array for frontend UI compatibility
- const tools: Tool[] = params.tools || [];
-
- // If no tools array but has flattened fields, create tools array from them
- // Each knowledge_base_id becomes a separate tool for UI display
- if (tools.length === 0 && params.knowledge_base_ids) {
- // Ensure knowledge_base_ids is an array
- const kbIds = Array.isArray(params.knowledge_base_ids)
- ? params.knowledge_base_ids
- : [params.knowledge_base_ids];
-
- kbIds.forEach((kbId: string) => {
- if (kbId) { // Only add non-empty IDs
- tools.push({
- type: 'file_search',
- knowledge_base_ids: [kbId], // Each tool gets one ID for UI
- max_num_results: params.max_num_results || 20,
- });
- }
- });
- }
-
- return {
- id: version.id,
- config_id: config.id,
- name: config.name,
- description: config.description,
- version: version.version,
- timestamp: version.inserted_at,
- instructions: params.instructions || '',
- promptContent: params.instructions || '',
- modelName: params.model || '',
- provider: blob.completion.provider,
- type: blob.completion.type || 'text', // Default to 'text' for backward compatibility
- temperature: params.temperature ?? 0.7,
- vectorStoreIds: tools[0]?.knowledge_base_ids?.[0] || '',
- tools: tools,
- commit_message: version.commit_message,
- };
-};
-
-// Group configs by name
-const groupConfigs = (configs: SavedConfig[]): ConfigGroup[] => {
- const grouped = new Map();
-
- configs.forEach((config) => {
- const existing = grouped.get(config.config_id) || [];
- existing.push(config);
- grouped.set(config.config_id, existing);
- });
-
- return Array.from(grouped.entries()).map(([config_id, versions]) => {
- // Sort versions by version number descending
- const sortedVersions = versions.sort((a, b) => b.version - a.version);
- return {
- config_id,
- name: sortedVersions[0].name,
- description: sortedVersions[0].description,
- versions: sortedVersions,
- latestVersion: sortedVersions[0],
- totalVersions: sortedVersions.length,
- };
- });
-};
-
-// ============ CACHE FUNCTIONS ============
-
-// Load cache from localStorage
-const loadCache = (): ConfigCache | null => {
- if (typeof window === 'undefined') return null;
- try {
- const cached = localStorage.getItem(CACHE_KEY);
- if (cached) {
- return JSON.parse(cached);
- }
- } catch (e) {
- console.error('Failed to load config cache:', e);
- }
- return null;
-};
-
-// Save cache to localStorage
-const saveCache = (cache: ConfigCache): void => {
- if (typeof window === 'undefined') return;
- try {
- localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
- } catch (e) {
- console.error('Failed to save config cache:', e);
- }
-};
-
-// Clear cache
-export const clearConfigCache = (): void => {
- if (typeof window === 'undefined') return;
- try {
- localStorage.removeItem(CACHE_KEY);
- // Also clear in-memory cache
- inMemoryCache = null;
- } catch (e) {
- console.error('Failed to clear config cache:', e);
- }
-};
-
-// In-memory cache for current session (avoids localStorage reads)
-let inMemoryCache: ConfigCache | null = null;
-
-// ============ MAIN HOOK ============
-
-export interface UseConfigsResult {
- configs: SavedConfig[];
- configGroups: ConfigGroup[];
- isLoading: boolean;
- error: string | null;
- refetch: (force?: boolean) => Promise;
- isCached: boolean;
-}
-
-export function useConfigs(): UseConfigsResult {
- const [configs, setConfigs] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
- const [isCached, setIsCached] = useState(false);
- const fetchInProgress = useRef(false);
-
- // Store refetch function in ref for background validation
- const refetchRef = useRef<((force: boolean) => Promise) | undefined>(undefined);
-
- const fetchConfigs = useCallback(async (force: boolean = false) => {
- // Prevent concurrent fetches
- if (fetchInProgress.current) return;
-
- const apiKey = getApiKey();
- if (!apiKey) {
- setError('No API key found. Please add an API key in the Keystore.');
- setIsLoading(false);
- return;
- }
-
- // Validate cache in background without blocking UI
- const validateCacheInBackground = async (cache: ConfigCache) => {
- try {
- // Fetch just the config list (lightweight call)
- const response = await fetch('/api/configs', {
- headers: { 'X-API-KEY': apiKey },
- });
- const data: ConfigListResponse = await response.json();
-
- if (!data.success || !data.data) return;
-
- // Check if any config has been updated or new configs added
- let needsRefresh = false;
- const currentMeta = cache.configMeta;
-
- // Check for new or updated configs
- for (const config of data.data) {
- const cached = currentMeta[config.id];
- if (!cached) {
- needsRefresh = true;
- break;
- }
- if (cached.updated_at !== config.updated_at) {
- needsRefresh = true;
- break;
- }
- }
-
- // Check for deleted configs
- if (!needsRefresh) {
- const currentIds = new Set(data.data.map(c => c.id));
- for (const cachedId of Object.keys(currentMeta)) {
- if (!currentIds.has(cachedId)) {
- needsRefresh = true;
- break;
- }
- }
- }
-
- // Also check version counts by fetching version lists
- if (!needsRefresh) {
- for (const config of data.data) {
- const cached = currentMeta[config.id];
- if (cached) {
- try {
- const versionsResponse = await fetch(`/api/configs/${config.id}/versions`, {
- headers: { 'X-API-KEY': apiKey },
- });
- const versionsData: ConfigVersionListResponse = await versionsResponse.json();
- if (versionsData.success && versionsData.data) {
- if (versionsData.data.length !== cached.version_count) {
- needsRefresh = true;
- break;
- }
- }
- } catch {
- // If we can't check, assume we need refresh to be safe
- needsRefresh = true;
- break;
- }
- }
- }
- }
-
- if (needsRefresh) {
- inMemoryCache = null;
- fetchInProgress.current = false;
- if (refetchRef.current) {
- await refetchRef.current(true);
- }
- }
- } catch {
- console.error('Failed to validate cache');
- }
- };
-
- // Check in-memory cache first (fastest)
- if (!force && inMemoryCache) {
- const cacheAge = Date.now() - inMemoryCache.cachedAt;
- if (cacheAge < CACHE_MAX_AGE_MS) {
- setConfigs(inMemoryCache.configs);
- setIsCached(true);
- setIsLoading(false);
- // Validate cache in background
- validateCacheInBackground(inMemoryCache);
- return;
- }
- }
-
- // Check localStorage cache
- if (!force) {
- const cache = loadCache();
- if (cache) {
- const cacheAge = Date.now() - cache.cachedAt;
- if (cacheAge < CACHE_MAX_AGE_MS) {
- setConfigs(cache.configs);
- setIsCached(true);
- setIsLoading(false);
- inMemoryCache = cache;
- // Validate cache in background
- validateCacheInBackground(cache);
- return;
- }
- }
- }
-
- // No valid cache, fetch from API
- fetchInProgress.current = true;
- setIsLoading(true);
- setError(null);
- setIsCached(false);
-
- try {
- const result = await fetchAllConfigs(apiKey);
- setConfigs(result.configs);
-
- // Save to cache
- const newCache: ConfigCache = {
- configs: result.configs,
- configMeta: result.configMeta,
- cachedAt: Date.now(),
- };
- saveCache(newCache);
- inMemoryCache = newCache;
- } catch {
- console.error('Failed to load saved configs');
- setError('Failed to load configurations. Please try again.');
- } finally {
- setIsLoading(false);
- fetchInProgress.current = false;
- }
- }, []);
-
- // Store refetch in ref for background validation
- refetchRef.current = fetchConfigs;
-
- useEffect(() => {
- fetchConfigs();
- }, [fetchConfigs]);
-
- return {
- configs,
- configGroups: groupConfigs(configs),
- isLoading,
- error,
- refetch: fetchConfigs,
- isCached,
- };
-}
-
-// ============ FETCH HELPERS ============
-
-interface FetchResult {
- configs: SavedConfig[];
- configMeta: Record;
-}
-
-async function fetchAllConfigs(apiKey: string): Promise {
- // Fetch all configs
- const response = await fetch('/api/configs', {
- headers: { 'X-API-KEY': apiKey },
- });
- const data: ConfigListResponse = await response.json();
-
- if (!data.success || !data.data) {
- throw new Error(data.error || 'Failed to fetch configs');
- }
-
- const allVersions: SavedConfig[] = [];
- const configMeta: Record = {};
-
- // Fetch versions for all configs in parallel (batched)
- const BATCH_SIZE = 5; // Fetch 5 configs at a time
- const configs = data.data;
-
- for (let i = 0; i < configs.length; i += BATCH_SIZE) {
- const batch = configs.slice(i, i + BATCH_SIZE);
- const batchPromises = batch.map(async (config) => {
- try {
- const versionsResponse = await fetch(`/api/configs/${config.id}/versions`, {
- headers: { 'X-API-KEY': apiKey },
- });
- const versionsData: ConfigVersionListResponse = await versionsResponse.json();
-
- if (versionsData.success && versionsData.data) {
- // Store metadata for cache validation
- configMeta[config.id] = {
- updated_at: config.updated_at,
- version_count: versionsData.data.length,
- };
-
- // Fetch all version details in parallel
- const versionPromises = versionsData.data.map(async (versionItem) => {
- try {
- const versionResponse = await fetch(
- `/api/configs/${config.id}/versions/${versionItem.version}`,
- { headers: { 'X-API-KEY': apiKey } }
- );
- const versionData: ConfigVersionResponse = await versionResponse.json();
-
- if (versionData.success && versionData.data) {
- return flattenConfigVersion(config, versionData.data);
- }
- } catch (e) {
- console.error(`Failed to fetch version ${versionItem.version}:`, e);
- }
- return null;
- });
-
- const versions = await Promise.all(versionPromises);
- return versions.filter((v): v is SavedConfig => v !== null);
- }
- } catch (e) {
- console.error(`Failed to fetch versions for config ${config.id}:`, e);
- }
- return [];
- });
-
- const batchResults = await Promise.all(batchPromises);
- batchResults.forEach(versions => allVersions.push(...versions));
- }
-
- return { configs: allVersions, configMeta };
-}
-
-// ============ UTILITY FUNCTIONS ============
-
-// Format timestamp as relative time
-// Handles UTC timestamps from the database and converts them to local time
-export const formatRelativeTime = (timestamp: string | number): string => {
- const now = Date.now();
-
- let date: number;
- if (typeof timestamp === 'string') {
- // If timestamp doesn't include timezone info, assume it's UTC
- // and append 'Z' to ensure it's interpreted as UTC
- const utcTimestamp = timestamp.endsWith('Z') || timestamp.includes('+') || timestamp.includes('T') && timestamp.split('T')[1].includes('-')
- ? timestamp
- : timestamp + 'Z';
- date = new Date(utcTimestamp).getTime();
- } else {
- date = timestamp;
- }
-
- const diff = now - date;
- const minutes = Math.floor(diff / 60000);
- const hours = Math.floor(diff / 3600000);
- const days = Math.floor(diff / 86400000);
-
- if (minutes < 1) return 'just now';
- if (minutes < 60) return `${minutes}m ago`;
- if (hours < 24) return `${hours}h ago`;
- if (days < 30) return `${days}d ago`;
- return new Date(date).toLocaleDateString();
-};
-
-// ============ CACHE INVALIDATION HELPERS ============
-
-// Call this when a config is saved/updated to invalidate cache
-export const invalidateConfigCache = (): void => {
- clearConfigCache();
-};
diff --git a/app/lib/utils.ts b/app/lib/utils.ts
index 3327498..6dab285 100644
--- a/app/lib/utils.ts
+++ b/app/lib/utils.ts
@@ -1,13 +1,156 @@
import { Credential, ProviderDef } from "@/app/lib/types/credentials";
import { formatDistanceToNow } from "date-fns";
+import { clearConfigCache } from "@/app/lib/store/configStore";
+import {
+ ConfigPublic,
+ ConfigVersionPublic,
+ ConfigVersionItems,
+ Tool,
+} from './configTypes';
+import { SavedConfig, ConfigGroup } from './types/configs';
export function timeAgo(dateStr: string): string {
return formatDistanceToNow(new Date(dateStr), { addSuffix: true });
}
+// Format timestamp as relative time
+// Handles UTC timestamps from the database and converts them to local time
export function getExistingForProvider(
provider: ProviderDef,
creds: Credential[],
): Credential | null {
return creds.find((c) => c.provider === provider.credentialKey) || null;
}
+
+export const formatRelativeTime = (timestamp: string | number): string => {
+ const now = Date.now();
+
+ let date: number;
+ if (typeof timestamp === 'string') {
+ // If timestamp doesn't include timezone info, assume it's UTC
+ // and append 'Z' to ensure it's interpreted as UTC
+ const utcTimestamp = timestamp.endsWith('Z') || timestamp.includes('+') || timestamp.includes('T') && timestamp.split('T')[1].includes('-')
+ ? timestamp
+ : timestamp + 'Z';
+ date = new Date(utcTimestamp).getTime();
+ } else {
+ date = timestamp;
+ }
+
+ const diff = now - date;
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(diff / 3600000);
+ const days = Math.floor(diff / 86400000);
+
+ if (minutes < 1) return 'just now';
+ if (minutes < 60) return `${minutes}m ago`;
+ if (hours < 24) return `${hours}h ago`;
+ if (days < 30) return `${days}d ago`;
+ return new Date(date).toLocaleDateString();
+};
+
+// Call this when a config is saved/updated to invalidate cache
+export const invalidateConfigCache = (): void => {
+ clearConfigCache();
+};
+
+// ============ CONFIG HELPERS ============
+
+/** Reads the first stored API key from localStorage. */
+export const getApiKey = (): string | null => {
+ if (typeof window === 'undefined') return null;
+ try {
+ const stored = localStorage.getItem('kaapi_api_keys');
+ if (stored) {
+ const keys = JSON.parse(stored);
+ return keys.length > 0 ? keys[0].key : null;
+ }
+ } catch (e) {
+ console.error('Failed to get API key:', e);
+ }
+ return null;
+};
+
+/**
+ * Flattens a raw API config version into the UI's SavedConfig shape.
+ * Converts backend flattened `knowledge_base_ids` / `max_num_results` fields
+ * into the `tools` array expected by the frontend.
+ */
+export const flattenConfigVersion = (
+ config: ConfigPublic,
+ version: ConfigVersionPublic,
+): SavedConfig => {
+ const blob = version.config_blob;
+ const params = blob.completion.params;
+
+ const tools: Tool[] = params.tools || [];
+
+ if (tools.length === 0 && params.knowledge_base_ids) {
+ const kbIds = Array.isArray(params.knowledge_base_ids)
+ ? params.knowledge_base_ids
+ : [params.knowledge_base_ids];
+
+ kbIds.forEach((kbId: string) => {
+ if (kbId) {
+ tools.push({
+ type: 'file_search',
+ knowledge_base_ids: [kbId],
+ max_num_results: params.max_num_results || 20,
+ });
+ }
+ });
+ }
+
+ return {
+ id: version.id,
+ config_id: config.id,
+ name: config.name,
+ description: config.description,
+ version: version.version,
+ timestamp: version.inserted_at,
+ instructions: params.instructions || '',
+ promptContent: params.instructions || '',
+ modelName: params.model || '',
+ provider: blob.completion.provider,
+ type: blob.completion.type || 'text',
+ temperature: params.temperature ?? 0.7,
+ vectorStoreIds: tools[0]?.knowledge_base_ids?.[0] || '',
+ tools,
+ commit_message: version.commit_message,
+ };
+};
+
+/**
+ * Groups a flat array of SavedConfig versions by config_id.
+ *
+ * @param versionCounts - optional map of config_id → actual total version count from the API
+ * @param vItemsMap - optional lightweight version items per config (from versionItemsCache)
+ */
+export const groupConfigs = (
+ configs: SavedConfig[],
+ versionCounts?: Record,
+ vItemsMap?: Record,
+): ConfigGroup[] => {
+ const grouped = new Map();
+
+ configs.forEach((config) => {
+ const existing = grouped.get(config.config_id) || [];
+ existing.push(config);
+ grouped.set(config.config_id, existing);
+ });
+
+ return Array.from(grouped.entries()).map(([config_id, versions]) => {
+ const sortedVersions = versions.sort((a, b) => b.version - a.version);
+ const totalVersions = versionCounts?.[config_id] ?? sortedVersions.length;
+ return {
+ config_id,
+ name: sortedVersions[0].name,
+ description: sortedVersions[0].description,
+ versions: sortedVersions,
+ latestVersion: sortedVersions[0],
+ totalVersions,
+ versionsFullyLoaded: sortedVersions.length >= totalVersions,
+ versionItems: vItemsMap?.[config_id] ?? [],
+ };
+ });
+};
From 0862c56ba042958ed853b8b8919f350366c50074 Mon Sep 17 00:00:00 2001
From: Ayush
Date: Mon, 23 Mar 2026 09:50:23 +0530
Subject: [PATCH 02/10] fix(*): remove the unwanted imports
---
app/evaluations/page.tsx | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/app/evaluations/page.tsx b/app/evaluations/page.tsx
index ead350c..d846355 100644
--- a/app/evaluations/page.tsx
+++ b/app/evaluations/page.tsx
@@ -9,7 +9,7 @@
import { useState, useEffect, useCallback, Suspense } from 'react';
import { colors } from '@/app/lib/colors';
-import { useRouter, useSearchParams } from 'next/navigation'
+import { useSearchParams } from 'next/navigation'
import { APIKey, STORAGE_KEY } from '@/app/keystore/page';
import { Dataset } from '@/app/datasets/page';
import Sidebar from '@/app/components/Sidebar';
@@ -24,7 +24,6 @@ type Tab = 'datasets' | 'evaluations';
const leftPanelWidth = 450;
function SimplifiedEvalContent() {
- const router = useRouter();
const searchParams = useSearchParams();
const toast = useToast();
From 3291d90fbc8f12a5b610e4b9e7308b7832cb226b Mon Sep 17 00:00:00 2001
From: Ayush8923
Date: Mon, 23 Mar 2026 09:52:41 +0530
Subject: [PATCH 03/10] fix(*): remove the unwanted imports
---
app/evaluations/page.tsx | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/app/evaluations/page.tsx b/app/evaluations/page.tsx
index ead350c..d846355 100644
--- a/app/evaluations/page.tsx
+++ b/app/evaluations/page.tsx
@@ -9,7 +9,7 @@
import { useState, useEffect, useCallback, Suspense } from 'react';
import { colors } from '@/app/lib/colors';
-import { useRouter, useSearchParams } from 'next/navigation'
+import { useSearchParams } from 'next/navigation'
import { APIKey, STORAGE_KEY } from '@/app/keystore/page';
import { Dataset } from '@/app/datasets/page';
import Sidebar from '@/app/components/Sidebar';
@@ -24,7 +24,6 @@ type Tab = 'datasets' | 'evaluations';
const leftPanelWidth = 450;
function SimplifiedEvalContent() {
- const router = useRouter();
const searchParams = useSearchParams();
const toast = useToast();
From 43babc17e855dc70ece9dab4bd898dfbc2a9d0f2 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Mon, 23 Mar 2026 12:18:07 +0530
Subject: [PATCH 04/10] fix(*): fix the n+1 api calls in the prompt editor flow
nd evalutions tabs
---
app/components/ConfigSelector.tsx | 659 ++++++---
.../prompt-editor/ConfigDiffPane.tsx | 4 +-
.../prompt-editor/ConfigEditorPane.tsx | 1206 ++++++++++-------
app/components/prompt-editor/DiffView.tsx | 6 +-
.../prompt-editor/HistorySidebar.tsx | 18 +-
app/configurations/prompt-editor/page.tsx | 593 ++++----
app/hooks/useConfigs.ts | 34 +-
app/lib/constants.ts | 13 +
app/lib/types/configs.ts | 5 +-
app/lib/utils.ts | 2 -
10 files changed, 1593 insertions(+), 947 deletions(-)
diff --git a/app/components/ConfigSelector.tsx b/app/components/ConfigSelector.tsx
index 3c3a82a..a8e988c 100644
--- a/app/components/ConfigSelector.tsx
+++ b/app/components/ConfigSelector.tsx
@@ -3,15 +3,20 @@
* Allows selecting a saved config with "Edit in Prompt Editor" link
*/
-"use client"
+"use client";
-import { useState, useRef, useLayoutEffect } from 'react';
-import { useRouter } from 'next/navigation';
-import { colors } from '@/app/lib/colors';
-import { useConfigs } from '@/app/hooks/useConfigs';
-import { SavedConfig } from '@/app/lib/types/configs';
-import { ChevronUpIcon, ChevronDownIcon, EditIcon, GearIcon, CheckIcon } from '@/app/components/icons';
-import { formatRelativeTime } from '@/app/lib/utils';
+import { useState, useRef, useLayoutEffect, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { colors } from "@/app/lib/colors";
+import { useConfigs } from "@/app/hooks/useConfigs";
+import {
+ ChevronUpIcon,
+ ChevronDownIcon,
+ EditIcon,
+ GearIcon,
+ CheckIcon,
+} from "@/app/components/icons";
+import { formatRelativeTime } from "@/app/lib/utils";
interface ConfigSelectorProps {
selectedConfigId: string;
@@ -35,12 +40,25 @@ export default function ConfigSelector({
experimentName,
}: ConfigSelectorProps) {
const router = useRouter();
- const { configs, configGroups, isLoading, error, loadVersionsForConfig } = useConfigs();
+ const {
+ configs,
+ isLoading,
+ error,
+ loadVersionsForConfig,
+ loadSingleVersion,
+ allConfigMeta,
+ versionItemsMap,
+ } = useConfigs({ pageSize: 0 });
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState('');
+ const [searchQuery, setSearchQuery] = useState("");
const [promptExpanded, setPromptExpanded] = useState(false);
const [isPromptOverflowing, setIsPromptOverflowing] = useState(false);
const promptRef = useRef(null);
+ const [expandedConfigId, setExpandedConfigId] = useState(null); // config group is expanded in the dropdown
+ const [loadingVersionsFor, setLoadingVersionsFor] = useState>(
+ new Set(),
+ ); // State for use which config groups are currently loading their version list
+ const [isLoadingPreview, setIsLoadingPreview] = useState(false); // True while full config details are being fetched for the preview pane
// Reset expanded state and recheck overflow whenever selected config changes.
useLayoutEffect(() => {
@@ -52,57 +70,114 @@ export default function ConfigSelector({
setIsPromptOverflowing(el.scrollHeight > el.clientHeight);
}, [selectedConfigId, selectedVersion, configs]);
- // Find currently selected config
+ // Find currently selected config (only present after loadSingleVersion has completed)
const selectedConfig = configs.find(
- c => c.config_id === selectedConfigId && c.version === selectedVersion
+ (c) => c.config_id === selectedConfigId && c.version === selectedVersion,
);
- // Filter config groups based on search query
- const filteredConfigGroups = searchQuery.trim()
- ? configGroups.filter(group =>
- group.name.toLowerCase().includes(searchQuery.toLowerCase())
+ // Config name from lightweight metadata
+ const selectedConfigName = selectedConfigId
+ ? allConfigMeta.find((m) => m.id === selectedConfigId)?.name
+ : undefined;
+
+ // Auto-load full config details for the preview pane whenever the selection changes
+ // and the full data isn't already in the loaded set.
+ useEffect(() => {
+ if (!selectedConfigId || !selectedVersion) {
+ setIsLoadingPreview(false);
+ return;
+ }
+ const alreadyLoaded = configs.find(
+ (c) => c.config_id === selectedConfigId && c.version === selectedVersion,
+ );
+ if (alreadyLoaded) {
+ setIsLoadingPreview(false);
+ return;
+ }
+ setIsLoadingPreview(true);
+ loadSingleVersion(selectedConfigId, selectedVersion)
+ .then((result) => {
+ if (!result) setIsLoadingPreview(false);
+ })
+ .catch(() => setIsLoadingPreview(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedConfigId, selectedVersion, configs]);
+
+ // Dropdown display list: all configs from the lightweight allConfigMeta,
+ // filtered by search query. Version details are loaded on-demand per group.
+ const filteredDisplayGroups = searchQuery.trim()
+ ? allConfigMeta.filter((m) =>
+ m.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
- : configGroups;
+ : allConfigMeta;
- // Handle config selection
- const handleSelect = (config: SavedConfig) => {
- onConfigSelect(config.config_id, config.version);
+ const handleSelectVersionItem = (config_id: string, version: number) => {
+ onConfigSelect(config_id, version);
setIsDropdownOpen(false);
- setSearchQuery(''); // Clear search on selection
+ setSearchQuery("");
};
- // Handle dropdown close
const handleCloseDropdown = () => {
setIsDropdownOpen(false);
- setSearchQuery(''); // Clear search on close
+ setSearchQuery(""); // Clear search on close
};
- // When opening the dropdown, lazily load full version history for all configs
+ // Open dropdown — auto-expand the currently-selected config group if any
const handleOpenDropdown = () => {
if (disabled) return;
if (!isDropdownOpen) {
- configGroups.forEach(group => {
- if (!group.versionsFullyLoaded) {
- loadVersionsForConfig(group.config_id);
- }
+ const autoExpand = selectedConfigId || null;
+ setExpandedConfigId(autoExpand);
+ if (
+ autoExpand &&
+ !versionItemsMap[autoExpand] &&
+ !loadingVersionsFor.has(autoExpand)
+ ) {
+ setLoadingVersionsFor((prev) => new Set(prev).add(autoExpand));
+ loadVersionsForConfig(autoExpand).finally(() => {
+ setLoadingVersionsFor((prev) => {
+ const s = new Set(prev);
+ s.delete(autoExpand);
+ return s;
+ });
+ });
+ }
+ }
+ setIsDropdownOpen((prev) => !prev);
+ };
+
+ // Toggle a config group’s expansion; load its version list on first expand
+ const handleToggleGroup = (config_id: string) => {
+ if (expandedConfigId === config_id) {
+ setExpandedConfigId(null);
+ return;
+ }
+ setExpandedConfigId(config_id);
+ if (!versionItemsMap[config_id] && !loadingVersionsFor.has(config_id)) {
+ setLoadingVersionsFor((prev) => new Set(prev).add(config_id));
+ loadVersionsForConfig(config_id).finally(() => {
+ setLoadingVersionsFor((prev) => {
+ const s = new Set(prev);
+ s.delete(config_id);
+ return s;
+ });
});
}
- setIsDropdownOpen(prev => !prev);
};
// Build URL params preserving evaluation context
const buildEditorUrl = (configId?: string, version?: number) => {
const params = new URLSearchParams();
if (configId && version) {
- params.set('config', configId);
- params.set('version', version.toString());
+ params.set("config", configId);
+ params.set("version", version.toString());
} else {
- params.set('new', 'true');
+ params.set("new", "true");
}
// Preserve evaluation context
- if (datasetId) params.set('dataset', datasetId);
- if (experimentName) params.set('experiment', experimentName);
- params.set('from', 'evaluations'); // Mark that we came from evaluations
+ if (datasetId) params.set("dataset", datasetId);
+ if (experimentName) params.set("experiment", experimentName);
+ params.set("from", "evaluations"); // Mark that we came from evaluations
return `/configurations/prompt-editor?${params.toString()}`;
};
@@ -114,26 +189,42 @@ export default function ConfigSelector({
// Navigate to Config Library
const handleBrowseLibrary = () => {
const params = new URLSearchParams();
- if (datasetId) params.set('dataset', datasetId);
- if (experimentName) params.set('experiment', experimentName);
- params.set('from', 'evaluations');
+ if (datasetId) params.set("dataset", datasetId);
+ if (experimentName) params.set("experiment", experimentName);
+ params.set("from", "evaluations");
router.push(`/configurations?${params.toString()}`);
};
if (isLoading) {
return (
-
-
- {compact ? 'Configuration *' : 'Select Configuration'}
+
+
+ {compact ? "Configuration *" : "Select Configuration"}
Loading configurations...
@@ -144,33 +235,98 @@ export default function ConfigSelector({
if (error) {
return (
-
-
- {compact ? 'Configuration *' : 'Select Configuration'}
-
-
+
+ {compact ? "Configuration *" : "Select Configuration"}
+
+
+
{error}
);
}
+ const noConfigsAvailable = () => {
+ return (
+
+
+
+ No configurations found
+
+
+ Create a configuration in the Prompt Editor first
+
+
+ (e.currentTarget.style.backgroundColor = colors.accent.hover)
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.backgroundColor = colors.accent.primary)
+ }
+ >
+ Create Configuration
+
+
+ );
+ };
+
return (
- {/* Header */}
-
+
-
- {compact ? 'Configuration *' : 'Select Configuration'}
+
+ {compact ? "Configuration *" : "Select Configuration"}
{!compact && (
@@ -187,8 +343,12 @@ export default function ConfigSelector({
border: `1px solid ${colors.border}`,
color: colors.text.primary,
}}
- onMouseEnter={(e) => e.currentTarget.style.backgroundColor = colors.bg.secondary}
- onMouseLeave={(e) => e.currentTarget.style.backgroundColor = colors.bg.primary}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.backgroundColor = colors.bg.secondary)
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.backgroundColor = colors.bg.primary)
+ }
>
Browse Library
@@ -200,47 +360,25 @@ export default function ConfigSelector({
border: `1px solid ${colors.border}`,
color: colors.text.primary,
}}
- onMouseEnter={(e) => e.currentTarget.style.backgroundColor = colors.bg.secondary}
- onMouseLeave={(e) => e.currentTarget.style.backgroundColor = colors.bg.primary}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.backgroundColor = colors.bg.secondary)
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.backgroundColor = colors.bg.primary)
+ }
>
- {selectedConfig ? 'Edit Config' : 'Create Config'}
+ {selectedConfig ? "Edit Config" : "Create Config"}
- {/* No configs available */}
- {configGroups.length === 0 ? (
-
-
-
- No configurations found
-
-
- Create a configuration in the Prompt Editor first
-
-
e.currentTarget.style.backgroundColor = colors.accent.hover}
- onMouseLeave={(e) => e.currentTarget.style.backgroundColor = colors.accent.primary}
- >
- Create Configuration
-
-
+ {allConfigMeta.length === 0 ? (
+ noConfigsAvailable()
) : (
<>
- {/* Dropdown Selector */}
-
+
{isDropdownOpen ? (
- /* Search Input when dropdown is open */
- {selectedConfig
- ? `${selectedConfig.name} (v${selectedConfig.version})`
- : '-- Select a configuration --'
- }
+ {isLoadingPreview
+ ? "Loading..."
+ : selectedConfig
+ ? `${selectedConfig.name} (v${selectedConfig.version})`
+ : selectedConfigName && selectedVersion
+ ? `${selectedConfigName} (v${selectedVersion})`
+ : "-- Select a configuration --"}
- {filteredConfigGroups.length === 0 ? (
-
- {searchQuery ? `No configurations match "${searchQuery}"` : 'No configurations available'}
+ {filteredDisplayGroups.length === 0 ? (
+
+ {searchQuery
+ ? `No configurations match "${searchQuery}"`
+ : "No configurations available"}
) : (
- filteredConfigGroups.map((group) => (
-
- {/* Config group header */}
-
- {group.name} ({group.totalVersions} version{group.totalVersions !== 1 ? 's' : ''})
-
- {/* Versions */}
- {group.versions.map((version) => (
-
handleSelect(version)}
- className="w-full px-4 py-2.5 text-left flex items-center justify-between transition-colors"
- style={{
- backgroundColor: selectedConfig?.id === version.id ? colors.bg.secondary : colors.bg.primary,
- }}
- onMouseEnter={(e) => e.currentTarget.style.backgroundColor = colors.bg.secondary}
- onMouseLeave={(e) => {
- e.currentTarget.style.backgroundColor = selectedConfig?.id === version.id
- ? colors.bg.secondary
- : colors.bg.primary;
- }}
- >
-
-
-
- v{version.version}
-
-
- {version.commit_message || 'No message'}
-
-
-
- {version.provider}/{version.modelName} • T:{version.temperature.toFixed(2)}
- {version.tools && version.tools.length > 0 && (
- <> • {version.tools.map(t => t.knowledge_base_ids).flat().length} KB>
+ filteredDisplayGroups.map((meta) => {
+ const isExpanded = expandedConfigId === meta.id;
+ const isLoadingGroup = loadingVersionsFor.has(meta.id);
+ const versionItems = versionItemsMap[meta.id] ?? [];
+ return (
+
+ {/* Config group header — click to expand/collapse */}
+
handleToggleGroup(meta.id)}
+ >
+
+ {meta.name}
+ {versionItems.length > 0 && (
+
+ ({versionItems.length} version
+ {versionItems.length !== 1 ? "s" : ""})
+
+ )}
+
+
+ {isLoadingGroup && (
+
+
+
+
)}
- {' • '}{formatRelativeTime(version.timestamp)}
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Version items — lightweight list, loaded on first expand */}
+ {isExpanded &&
+ !isLoadingGroup &&
+ versionItems.map((item) => {
+ const isSelected =
+ selectedConfigId === item.config_id &&
+ selectedVersion === item.version;
+ return (
+
+ handleSelectVersionItem(
+ item.config_id,
+ item.version,
+ )
+ }
+ className="w-full px-4 py-2.5 text-left flex items-center justify-between transition-colors"
+ style={{
+ backgroundColor: isSelected
+ ? colors.bg.secondary
+ : colors.bg.primary,
+ }}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.backgroundColor =
+ colors.bg.secondary)
+ }
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor =
+ isSelected
+ ? colors.bg.secondary
+ : colors.bg.primary;
+ }}
+ >
+
+
+
+ v{item.version}
+
+
+ {item.commit_message || "No message"}
+
+
+
+ {formatRelativeTime(item.inserted_at)}
+
+
+ {isSelected && (
+
+ )}
+
+ );
+ })}
+ {/* Spinner while version list is being fetched */}
+ {isExpanded && isLoadingGroup && (
+
+ Loading versions…
-
- {selectedConfig?.id === version.id && (
-
)}
-
- ))}
-
- )))}
+ {/* Empty state: expanded but no versions returned */}
+ {isExpanded &&
+ !isLoadingGroup &&
+ versionItems.length === 0 && (
+
+ No versions available
+
+ )}
+
+ );
+ })
+ )}
)}
+ {/* Preview: loading state while full config details are being fetched */}
+ {isLoadingPreview && !selectedConfig && (
+
+
+
+
+
+
+ Loading configuration details…
+
+
+ )}
+
{/* Selected Config Preview */}
- {selectedConfig && (
+ {selectedConfig && !isLoadingPreview && (
-
+
Provider & Model
-
+
{selectedConfig.provider}/{selectedConfig.modelName}
-
+
Temperature
-
+
{selectedConfig.temperature.toFixed(2)}
{selectedConfig.tools && selectedConfig.tools.length > 0 && (
<>
-
+
Knowledge Base IDs
-
- {selectedConfig.tools.map(tool => tool.knowledge_base_ids).flat().join(', ') || 'None'}
+
+ {selectedConfig.tools
+ .map((tool) => tool.knowledge_base_ids)
+ .flat()
+ .join(", ") || "None"}
-
+
Max Results
-
+
{selectedConfig.tools[0].max_num_results}
@@ -396,32 +704,34 @@ export default function ConfigSelector({
{/* Prompt Preview */}
-
+
-
+
Prompt Preview
{selectedConfig.instructions && isPromptOverflowing && (
setPromptExpanded(p => !p)}
+ onClick={() => setPromptExpanded((p) => !p)}
className="rounded p-0.5 transition-colors"
style={{ color: colors.text.secondary }}
- title={promptExpanded ? 'Collapse' : 'Expand'}
+ title={promptExpanded ? "Collapse" : "Expand"}
>
- {promptExpanded ? (
-
- ) : (
-
- )}
+ {promptExpanded ? : }
)}
- {selectedConfig.instructions || 'No instructions set'}
+ {selectedConfig.instructions || "No instructions set"}
@@ -431,10 +741,7 @@ export default function ConfigSelector({
{/* Click outside to close dropdown */}
{isDropdownOpen && (
-
+
)}
);
diff --git a/app/components/prompt-editor/ConfigDiffPane.tsx b/app/components/prompt-editor/ConfigDiffPane.tsx
index 7711478..24791e2 100644
--- a/app/components/prompt-editor/ConfigDiffPane.tsx
+++ b/app/components/prompt-editor/ConfigDiffPane.tsx
@@ -137,7 +137,7 @@ export default function ConfigDiffPane({
Before (v{compareWith.version})
void;
+ /** Called when user selects a version. Null = new (unsaved) config. */
+ onLoadConfig: (config: SavedConfig | null) => void;
commitMessage: string;
onCommitMessageChange: (message: string) => void;
onSave: () => void;
@@ -20,25 +23,23 @@ interface ConfigEditorPaneProps {
// Collapse functionality
collapsed?: boolean;
onToggle?: () => void;
- /** Lazily loads all historical versions for a given config_id */
+ allConfigMeta?: ConfigPublic[]; // Lightweight list of all configs
+ versionItemsMap?: Record
; // Lightweight version items
loadVersionsForConfig?: (config_id: string) => Promise;
-}
-
-// Group configs by name for nested dropdown
-interface ConfigGroupForDropdown {
- config_id: string;
- name: string;
- versions: SavedConfig[];
+ loadSingleVersion?: (
+ config_id: string,
+ version: number,
+ ) => Promise;
}
// Provider-specific models
const MODEL_OPTIONS = {
openai: [
- { value: 'gpt-4o', label: 'GPT-4o' },
- { value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
- { value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
- { value: 'gpt-4', label: 'GPT-4' },
- { value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo' },
+ { value: "gpt-4o", label: "GPT-4o" },
+ { value: "gpt-4o-mini", label: "GPT-4o Mini" },
+ { value: "gpt-4-turbo", label: "GPT-4 Turbo" },
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
],
// anthropic: [
// { value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
@@ -67,16 +68,62 @@ export default function ConfigEditorPane({
isSaving = false,
collapsed = false,
onToggle,
+ allConfigMeta = [],
+ versionItemsMap = {},
loadVersionsForConfig,
+ loadSingleVersion,
}: ConfigEditorPaneProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [expandedConfigId, setExpandedConfigId] = useState(null); // config group is expanded in the Load dropdown
+ const [loadingVersionsFor, setLoadingVersionsFor] = useState>(
+ new Set(),
+ ); // groups are currently loading their version list
const handleOpenLoadDropdown = () => {
- // When opening, trigger lazy loading for any config that only has its latest version
- if (!isDropdownOpen && loadVersionsForConfig) {
- configGroups.forEach(group => loadVersionsForConfig(group.config_id));
+ if (!isDropdownOpen) {
+ // Auto-expand the currently selected config group, load its version list if needed
+ if (selectedConfigId) {
+ const selected = savedConfigs.find((c) => c.id === selectedConfigId);
+ if (selected) {
+ setExpandedConfigId(selected.config_id);
+ if (!versionItemsMap[selected.config_id] && loadVersionsForConfig) {
+ setLoadingVersionsFor((prev) =>
+ new Set(prev).add(selected.config_id),
+ );
+ loadVersionsForConfig(selected.config_id).finally(() => {
+ setLoadingVersionsFor((prev) => {
+ const s = new Set(prev);
+ s.delete(selected.config_id);
+ return s;
+ });
+ });
+ }
+ }
+ }
+ }
+ setIsDropdownOpen((prev) => !prev);
+ };
+
+ const handleToggleGroup = (config_id: string) => {
+ if (expandedConfigId === config_id) {
+ setExpandedConfigId(null);
+ return;
+ }
+ setExpandedConfigId(config_id);
+ if (
+ !versionItemsMap[config_id] &&
+ !loadingVersionsFor.has(config_id) &&
+ loadVersionsForConfig
+ ) {
+ setLoadingVersionsFor((prev) => new Set(prev).add(config_id));
+ loadVersionsForConfig(config_id).finally(() => {
+ setLoadingVersionsFor((prev) => {
+ const s = new Set(prev);
+ s.delete(config_id);
+ return s;
+ });
+ });
}
- setIsDropdownOpen(prev => !prev);
};
const [showTooltip, setShowTooltip] = useState(null);
@@ -84,26 +131,13 @@ export default function ConfigEditorPane({
const params = configBlob.completion.params;
const tools = (params.tools || []) as Tool[];
- // Group configs by config_id for nested dropdown
- const configGroups = useMemo(() => {
- const grouped = new Map();
- savedConfigs.forEach((config) => {
- const existing = grouped.get(config.config_id) || [];
- existing.push(config);
- grouped.set(config.config_id, existing);
- });
- return Array.from(grouped.entries()).map(([config_id, versions]) => {
- const sortedVersions = versions.sort((a, b) => b.version - a.version);
- return {
- config_id,
- name: sortedVersions[0].name,
- versions: sortedVersions,
- } as ConfigGroupForDropdown;
- });
- }, [savedConfigs]);
+ // Find currently selected config from loaded set
+ const selectedConfig = savedConfigs.find((c) => c.id === selectedConfigId);
- // Find currently selected config
- const selectedConfig = savedConfigs.find(c => c.id === selectedConfigId);
+ // Config name hint text: use allConfigMeta for accurate new-vs-existing detection
+ const existingConfigForHint = configName.trim()
+ ? allConfigMeta.find((m) => m.name === configName.trim())
+ : undefined;
const handleProviderChange = (newProvider: string) => {
onConfigChange({
@@ -113,13 +147,14 @@ export default function ConfigEditorPane({
provider: newProvider as any,
params: {
...params,
- model: MODEL_OPTIONS[newProvider as keyof typeof MODEL_OPTIONS][0].value,
+ model:
+ MODEL_OPTIONS[newProvider as keyof typeof MODEL_OPTIONS][0].value,
},
},
});
};
- const handleTypeChange = (newType: 'text' | 'stt' | 'tts') => {
+ const handleTypeChange = (newType: "text" | "stt" | "tts") => {
onConfigChange({
...configBlob,
completion: {
@@ -153,8 +188,8 @@ export default function ConfigEditorPane({
const newTools = [
...tools,
{
- type: 'file_search' as const,
- knowledge_base_ids: [''],
+ type: "file_search" as const,
+ knowledge_base_ids: [""],
max_num_results: 20,
},
];
@@ -180,7 +215,7 @@ export default function ConfigEditorPane({
const handleUpdateTool = (index: number, field: keyof Tool, value: any) => {
const newTools = [...tools];
- if (field === 'knowledge_base_ids') {
+ if (field === "knowledge_base_ids") {
newTools[index][field] = [value];
} else {
(newTools[index] as any)[field] = value;
@@ -198,11 +233,11 @@ export default function ConfigEditorPane({
{/* Header */}
@@ -210,15 +245,18 @@ export default function ConfigEditorPane({
className="border-b flex items-center flex-shrink-0"
style={{
borderColor: colors.border,
- padding: collapsed ? '0' : '12px 16px',
- justifyContent: collapsed ? 'center' : 'space-between',
- height: collapsed ? '40px' : 'auto',
- transition: 'padding 0.2s ease-in-out',
+ padding: collapsed ? "0" : "12px 16px",
+ justifyContent: collapsed ? "center" : "space-between",
+ height: collapsed ? "40px" : "auto",
+ transition: "padding 0.2s ease-in-out",
}}
>
{/* Title - hidden when collapsed, shown first when expanded */}
{!collapsed && (
-
+
Configuration
)}
@@ -228,13 +266,13 @@ export default function ConfigEditorPane({
onClick={onToggle}
className="rounded flex-shrink-0 flex items-center justify-center"
style={{
- width: '28px',
- height: '28px',
- borderWidth: '1px',
+ width: "28px",
+ height: "28px",
+ borderWidth: "1px",
borderColor: colors.border,
backgroundColor: colors.bg.primary,
color: colors.text.secondary,
- transition: 'all 0.15s ease',
+ transition: "all 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = colors.bg.secondary;
@@ -244,7 +282,7 @@ export default function ConfigEditorPane({
e.currentTarget.style.backgroundColor = colors.bg.primary;
e.currentTarget.style.color = colors.text.secondary;
}}
- title={collapsed ? 'Show configuration' : 'Hide configuration'}
+ title={collapsed ? "Show configuration" : "Hide configuration"}
>
-
+
)}
@@ -274,8 +317,8 @@ export default function ConfigEditorPane({
Configuration
@@ -285,477 +328,666 @@ export default function ConfigEditorPane({
{/* Content - hidden when collapsed */}
{!collapsed && (
-
-
- {/* Load Saved Config - Nested dropdown matching Evaluations page pattern */}
-
-
- Load Configuration
-
-
- {selectedConfig ? (
-
-
- {selectedConfig.name}
-
- v{selectedConfig.version}
-
-
-
- {selectedConfig.provider}/{selectedConfig.modelName} • {selectedConfig.type}
-
-
- ) : (
- + New Configuration
- )}
-
+
+ {/* Load Saved Config - Nested dropdown matching Evaluations page pattern */}
+
+
-
-
-
-
- {/* Dropdown Menu */}
- {isDropdownOpen && (
-
+
- {/* New Config Option */}
- {
- onLoadConfig('');
- setIsDropdownOpen(false);
- }}
- className="w-full px-3 py-2.5 text-left flex items-center gap-2 transition-colors"
+ {selectedConfig ? (
+
+
+
+ {selectedConfig.name}
+
+
+ v{selectedConfig.version}
+
+
+
+ {selectedConfig.provider}/{selectedConfig.modelName} •{" "}
+ {selectedConfig.type}
+
+
+ ) : (
+
+ + New Configuration
+
+ )}
+ e.currentTarget.style.backgroundColor = colors.bg.secondary}
- onMouseLeave={(e) => e.currentTarget.style.backgroundColor = !selectedConfigId ? colors.bg.secondary : colors.bg.primary}
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
>
-
-
-
- New Configuration
-
+
+
+
- {/* Grouped Configs */}
- {configGroups.map((group) => (
-
- {/* Config group header */}
-
+ {/* New Config Option */}
+ {
+ onLoadConfig(null);
+ setIsDropdownOpen(false);
+ }}
+ className="w-full px-3 py-2.5 text-left flex items-center gap-2 transition-colors"
+ style={{
+ backgroundColor: !selectedConfigId
+ ? colors.bg.secondary
+ : colors.bg.primary,
+ borderBottom: `1px solid ${colors.border}`,
+ }}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.backgroundColor =
+ colors.bg.secondary)
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.backgroundColor = !selectedConfigId
+ ? colors.bg.secondary
+ : colors.bg.primary)
+ }
+ >
+
- {group.name} ({group.versions.length} version{group.versions.length !== 1 ? 's' : ''})
-
- {/* Versions */}
- {group.versions.map((version) => (
-
{
- onLoadConfig(version.id);
- setIsDropdownOpen(false);
- }}
- className="w-full px-4 py-2 text-left flex items-center justify-between transition-colors"
- style={{
- backgroundColor: selectedConfig?.id === version.id ? colors.bg.secondary : colors.bg.primary,
- }}
- onMouseEnter={(e) => e.currentTarget.style.backgroundColor = colors.bg.secondary}
- onMouseLeave={(e) => {
- e.currentTarget.style.backgroundColor = selectedConfig?.id === version.id
- ? colors.bg.secondary
- : colors.bg.primary;
- }}
- >
-
-
-
+
+
+ New Configuration
+
+
+
+ {/* Grouped Configs — built from lightweight allConfigMeta */}
+ {allConfigMeta.map((meta) => {
+ const isExpanded = expandedConfigId === meta.id;
+ const isLoadingGroup = loadingVersionsFor.has(meta.id);
+ const items = versionItemsMap[meta.id] ?? [];
+ return (
+
+ {/* Config group header */}
+
handleToggleGroup(meta.id)}
+ >
+
+ {meta.name}
+ {items.length > 0 && (
+
+ ({items.length})
+
+ )}
+
+
+ {isLoadingGroup && (
+
+
+
+
+ )}
+
- v{version.version}
-
-
- {version.commit_message || 'No message'}
-
-
-
- {version.provider}/{version.modelName} • {formatRelativeTime(version.timestamp)}
+
+
+
+
+ {/* Version items — only when expanded */}
+ {isExpanded &&
+ !isLoadingGroup &&
+ items.map((item) => {
+ const isSelected =
+ selectedConfig?.config_id === meta.id &&
+ selectedConfig?.version === item.version;
+ return (
+
{
+ const full = savedConfigs.find(
+ (c) =>
+ c.config_id === item.config_id &&
+ c.version === item.version,
+ );
+ const config =
+ full ??
+ (loadSingleVersion
+ ? await loadSingleVersion(
+ item.config_id,
+ item.version,
+ )
+ : null);
+ if (config) {
+ onLoadConfig(config);
+ setIsDropdownOpen(false);
+ }
+ }}
+ className="w-full px-4 py-2 text-left flex items-center justify-between transition-colors"
+ style={{
+ backgroundColor: isSelected
+ ? colors.bg.secondary
+ : colors.bg.primary,
+ }}
+ onMouseEnter={(e) =>
+ (e.currentTarget.style.backgroundColor =
+ colors.bg.secondary)
+ }
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor =
+ isSelected
+ ? colors.bg.secondary
+ : colors.bg.primary;
+ }}
+ >
+
+
+
+ v{item.version}
+
+
+ {item.commit_message || "No message"}
+
+
+
+ {formatRelativeTime(item.inserted_at)}
+
+
+ {isSelected && (
+
+
+
+ )}
+
+ );
+ })}
+ {isExpanded && isLoadingGroup && (
+
+ Loading versions…
-
- {selectedConfig?.id === version.id && (
-
-
-
)}
-
- ))}
-
- ))}
-
- )}
+
+ );
+ })}
+
+ )}
- {/* Click outside to close dropdown */}
- {isDropdownOpen && (
- setIsDropdownOpen(false)}
- />
- )}
-
+ {/* Click outside to close dropdown */}
+ {isDropdownOpen && (
+ setIsDropdownOpen(false)}
+ />
+ )}
+
- {/* Config Name */}
-
-
- Configuration Name
-
-
onConfigNameChange(e.target.value)}
- placeholder="e.g., my-config"
- className="w-full px-3 py-2 rounded-md text-sm focus:outline-none"
- style={{
- border: `1px solid ${colors.border}`,
- backgroundColor: colors.bg.primary,
- color: colors.text.primary,
- }}
- />
- {configName.trim() && (() => {
- const existingConfig = savedConfigs.find(c => c.name === configName.trim());
- return (
-
- {existingConfig
+ {/* Config Name */}
+
+
+ Configuration Name
+
+
onConfigNameChange(e.target.value)}
+ placeholder="e.g., my-config"
+ className="w-full px-3 py-2 rounded-md text-sm focus:outline-none"
+ style={{
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.bg.primary,
+ color: colors.text.primary,
+ }}
+ />
+ {configName.trim() && (
+
+ {existingConfigForHint
? `💡 Will create a new version for "${configName}"`
- : `✨ Will create a new config "${configName}"`
- }
+ : `✨ Will create a new config "${configName}"`}
- );
- })()}
-
-
- {/* Provider */}
-
-
- Provider
-
- handleProviderChange(e.target.value)}
- className="w-full px-3 py-2 rounded-md text-sm focus:outline-none"
- style={{
- border: `1px solid ${colors.border}`,
- backgroundColor: colors.bg.primary,
- color: colors.text.primary,
- }}
- >
- OpenAI
- {/* Anthropic */}
- {/* Google */}
-
-
-
- {/* Type */}
-
-
- Type
-
-
handleTypeChange(e.target.value as 'text' | 'stt' | 'tts')}
- className="w-full px-3 py-2 rounded-md text-sm focus:outline-none"
- style={{
- border: `1px solid ${colors.border}`,
- backgroundColor: colors.bg.primary,
- color: colors.text.primary,
- }}
- >
- Text Completion
- Speech-to-Text (Coming Soon)
- Text-to-Speech (Coming Soon)
-
-
- Standard text-based LLM completion
-
-
-
- {/* Model */}
-
-
- Model
-
- handleModelChange(e.target.value)}
- className="w-full px-3 py-2 rounded-md text-sm focus:outline-none"
- style={{
- border: `1px solid ${colors.border}`,
- backgroundColor: colors.bg.primary,
- color: colors.text.primary,
- }}
- >
- {MODEL_OPTIONS[provider as keyof typeof MODEL_OPTIONS].map((model) => (
-
- {model.label}
-
- ))}
-
-
+ )}
+
- {/* Temperature */}
-
-
- Temperature: {(params.temperature ?? 0.7).toFixed(2)}
-
-
handleTemperatureChange(parseFloat(e.target.value))}
- className="w-full"
- style={{ accentColor: colors.accent.primary }}
- />
-
-
0
-
2
+ {/* Provider */}
+
+
+ Provider
+
+ handleProviderChange(e.target.value)}
+ className="w-full px-3 py-2 rounded-md text-sm focus:outline-none"
+ style={{
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.bg.primary,
+ color: colors.text.primary,
+ }}
+ >
+ {PROVIDES_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
-
- {/* Tools */}
-
-
+ {/* Type */}
+
- Tools
+ Type
-
+ handleTypeChange(e.target.value as "text" | "stt" | "tts")
+ }
+ className="w-full px-3 py-2 rounded-md text-sm focus:outline-none"
style={{
- backgroundColor: colors.accent.primary,
- color: '#ffffff',
- border: 'none',
- cursor: 'pointer',
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.bg.primary,
+ color: colors.text.primary,
}}
>
- + Add Tool
-
+ {PROVIDER_TYPES.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ Standard text-based LLM completion
+
- {tools.map((tool, index) => (
-
+
+ Model
+
+
handleModelChange(e.target.value)}
+ className="w-full px-3 py-2 rounded-md text-sm focus:outline-none"
style={{
border: `1px solid ${colors.border}`,
- backgroundColor: colors.bg.secondary,
+ backgroundColor: colors.bg.primary,
+ color: colors.text.primary,
}}
>
-
- File Search
- handleRemoveTool(index)}
- className="text-xs"
- style={{
- background: 'none',
- border: 'none',
- color: colors.status.error,
- cursor: 'pointer',
- }}
- >
- Remove
-
-
-
-
- Knowledge Base ID
-
-
- handleUpdateTool(index, 'knowledge_base_ids', e.target.value)
- }
- placeholder="vs_abc123"
- className="w-full px-2 py-1 rounded text-xs focus:outline-none"
- style={{
- border: `1px solid ${colors.border}`,
- backgroundColor: colors.bg.primary,
- color: colors.text.primary,
- }}
- />
-
-
-
- (
+
+ {model.label}
+
+ ))}
+
+
+
+ {/* Temperature */}
+
+
+ Temperature: {(params.temperature ?? 0.7).toFixed(2)}
+
+
+ handleTemperatureChange(parseFloat(e.target.value))
+ }
+ className="w-full"
+ style={{ accentColor: colors.accent.primary }}
+ />
+
+ 0
+ 2
+
+
+
+ {/* Tools */}
+
+
+
+ Tools
+
+
+ + Add Tool
+
+
+ {tools.map((tool, index) => (
+
+
+
+ File Search
+
+ handleRemoveTool(index)}
className="text-xs"
+ style={{
+ background: "none",
+ border: "none",
+ color: colors.status.error,
+ cursor: "pointer",
+ }}
+ >
+ Remove
+
+
+
+
- Max Results
+ Knowledge Base ID
-
setShowTooltip(index)}
- onMouseLeave={() => setShowTooltip(null)}
- >
-
+ handleUpdateTool(
+ index,
+ "knowledge_base_ids",
+ e.target.value,
+ )
+ }
+ placeholder="vs_abc123"
+ className="w-full px-2 py-1 rounded text-xs focus:outline-none"
+ style={{
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.bg.primary,
+ color: colors.text.primary,
+ }}
+ />
+
+
+
+
-
-
- {showTooltip === index && (
-
+
setShowTooltip(index)}
+ onMouseLeave={() => setShowTooltip(null)}
+ >
+
- Controls how many matching results are returned from the search
+
+
+ {showTooltip === index && (
-
- )}
+ >
+ Controls how many matching results are returned
+
+ from the search
+
+
+ )}
+
+
+ handleUpdateTool(
+ index,
+ "max_num_results",
+ parseInt(e.target.value) || 20,
+ )
+ }
+ className="w-full px-2 py-1 rounded text-xs focus:outline-none"
+ style={{
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.bg.primary,
+ color: colors.text.primary,
+ }}
+ />
-
- handleUpdateTool(
- index,
- 'max_num_results',
- parseInt(e.target.value) || 20
- )
- }
- className="w-full px-2 py-1 rounded text-xs focus:outline-none"
- style={{
- border: `1px solid ${colors.border}`,
- backgroundColor: colors.bg.primary,
- color: colors.text.primary,
- }}
- />
-
- ))}
-
+ ))}
+
- {/* Commit Message */}
-
-
- Commit Message (Optional)
-
-
onCommitMessageChange(e.target.value)}
- placeholder="Describe your changes..."
- className="w-full px-3 py-2 rounded-md text-sm focus:outline-none"
+ {/* Commit Message */}
+
+
+ Commit Message (Optional)
+
+ onCommitMessageChange(e.target.value)}
+ placeholder="Describe your changes..."
+ className="w-full px-3 py-2 rounded-md text-sm focus:outline-none"
+ style={{
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.bg.primary,
+ color: colors.text.primary,
+ }}
+ />
+
+
+ {/* Save Button */}
+
+ >
+ {isSaving ? "Saving..." : "Save Configuration"}
+
-
- {/* Save Button */}
-
- {isSaving ? 'Saving...' : 'Save Configuration'}
-
-
)}
);
diff --git a/app/components/prompt-editor/DiffView.tsx b/app/components/prompt-editor/DiffView.tsx
index aefaf60..0b5b801 100644
--- a/app/components/prompt-editor/DiffView.tsx
+++ b/app/components/prompt-editor/DiffView.tsx
@@ -10,7 +10,7 @@ interface DiffViewProps {
compareWith: SavedConfig | null;
commits: SavedConfig[];
onCompareChange: (commit: SavedConfig | null) => void;
- onLoadVersion: (versionId: string) => void;
+ onLoadVersion: (config: SavedConfig) => void;
/** Lazily loads the lightweight version list for a given config_id (1 call or no-op) */
loadVersionsForConfig?: (config_id: string) => Promise;
/**
@@ -166,7 +166,7 @@ export default function DiffView({
{compareWith && (
onLoadVersion(compareWith.id)}
+ onClick={() => onLoadVersion(compareWith)}
className="px-3 py-1.5 rounded text-xs font-medium"
style={{
backgroundColor: colors.bg.secondary,
@@ -192,7 +192,7 @@ export default function DiffView({
}
onLoadVersion(selectedCommit.id)}
+ onClick={() => onLoadVersion(selectedCommit)}
className="px-3 py-1.5 rounded text-xs font-medium"
style={{
backgroundColor: colors.accent.primary,
diff --git a/app/components/prompt-editor/HistorySidebar.tsx b/app/components/prompt-editor/HistorySidebar.tsx
index c3d5cd9..b49f05d 100644
--- a/app/components/prompt-editor/HistorySidebar.tsx
+++ b/app/components/prompt-editor/HistorySidebar.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { colors } from '@/app/lib/colors';
import { SavedConfig, ConfigVersionItems } from '@/app/lib/types/configs';
@@ -42,6 +42,16 @@ export default function HistorySidebar({
/** Which version number is currently being fetched on-demand (for loading indicator) */
const [fetchingVersion, setFetchingVersion] = useState(null);
+ // Auto-expand the current config's group when arriving from URL params
+ useEffect(() => {
+ if (currentConfigId) {
+ setExpandedConfigs(prev => {
+ if (prev.has(currentConfigId)) return prev;
+ return new Set([...prev, currentConfigId]);
+ });
+ }
+ }, [currentConfigId]);
+
// Toggle expand/collapse
const toggleExpand = (configName: string) => {
const newExpanded = new Set(expandedConfigs);
@@ -236,7 +246,9 @@ export default function HistorySidebar({
{currentConfigId && sortedVersionItems ? (() => {
// Find the config name from loaded data (latest version is always loaded)
const configName = savedConfigs.find(c => c.config_id === currentConfigId)?.name ?? '';
- const isExpanded = expandedConfigs.has(configName || currentConfigId);
+ // Always key on currentConfigId (stable) — configName is not known immediately
+ // with pageSize:0 and would cause the expanded key to flip once savedConfigs loads.
+ const isExpanded = expandedConfigs.has(currentConfigId);
return (
{/* Config header */}
toggleExpand(configName || currentConfigId)}
+ onClick={() => toggleExpand(currentConfigId)}
className="p-3 cursor-pointer"
style={{ backgroundColor: colors.bg.secondary, transition: 'all 0.15s ease' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
diff --git a/app/configurations/prompt-editor/page.tsx b/app/configurations/prompt-editor/page.tsx
index bf55003..dde07fc 100644
--- a/app/configurations/prompt-editor/page.tsx
+++ b/app/configurations/prompt-editor/page.tsx
@@ -7,27 +7,27 @@
* Supports URL query params for cross-navigation from Config Library/Evaluations.
*/
-"use client"
-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 './types';
-import { hasConfigChanges } from './utils';
-import {
- ConfigCreate,
- ConfigVersionCreate,
-} from '@/app/lib/configTypes';
-import Header from '@/app/components/prompt-editor/Header';
-import HistorySidebar from '@/app/components/prompt-editor/HistorySidebar';
-import PromptEditorPane from '@/app/components/prompt-editor/PromptEditorPane';
-import ConfigEditorPane from '@/app/components/prompt-editor/ConfigEditorPane';
-import DiffView from '@/app/components/prompt-editor/DiffView';
-import { useToast } from '@/app/components/Toast';
-import Loader from '@/app/components/Loader';
-import { useConfigs } from '@/app/hooks/useConfigs';
-import { SavedConfig } from '@/app/lib/types/configs';
-import { invalidateConfigCache } from '@/app/lib/utils';
+"use client";
+
+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 "./types";
+import { hasConfigChanges } from "./utils";
+import { ConfigCreate, ConfigVersionCreate } from "@/app/lib/configTypes";
+import Header from "@/app/components/prompt-editor/Header";
+import HistorySidebar from "@/app/components/prompt-editor/HistorySidebar";
+import PromptEditorPane from "@/app/components/prompt-editor/PromptEditorPane";
+import ConfigEditorPane from "@/app/components/prompt-editor/ConfigEditorPane";
+import DiffView from "@/app/components/prompt-editor/DiffView";
+import { useToast } from "@/app/components/Toast";
+import Loader from "@/app/components/Loader";
+import { useConfigs } from "@/app/hooks/useConfigs";
+import { SavedConfig } from "@/app/lib/types/configs";
+import { getApiKey, invalidateConfigCache } from "@/app/lib/utils";
+import { configState } from "@/app/lib/store/configStore";
+import { apiFetch } from "@/app/lib/apiClient";
function PromptEditorContent() {
const toast = useToast();
@@ -35,141 +35,202 @@ function PromptEditorContent() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// URL query params for cross-navigation
- const urlConfigId = searchParams.get('config');
- const urlVersion = searchParams.get('version');
- const showHistory = searchParams.get('history') === 'true';
- const isNewConfig = searchParams.get('new') === 'true';
+ const urlConfigId = searchParams.get("config");
+ const urlVersion = searchParams.get("version");
+ const showHistory = searchParams.get("history") === "true";
+ const isNewConfig = searchParams.get("new") === "true";
// Evaluation context to preserve (when coming from evaluations page)
- const urlDatasetId = searchParams.get('dataset');
- const urlExperimentName = searchParams.get('experiment');
- const fromEvaluations = searchParams.get('from') === 'evaluations';
+ const urlDatasetId = searchParams.get("dataset");
+ const urlExperimentName = searchParams.get("experiment");
+ const fromEvaluations = searchParams.get("from") === "evaluations";
// Default config for new versions
const defaultConfig: ConfigBlob = {
completion: {
- provider: 'openai',
- type: 'text',
+ provider: "openai",
+ type: "text",
params: {
- model: 'gpt-4o-mini',
- instructions: '',
+ model: "gpt-4o-mini",
+ instructions: "",
temperature: 0.7,
tools: [],
},
},
};
- // Use shared configs hook with caching
- const { configs: savedConfigs, isLoading, refetch: refetchConfigs, loadVersionsForConfig, loadSingleVersion, versionItemsMap } = useConfigs();
+ // Use shared configs hook with caching — pageSize:0 means only 1 API call on mount
+ const {
+ configs: savedConfigs,
+ isLoading,
+ refetch: refetchConfigs,
+ loadVersionsForConfig,
+ loadSingleVersion,
+ versionItemsMap: hookVersionItemsMap,
+ allConfigMeta,
+ } = useConfigs({ pageSize: 0 });
const [isSaving, setIsSaving] = useState
(false);
- const initialLoadComplete = !isLoading && savedConfigs.length >= 0;
+ const initialLoadComplete = !isLoading;
+ const editorInitialized = React.useRef(false);
+ // False while we are still fetching the URL-specified config — prevents the flash
+ // of empty/default state that would otherwise show before applyConfig() is called.
+ const [editorReady, setEditorReady] = useState(!urlConfigId);
+
+ // Stable version items map that only grows — never wiped by background refetches.
+ // Background revalidation clears configState.versionItemsCache then re-fetches;
+ // we merge the hook's map into our local copy so already-loaded entries survive.
+ const [stableVersionItemsMap, setStableVersionItemsMap] = useState<
+ Record
+ >({});
+
+ useEffect(() => {
+ if (Object.keys(hookVersionItemsMap).length > 0) {
+ setStableVersionItemsMap((prev) => ({ ...prev, ...hookVersionItemsMap }));
+ }
+ }, [hookVersionItemsMap]);
+
+ const versionItemsMap = stableVersionItemsMap;
// Current working state
- const [currentContent, setCurrentContent] = useState('You are a helpful AI assistant.\nYou provide clear and concise answers.\nYou are polite and professional.');
- const [currentConfigBlob, setCurrentConfigBlob] = useState(defaultConfig);
- const [currentConfigName, setCurrentConfigName] = useState('');
- const [selectedConfigId, setSelectedConfigId] = useState(''); // Selected version ID
- const [currentConfigParentId, setCurrentConfigParentId] = useState(''); // Parent config ID for evaluation
+ const [currentContent, setCurrentContent] = useState(
+ "You are a helpful AI assistant.\nYou provide clear and concise answers.\nYou are polite and professional.",
+ );
+ const [currentConfigBlob, setCurrentConfigBlob] =
+ useState(defaultConfig);
+ const [currentConfigName, setCurrentConfigName] = useState("");
+ const [selectedConfigId, setSelectedConfigId] = useState(""); // Selected version ID
+ const [currentConfigParentId, setCurrentConfigParentId] =
+ useState(""); // Parent config ID for evaluation
const [currentConfigVersion, setCurrentConfigVersion] = useState(0); // Version number for evaluation
- const [provider, setProvider] = useState('openai');
+ const [provider, setProvider] = useState("openai");
const [temperature, setTemperature] = useState(0.7);
const [tools, setTools] = useState([]);
// UI state
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
- const [commitMessage, setCommitMessage] = useState('');
+ const [commitMessage, setCommitMessage] = useState("");
const [showHistorySidebar, setShowHistorySidebar] = useState(true); // Default open, or from URL param
const [showConfigPane, setShowConfigPane] = useState(true); // Config pane collapse state
// History viewing state
- const [selectedVersion, setSelectedVersion] = useState(null);
+ const [selectedVersion, setSelectedVersion] = useState(
+ null,
+ );
const [compareWith, setCompareWith] = useState(null);
- // Get API key from localStorage
- const getApiKey = (): string | null => {
- try {
- const stored = localStorage.getItem('kaapi_api_keys');
- if (stored) {
- const keys = JSON.parse(stored);
- return keys.length > 0 ? keys[0].key : null;
+ // Populate the editor from a fully-loaded SavedConfig
+ const applyConfig = React.useCallback(
+ (config: SavedConfig, selectInHistory?: boolean) => {
+ setCurrentContent(config.promptContent);
+ setCurrentConfigBlob({
+ completion: {
+ provider: config.provider as any,
+ type: config.type,
+ params: {
+ model: config.modelName,
+ instructions: config.instructions,
+ temperature: config.temperature,
+ tools: config.tools || [],
+ },
+ },
+ });
+ setProvider(config.provider);
+ setTemperature(config.temperature);
+ setSelectedConfigId(config.id);
+ setCurrentConfigName(config.name);
+ setCurrentConfigParentId(config.config_id);
+ setCurrentConfigVersion(config.version);
+ setTools(config.tools || []);
+ if (selectInHistory) setSelectedVersion(config);
+ },
+ [],
+ );
+
+ // Load a config directly from a SavedConfig object (no savedConfigs lookup needed)
+ const handleLoadConfig = React.useCallback(
+ (config: SavedConfig | null) => {
+ if (!config) {
+ // Reset to new config
+ setCurrentContent("");
+ setCurrentConfigBlob(defaultConfig);
+ setProvider("openai");
+ setTemperature(0.7);
+ setSelectedConfigId("");
+ setCurrentConfigName("");
+ setCurrentConfigParentId("");
+ setCurrentConfigVersion(0);
+ setTools([]);
+ return;
}
- } catch (e) {
- console.error('Failed to get API key:', e);
- }
- return null;
- };
+ // Load the lightweight version list for the history sidebar (1 call or no-op if cached)
+ loadVersionsForConfig(config.config_id);
+ applyConfig(config);
+ },
+ [applyConfig, loadVersionsForConfig],
+ );
- // Handle URL query params after configs are loaded
+ // Initialize editor from URL params — runs once, on first load completion
useEffect(() => {
- if (!initialLoadComplete || savedConfigs.length === 0) return;
+ if (!initialLoadComplete) return;
+ if (editorInitialized.current) return;
+ editorInitialized.current = true;
// If new config is requested, reset to defaults
if (isNewConfig) {
- setCurrentContent('');
+ setCurrentContent("");
setCurrentConfigBlob(defaultConfig);
- setProvider('openai');
+ setProvider("openai");
setTemperature(0.7);
- setSelectedConfigId('');
- setCurrentConfigName('');
- setCurrentConfigParentId('');
+ setSelectedConfigId("");
+ setCurrentConfigName("");
+ setCurrentConfigParentId("");
setCurrentConfigVersion(0);
setTools([]);
+ setEditorReady(true);
return;
}
- // If a specific config/version is requested via URL params
- if (urlConfigId) {
- // Eagerly load all versions for this config so history sidebar and version
- // picker are fully populated (only the latest version is fetched on initial load)
- loadVersionsForConfig(urlConfigId);
+ if (!urlConfigId) {
+ setEditorReady(true);
+ return;
+ }
- // Find the config by config_id and optionally version
- let targetConfig: SavedConfig | undefined;
+ (async () => {
+ // Load version list for history sidebar (1 call, cached on subsequent runs)
+ await loadVersionsForConfig(urlConfigId);
- if (urlVersion) {
- // Find specific version
- targetConfig = savedConfigs.find(
- c => c.config_id === urlConfigId && c.version === parseInt(urlVersion)
- );
- } else {
- // Find latest version for this config
- const configVersions = savedConfigs.filter(c => c.config_id === urlConfigId);
- if (configVersions.length > 0) {
- targetConfig = configVersions.reduce((latest, current) =>
- current.version > latest.version ? current : latest
- );
- }
+ const items = configState.versionItemsCache[urlConfigId] ?? [];
+ if (items.length === 0) {
+ setEditorReady(true);
+ return;
}
- if (targetConfig) {
- // Load the config
- setCurrentContent(targetConfig.promptContent);
- setCurrentConfigBlob({
- completion: {
- provider: targetConfig.provider as any,
- params: {
- model: targetConfig.modelName,
- instructions: targetConfig.instructions,
- temperature: targetConfig.temperature,
- tools: targetConfig.tools || [],
- },
- },
- });
- setProvider(targetConfig.provider);
- setTemperature(targetConfig.temperature);
- setSelectedConfigId(targetConfig.id);
- setCurrentConfigName(targetConfig.name);
- setCurrentConfigParentId(targetConfig.config_id);
- setCurrentConfigVersion(targetConfig.version);
- setTools(targetConfig.tools || []);
-
- // If history is requested, show the history sidebar with this version selected
- if (showHistory) {
- setSelectedVersion(targetConfig);
- }
- }
+ // Resolve the target version number (latest if no specific version requested)
+ const versionNum = urlVersion
+ ? parseInt(urlVersion)
+ : items.reduce((a, b) => (b.version > a.version ? b : a)).version;
+
+ const config = await loadSingleVersion(urlConfigId, versionNum);
+ if (config) applyConfig(config, showHistory);
+ setEditorReady(true);
+ })();
+ }, [
+ initialLoadComplete,
+ urlConfigId,
+ urlVersion,
+ showHistory,
+ isNewConfig,
+ loadVersionsForConfig,
+ loadSingleVersion,
+ applyConfig,
+ ]);
+
+ // Re-populate version items when missing (e.g. after background cache revalidation wipes versionItemsCache)
+ useEffect(() => {
+ if (currentConfigParentId && !versionItemsMap[currentConfigParentId]) {
+ loadVersionsForConfig(currentConfigParentId);
}
- }, [initialLoadComplete, savedConfigs, urlConfigId, urlVersion, showHistory, isNewConfig, loadVersionsForConfig]);
+ }, [currentConfigParentId, versionItemsMap, loadVersionsForConfig]);
// Detect unsaved changes
useEffect(() => {
@@ -179,7 +240,7 @@ function PromptEditorContent() {
return;
}
- const selectedConfig = savedConfigs.find(c => c.id === selectedConfigId);
+ const selectedConfig = savedConfigs.find((c) => c.id === selectedConfigId);
if (!selectedConfig) {
setHasUnsavedChanges(true);
return;
@@ -200,18 +261,26 @@ function PromptEditorContent() {
});
setHasUnsavedChanges(promptChanged || configChanged);
- }, [selectedConfigId, currentContent, currentConfigBlob, provider, temperature, tools, savedConfigs]);
+ }, [
+ selectedConfigId,
+ currentContent,
+ currentConfigBlob,
+ provider,
+ temperature,
+ tools,
+ savedConfigs,
+ ]);
// Save current configuration
const handleSaveConfig = async () => {
if (!currentConfigName.trim()) {
- toast.error('Please enter a configuration name');
+ toast.error("Please enter a configuration name");
return;
}
const apiKey = getApiKey();
if (!apiKey) {
- toast.error('No API key found. Please add an API key in the Keystore.');
+ toast.error("No API key found. Please add an API key in the Keystore.");
return;
}
@@ -238,7 +307,7 @@ function PromptEditorContent() {
const configBlob: ConfigBlob = {
completion: {
provider: currentConfigBlob.completion.provider,
- type: currentConfigBlob.completion.type || 'text', // Default to 'text'
+ type: currentConfigBlob.completion.type || "text", // Default to 'text'
params: {
model: currentConfigBlob.completion.params.model,
instructions: currentContent, // Store prompt as instructions
@@ -252,59 +321,65 @@ function PromptEditorContent() {
},
};
- // Check if updating existing config (same name exists)
- const existingConfig = savedConfigs.find(c => c.name === currentConfigName.trim());
+ // Check if updating existing config (same name exists) using allConfigMeta
+ const existingConfigMeta = allConfigMeta.find(
+ (m) => m.name === currentConfigName.trim(),
+ );
- if (existingConfig) {
+ if (existingConfigMeta) {
// Create new version for existing config
const versionCreate: ConfigVersionCreate = {
config_blob: configBlob,
commit_message: commitMessage.trim() || `Updated prompt and config`,
};
- const response = await fetch(`/api/configs/${existingConfig.config_id}/versions`, {
- method: 'POST',
- headers: {
- 'X-API-KEY': apiKey,
- 'Content-Type': 'application/json',
+ const data = await apiFetch<{ success: boolean; error?: string }>(
+ `/api/configs/${existingConfigMeta.id}/versions`,
+ apiKey,
+ {
+ method: "POST",
+ body: JSON.stringify(versionCreate),
},
- body: JSON.stringify(versionCreate),
- });
-
- const data = await response.json();
+ );
if (!data.success) {
- toast.error(`Failed to create version: ${data.error || 'Unknown error'}`);
+ toast.error(
+ `Failed to create version: ${data.error || "Unknown error"}`,
+ );
return;
}
- toast.success(`Configuration "${currentConfigName}" updated! New version created.`);
+ toast.success(
+ `Configuration "${currentConfigName}" updated! New version created.`,
+ );
} else {
// Create new config
const configCreate: ConfigCreate = {
name: currentConfigName.trim(),
description: `${provider} configuration with prompt`,
config_blob: configBlob,
- commit_message: commitMessage.trim() || 'Initial version',
+ commit_message: commitMessage.trim() || "Initial version",
};
- const response = await fetch('/api/configs', {
- method: 'POST',
- headers: {
- 'X-API-KEY': apiKey,
- 'Content-Type': 'application/json',
- },
+ const data = await apiFetch<{
+ success: boolean;
+ data?: unknown;
+ error?: string;
+ }>("/api/configs", apiKey, {
+ method: "POST",
body: JSON.stringify(configCreate),
});
- const data = await response.json();
-
if (!data.success || !data.data) {
- toast.error(`Failed to create config: ${data.error || 'Unknown error'}`);
+ toast.error(
+ `Failed to create config: ${data.error || "Unknown error"}`,
+ );
return;
}
- toast.success(`Configuration "${currentConfigName}" created successfully!`);
+ toast.success(
+ `Configuration "${currentConfigName}" created successfully!`,
+ );
}
// Invalidate config cache and refresh from shared hook
@@ -313,64 +388,25 @@ function PromptEditorContent() {
// Reset unsaved changes flag and commit message after successful save
setHasUnsavedChanges(false);
- setCommitMessage('');
+ setCommitMessage("");
} catch (e) {
- console.error('Failed to save config:', e);
- toast.error('Failed to save configuration. Please try again.');
+ console.error("Failed to save config:", e);
+ toast.error("Failed to save configuration. Please try again.");
} finally {
setIsSaving(false);
}
};
- // Load a saved configuration by ID
- const handleLoadConfigById = (configId: string) => {
- if (!configId) {
- // Reset to new config
- setCurrentContent('');
- setCurrentConfigBlob(defaultConfig);
- setProvider('openai');
- setTemperature(0.7);
- setSelectedConfigId('');
- setCurrentConfigName('');
- setCurrentConfigParentId('');
- setCurrentConfigVersion(0);
- setTools([]);
- return;
- }
-
- const config = savedConfigs.find(c => c.id === configId);
- if (!config) return;
-
- // Lazily load all versions for this config (history sidebar needs them)
- loadVersionsForConfig(config.config_id);
-
- setCurrentContent(config.promptContent);
- setCurrentConfigBlob({
- completion: {
- provider: config.provider as any,
- type: config.type,
- params: {
- model: config.modelName,
- instructions: config.instructions,
- temperature: config.temperature,
- tools: config.tools || [],
- },
- },
- });
- setProvider(config.provider);
- setTemperature(config.temperature);
- setSelectedConfigId(config.id);
- setCurrentConfigName(config.name);
- setCurrentConfigParentId(config.config_id);
- setCurrentConfigVersion(config.version);
- setTools(config.tools || []);
- };
-
-
return (
-
+
-
+
-
setShowHistorySidebar(!showHistorySidebar)}
- onSelectVersion={(version) => {
- setSelectedVersion(version);
- setCompareWith(null);
- }}
- onLoadVersion={(version) => {
- handleLoadConfigById(version.id);
- }}
- onBackToEditor={() => {
- setSelectedVersion(null);
- setCompareWith(null);
- }}
- isLoading={isLoading}
- versionItems={currentConfigParentId ? (versionItemsMap[currentConfigParentId] ?? []) : undefined}
- onFetchVersionDetail={currentConfigParentId
- ? (version) => loadSingleVersion(currentConfigParentId, version)
- : undefined
- }
- />
-
- {/* Show DiffView only when comparing versions (sidebar open + version selected) */}
- {showHistorySidebar && selectedVersion ? (
- {
- handleLoadConfigById(versionId);
- setSelectedVersion(null);
- setCompareWith(null);
- }}
- />
- ) : (
-
- {/* Split View: Prompt (left) + Config (right) */}
-
+ {!editorReady ? (
+
+
-
setShowConfigPane(!showConfigPane)}
- loadVersionsForConfig={loadVersionsForConfig}
/>
+
+ Loading configuration...
+
+ ) : (
+ <>
+
setShowHistorySidebar(!showHistorySidebar)}
+ onSelectVersion={(version) => {
+ setSelectedVersion(version);
+ setCompareWith(null);
+ }}
+ onLoadVersion={(version) => {
+ handleLoadConfig(version);
+ }}
+ onBackToEditor={() => {
+ setSelectedVersion(null);
+ setCompareWith(null);
+ }}
+ isLoading={!editorReady}
+ versionItems={
+ currentConfigParentId
+ ? (versionItemsMap[currentConfigParentId] ?? [])
+ : undefined
+ }
+ onFetchVersionDetail={
+ currentConfigParentId
+ ? (version) =>
+ loadSingleVersion(currentConfigParentId, version)
+ : undefined
+ }
+ />
+
+ {/* Show DiffView only when comparing versions (sidebar open + version selected) */}
+ {showHistorySidebar && selectedVersion ? (
+ {
+ handleLoadConfig(config);
+ setSelectedVersion(null);
+ setCompareWith(null);
+ }}
+ />
+ ) : (
+
+ {/* Split View: Prompt (left) + Config (right) */}
+
+
+
setShowConfigPane(!showConfigPane)}
+ />
+
+
+ )}
+ >
)}
@@ -470,7 +542,6 @@ function PromptEditorContent() {
);
}
-// Wrapper component with Suspense for useSearchParams
export default function PromptEditorPage() {
return (
}>
diff --git a/app/hooks/useConfigs.ts b/app/hooks/useConfigs.ts
index e9c3c02..f39202f 100644
--- a/app/hooks/useConfigs.ts
+++ b/app/hooks/useConfigs.ts
@@ -51,14 +51,14 @@ export interface UseConfigsResult {
hasMoreConfigs: boolean;
/** True while loadMoreConfigs is in progress */
isLoadingMore: boolean;
- /**
- * Fetches the full details (config_blob) for a single version on demand.
+ /** Fetches the full details (config_blob) for a single version on demand.
* Returns the SavedConfig immediately if already loaded; makes 1 GET call otherwise.
* Safe to call concurrently – duplicate in-flight requests are coalesced.
*/
loadSingleVersion: (config_id: string, version: number) => Promise;
/** Lightweight version items per config, indexed by config_id. */
versionItemsMap: Record;
+ allConfigMeta: ConfigPublic[]; // Full lightweight config list from GET /api/configs.
}
export function useConfigs(options?: { pageSize?: number }): UseConfigsResult {
@@ -66,6 +66,7 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult {
const [configs, setConfigs] = useState([]);
const [versionCounts, setVersionCounts] = useState>({});
const [versionItemsMap, setVersionItemsMap] = useState>({});
+ const [allConfigMeta, setAllConfigMeta] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState(null);
@@ -94,6 +95,11 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult {
setTotalKnownCount(
configState.inMemoryCache.totalConfigCount ?? configState.inMemoryCache.configs.length,
);
+ // Restore allConfigMeta from cache or derive from loaded configs as fallback
+ if (!configState.allConfigMeta) {
+ configState.allConfigMeta = configState.inMemoryCache.allConfigMeta ?? null;
+ }
+ setAllConfigMeta(configState.allConfigMeta ?? []);
setIsCached(true);
setIsLoading(false);
scheduleBackgroundValidation(configState.inMemoryCache, apiKey);
@@ -115,6 +121,11 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult {
// populate it on demand (1 GET /versions call) when a config is opened.
setVersionItemsMap({ ...configState.versionItemsCache });
setTotalKnownCount(lsCache.totalConfigCount ?? lsCache.configs.length);
+ // Restore allConfigMeta from localStorage cache if not already in module state
+ if (!configState.allConfigMeta && lsCache.allConfigMeta) {
+ configState.allConfigMeta = lsCache.allConfigMeta;
+ }
+ setAllConfigMeta(configState.allConfigMeta ?? []);
setIsCached(true);
setIsLoading(false);
configState.inMemoryCache = lsCache;
@@ -136,6 +147,7 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult {
setTotalKnownCount(
configState.inMemoryCache.totalConfigCount ?? configState.inMemoryCache.configs.length,
);
+ setAllConfigMeta(configState.allConfigMeta ?? []);
setIsCached(false);
} else {
setError('Failed to load configurations. Please try again.');
@@ -158,6 +170,7 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult {
versionCounts: result.versionCounts,
totalConfigCount: result.totalConfigCount,
partialFetch: result.partialFetch,
+ allConfigMeta: configState.allConfigMeta ?? [],
};
saveCache(newCache);
configState.inMemoryCache = newCache;
@@ -165,6 +178,7 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult {
setVersionCounts(result.versionCounts);
setVersionItemsMap({ ...configState.versionItemsCache });
setTotalKnownCount(result.totalConfigCount);
+ setAllConfigMeta(configState.allConfigMeta ?? []);
})().finally(() => {
configState.pendingFetch = null;
});
@@ -249,16 +263,13 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult {
if (!apiKey) return null;
const configSource = configs.find(c => c.config_id === config_id);
- if (!configSource) return null;
+ // Fall back to the lightweight allConfigMeta when the config hasn't been detail-fetched yet
+ const metaSource = configState.allConfigMeta?.find(m => m.id === config_id);
+ if (!configSource && !metaSource) return null;
- const configPublic: ConfigPublic = {
- id: config_id,
- name: configSource.name,
- description: configSource.description ?? null,
- project_id: 0,
- inserted_at: '',
- updated_at: '',
- };
+ const configPublic: ConfigPublic = configSource
+ ? { id: config_id, name: configSource.name, description: configSource.description ?? null, project_id: 0, inserted_at: '', updated_at: '' }
+ : metaSource!;
const loadPromise: Promise = (async () => {
try {
@@ -386,5 +397,6 @@ export function useConfigs(options?: { pageSize?: number }): UseConfigsResult {
isLoadingMore,
loadSingleVersion,
versionItemsMap,
+ allConfigMeta,
};
}
diff --git a/app/lib/constants.ts b/app/lib/constants.ts
index 0e44cbb..9ce1fe5 100644
--- a/app/lib/constants.ts
+++ b/app/lib/constants.ts
@@ -13,3 +13,16 @@ export const PAGE_SIZE = 10;
/** Custom event dispatched when background validation invalidates the in-memory cache */
export const CACHE_INVALIDATED_EVENT = 'kaapi:config-cache-invalidated';
+
+
+export const PROVIDES_OPTIONS = [
+ { value: 'openai', label: 'OpenAI' },
+ { value: 'anthropic', label: 'Anthropic' },
+ { value: 'google', label: 'Google' },
+];
+
+export const PROVIDER_TYPES = [
+ { value: 'text', label: 'Text Completion' },
+ { value: 'stt', label: 'Speech-to-Text (Coming Soon)' },
+ { value: 'tts', label: 'Text-to-Speech (Coming Soon)' },
+]
\ No newline at end of file
diff --git a/app/lib/types/configs.ts b/app/lib/types/configs.ts
index 3332a27..7da7a0e 100644
--- a/app/lib/types/configs.ts
+++ b/app/lib/types/configs.ts
@@ -4,7 +4,7 @@
* flattened from the raw API response shapes in configTypes.ts.
*/
-import { ConfigVersionItems, Tool } from '@/app/lib/configTypes';
+import { ConfigVersionItems, Tool, ConfigPublic } from "@/app/lib/configTypes";
// Re-export so consumers don't need to reach into configTypes directly
export type { ConfigVersionItems, Tool };
@@ -21,7 +21,7 @@ export interface SavedConfig {
promptContent: string; // Same as instructions for compatibility
modelName: string;
provider: string;
- type: 'text' | 'stt' | 'tts'; // Config type - always present in UI (defaults to 'text')
+ type: "text" | "stt" | "tts"; // Config type - always present in UI (defaults to 'text')
temperature: number;
vectorStoreIds: string;
tools?: Tool[];
@@ -54,6 +54,7 @@ export interface ConfigCache {
totalConfigCount?: number;
/** True when only a subset of configs have had their version details fetched */
partialFetch?: boolean;
+ allConfigMeta?: ConfigPublic[]; // Full lightweight config list.
}
// Result shape returned by fetchAllConfigs
diff --git a/app/lib/utils.ts b/app/lib/utils.ts
index 6dab285..c3457b1 100644
--- a/app/lib/utils.ts
+++ b/app/lib/utils.ts
@@ -54,8 +54,6 @@ export const invalidateConfigCache = (): void => {
clearConfigCache();
};
-// ============ CONFIG HELPERS ============
-
/** Reads the first stored API key from localStorage. */
export const getApiKey = (): string | null => {
if (typeof window === 'undefined') return null;
From 803a43c99d26bb5bd496cd143fe90bacc8b9eeb6 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Mon, 23 Mar 2026 12:53:06 +0530
Subject: [PATCH 05/10] fix(*): some cleanups
---
app/components/ConfigSelector.tsx | 1 -
.../prompt-editor/ConfigEditorPane.tsx | 25 +--
app/components/prompt-editor/DiffView.tsx | 11 +-
.../prompt-editor/HistorySidebar.tsx | 20 ---
app/lib/configFetchers.ts | 143 +++++++++++-------
app/lib/constants.ts | 24 ++-
6 files changed, 115 insertions(+), 109 deletions(-)
diff --git a/app/components/ConfigSelector.tsx b/app/components/ConfigSelector.tsx
index a8e988c..bed7e18 100644
--- a/app/components/ConfigSelector.tsx
+++ b/app/components/ConfigSelector.tsx
@@ -122,7 +122,6 @@ export default function ConfigSelector({
setSearchQuery(""); // Clear search on close
};
- // Open dropdown — auto-expand the currently-selected config group if any
const handleOpenDropdown = () => {
if (disabled) return;
if (!isDropdownOpen) {
diff --git a/app/components/prompt-editor/ConfigEditorPane.tsx b/app/components/prompt-editor/ConfigEditorPane.tsx
index 4a9e64d..b2f426a 100644
--- a/app/components/prompt-editor/ConfigEditorPane.tsx
+++ b/app/components/prompt-editor/ConfigEditorPane.tsx
@@ -4,7 +4,7 @@ import { ConfigBlob, Tool } from "@/app/configurations/prompt-editor/types";
import { SavedConfig, ConfigVersionItems } from "@/app/lib/types/configs";
import { ConfigPublic } from "@/app/lib/configTypes";
import { formatRelativeTime } from "@/app/lib/utils";
-import { PROVIDER_TYPES, PROVIDES_OPTIONS } from "@/app/lib/constants";
+import { MODEL_OPTIONS, PROVIDER_TYPES, PROVIDES_OPTIONS } from "@/app/lib/constants";
interface ConfigEditorPaneProps {
configBlob: ConfigBlob;
@@ -14,7 +14,6 @@ interface ConfigEditorPaneProps {
// Additional props for full functionality
savedConfigs: SavedConfig[];
selectedConfigId: string;
- /** Called when user selects a version. Null = new (unsaved) config. */
onLoadConfig: (config: SavedConfig | null) => void;
commitMessage: string;
onCommitMessageChange: (message: string) => void;
@@ -32,28 +31,6 @@ interface ConfigEditorPaneProps {
) => Promise;
}
-// Provider-specific models
-const MODEL_OPTIONS = {
- openai: [
- { value: "gpt-4o", label: "GPT-4o" },
- { value: "gpt-4o-mini", label: "GPT-4o Mini" },
- { value: "gpt-4-turbo", label: "GPT-4 Turbo" },
- { value: "gpt-4", label: "GPT-4" },
- { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
- ],
- // anthropic: [
- // { value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
- // { value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
- // { value: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet' },
- // { value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
- // ],
- // google: [
- // { value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
- // { value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' },
- // { value: 'gemini-pro', label: 'Gemini Pro' },
- // ],
-};
-
export default function ConfigEditorPane({
configBlob,
onConfigChange,
diff --git a/app/components/prompt-editor/DiffView.tsx b/app/components/prompt-editor/DiffView.tsx
index 0b5b801..2fc3fd7 100644
--- a/app/components/prompt-editor/DiffView.tsx
+++ b/app/components/prompt-editor/DiffView.tsx
@@ -13,11 +13,7 @@ interface DiffViewProps {
onLoadVersion: (config: SavedConfig) => void;
/** Lazily loads the lightweight version list for a given config_id (1 call or no-op) */
loadVersionsForConfig?: (config_id: string) => Promise;
- /**
- * Lightweight version items per config_id. When provided, the compare dropdown
- * shows ALL versions (not just loaded ones); full details are fetched on selection.
- */
- versionItemsMap?: Record;
+ versionItemsMap?: Record; // Lightweight version items per config_id. When provided, the compare dropdown shows ALL version
/**
* Fetches a single version's full details on demand (1 GET call).
* Called when the user picks a version that isn't yet in `commits`.
@@ -29,7 +25,6 @@ interface DiffViewProps {
interface ConfigGroupForCompare {
config_id: string;
name: string;
- /** Lightweight items used for the option list */
items: ConfigVersionItems[];
}
@@ -46,18 +41,14 @@ export default function DiffView({
const [isLoadingCompare, setIsLoadingCompare] = useState(false);
// Build groups for the compare dropdown.
- // Prefer the lightweight versionItemsMap (full history, no config_blob) over
- // the loaded commits (which may only have the latest version per config).
const configGroups = useMemo((): ConfigGroupForCompare[] => {
if (versionItemsMap && Object.keys(versionItemsMap).length > 0) {
- // Use lightweight items as the authoritative list; derive config names from commits
return Object.entries(versionItemsMap).map(([config_id, items]) => {
const nameFallback = commits.find(c => c.config_id === config_id)?.name ?? config_id;
const sorted = [...items].sort((a, b) => b.version - a.version);
return { config_id, name: nameFallback, items: sorted };
});
}
- // Fallback: use loaded commits
const grouped = new Map();
commits.forEach((config) => {
const existing = grouped.get(config.config_id) || [];
diff --git a/app/components/prompt-editor/HistorySidebar.tsx b/app/components/prompt-editor/HistorySidebar.tsx
index b49f05d..1eacd93 100644
--- a/app/components/prompt-editor/HistorySidebar.tsx
+++ b/app/components/prompt-editor/HistorySidebar.tsx
@@ -12,16 +12,7 @@ interface HistorySidebarProps {
collapsed: boolean; // Whether the sidebar is collapsed
isLoading?: boolean;
currentConfigId?: string; // To filter versions for current config only
- /**
- * Lightweight version list for the current config (no config_blob).
- * When provided, this is used as the authoritative list for history display
- * instead of the loaded SavedConfigs, which may only have the latest version.
- */
versionItems?: ConfigVersionItems[];
- /**
- * Called when a version's full details are needed but not yet loaded.
- * Returns the full SavedConfig (1 API call) or null on failure.
- */
onFetchVersionDetail?: (version: number) => Promise;
}
@@ -84,8 +75,6 @@ export default function HistorySidebar({
// When versionItems is provided for the current config, use it as the
// authoritative (lightweight) list; the fully-loaded SavedConfig entries
- // are still looked up per-entry for action callbacks.
- // Sort lightweight items newest-first too.
const sortedVersionItems = versionItems
? [...versionItems].sort((a, b) => b.version - a.version)
: null;
@@ -237,17 +226,8 @@ export default function HistorySidebar({
) : (
- {/*
- When versionItems + currentConfigId are provided, render the lightweight
- list directly (no config_blob required for display). Full details are
- fetched on-demand when the user clicks Load or Compare.
- Otherwise fall back to the loaded SavedConfig groups.
- */}
{currentConfigId && sortedVersionItems ? (() => {
- // Find the config name from loaded data (latest version is always loaded)
const configName = savedConfigs.find(c => c.config_id === currentConfigId)?.name ?? '';
- // Always key on currentConfigId (stable) — configName is not known immediately
- // with pageSize:0 and would cause the expanded key to flip once savedConfigs loads.
const isExpanded = expandedConfigs.has(currentConfigId);
return (
diff --git a/app/lib/configFetchers.ts b/app/lib/configFetchers.ts
index 4845309..a385722 100644
--- a/app/lib/configFetchers.ts
+++ b/app/lib/configFetchers.ts
@@ -9,11 +9,12 @@ import {
ConfigListResponse,
ConfigVersionListResponse,
ConfigVersionResponse,
-} from './configTypes';
-import { SavedConfig, ConfigCache, FetchResult } from './types/configs';
-import { CACHE_INVALIDATED_EVENT } from './constants';
-import { configState, saveCache } from './store/configStore';
-import { flattenConfigVersion } from './utils';
+} from "@/app/lib/configTypes";
+import { SavedConfig, ConfigCache, FetchResult } from "@/app/lib/types/configs";
+import { CACHE_INVALIDATED_EVENT } from "@/app/lib/constants";
+import { configState } from "@/app/lib/store/configStore";
+import { flattenConfigVersion } from "@/app/lib/utils";
+import { apiFetch } from "@/app/lib/apiClient";
/**
* Schedules a single background validation pass.
@@ -25,14 +26,16 @@ import { flattenConfigVersion } from './utils';
* When a new version is created the backend bumps the parent config's updated_at,
* so the updated_at check is sufficient to detect all changes.
*/
-export function scheduleBackgroundValidation(cache: ConfigCache, apiKey: string): void {
+export function scheduleBackgroundValidation(
+ cache: ConfigCache,
+ apiKey: string,
+): void {
if (configState.validationInProgress) return;
configState.validationInProgress = true;
(async () => {
try {
- const response = await fetch('/api/configs', { headers: { 'X-API-KEY': apiKey } });
- const data: ConfigListResponse = await response.json();
+ const data = await apiFetch
("/api/configs", apiKey);
if (!data.success || !data.data) return;
let needsRefresh = false;
@@ -49,7 +52,7 @@ export function scheduleBackgroundValidation(cache: ConfigCache, apiKey: string)
// Check for deleted configs
if (!needsRefresh) {
- const currentIds = new Set(data.data.map(c => c.id));
+ const currentIds = new Set(data.data.map((c) => c.id));
for (const cachedId of Object.keys(currentMeta)) {
if (!currentIds.has(cachedId)) {
needsRefresh = true;
@@ -61,7 +64,7 @@ export function scheduleBackgroundValidation(cache: ConfigCache, apiKey: string)
if (needsRefresh) {
configState.inMemoryCache = null;
configState.versionItemsCache = {};
- if (typeof window !== 'undefined') {
+ if (typeof window !== "undefined") {
window.dispatchEvent(new Event(CACHE_INVALIDATED_EVENT));
}
}
@@ -81,14 +84,14 @@ export function scheduleBackgroundValidation(cache: ConfigCache, apiKey: string)
* When pageSize is provided, only the first N configs get version details fetched.
* The full lightweight config list is always stored in configState.allConfigMeta.
*/
-export async function fetchAllConfigs(apiKey: string, pageSize?: number): Promise {
- const response = await fetch('/api/configs', {
- headers: { 'X-API-KEY': apiKey },
- });
- const data: ConfigListResponse = await response.json();
+export async function fetchAllConfigs(
+ apiKey: string,
+ pageSize?: number,
+): Promise {
+ const data = await apiFetch("/api/configs", apiKey);
if (!data.success || !data.data) {
- throw new Error(data.error || 'Failed to fetch configs');
+ throw new Error(data.error || "Failed to fetch configs");
}
// Always store the full lightweight list so loadMoreConfigs knows what's available
@@ -96,10 +99,14 @@ export async function fetchAllConfigs(apiKey: string, pageSize?: number): Promis
const totalConfigCount = data.data.length;
const allVersions: SavedConfig[] = [];
- const configMeta: Record = {};
+ const configMeta: Record<
+ string,
+ { updated_at: string; version_count: number }
+ > = {};
const versionCounts: Record = {};
- const configsToFetch = pageSize !== undefined ? data.data.slice(0, pageSize) : data.data;
+ const configsToFetch =
+ pageSize !== undefined ? data.data.slice(0, pageSize) : data.data;
const BATCH_SIZE = 5;
@@ -107,30 +114,41 @@ export async function fetchAllConfigs(apiKey: string, pageSize?: number): Promis
const batch = configsToFetch.slice(i, i + BATCH_SIZE);
const batchPromises = batch.map(async (config) => {
try {
- const versionsResponse = await fetch(`/api/configs/${config.id}/versions`, {
- headers: { 'X-API-KEY': apiKey },
- });
- const versionsData: ConfigVersionListResponse = await versionsResponse.json();
+ const versionsData = await apiFetch(
+ `/api/configs/${config.id}/versions`,
+ apiKey,
+ );
- if (versionsData.success && versionsData.data && versionsData.data.length > 0) {
+ if (
+ versionsData.success &&
+ versionsData.data &&
+ versionsData.data.length > 0
+ ) {
const versionCount = versionsData.data.length;
- configMeta[config.id] = { updated_at: config.updated_at, version_count: versionCount };
+ configMeta[config.id] = {
+ updated_at: config.updated_at,
+ version_count: versionCount,
+ };
versionCounts[config.id] = versionCount;
// Cache the lightweight version list so loadVersionsForConfig doesn't re-fetch it
configState.versionItemsCache[config.id] = versionsData.data;
- const latestItem = versionsData.data.reduce((a, b) => (b.version > a.version ? b : a));
+ const latestItem = versionsData.data.reduce((a, b) =>
+ b.version > a.version ? b : a,
+ );
try {
- const versionResponse = await fetch(
+ const versionData = await apiFetch(
`/api/configs/${config.id}/versions/${latestItem.version}`,
- { headers: { 'X-API-KEY': apiKey } },
+ apiKey,
);
- const versionData: ConfigVersionResponse = await versionResponse.json();
if (versionData.success && versionData.data) {
return [flattenConfigVersion(config, versionData.data)];
}
} catch (e) {
- console.error(`Failed to fetch latest version for config ${config.id}:`, e);
+ console.error(
+ `Failed to fetch latest version for config ${config.id}:`,
+ e,
+ );
}
}
} catch (e) {
@@ -140,7 +158,7 @@ export async function fetchAllConfigs(apiKey: string, pageSize?: number): Promis
});
const batchResults = await Promise.all(batchPromises);
- batchResults.forEach(versions => allVersions.push(...versions));
+ batchResults.forEach((versions) => allVersions.push(...versions));
}
return {
@@ -168,25 +186,29 @@ export async function fetchRemainingVersions(
name: configSource.name,
description: configSource.description ?? null,
project_id: 0,
- inserted_at: '',
- updated_at: '',
+ inserted_at: "",
+ updated_at: "",
};
// Use the cached version-items list, falling back to a fresh API call only if
// the cache was invalidated (e.g. after a force-refetch).
- let versionItems: ConfigVersionItems[] | undefined = configState.versionItemsCache[config_id];
+ let versionItems: ConfigVersionItems[] | undefined =
+ configState.versionItemsCache[config_id];
if (!versionItems) {
const versionsResponse = await fetch(`/api/configs/${config_id}/versions`, {
- headers: { 'X-API-KEY': apiKey },
+ headers: { "X-API-KEY": apiKey },
});
- const versionsData: ConfigVersionListResponse = await versionsResponse.json();
+ const versionsData: ConfigVersionListResponse =
+ await versionsResponse.json();
if (!versionsData.success || !versionsData.data) return [];
versionItems = versionsData.data;
configState.versionItemsCache[config_id] = versionItems;
}
- const loadedVersionNumbers = new Set(alreadyLoaded.map(v => v.version));
- const missingVersions = versionItems.filter(v => !loadedVersionNumbers.has(v.version));
+ const loadedVersionNumbers = new Set(alreadyLoaded.map((v) => v.version));
+ const missingVersions = versionItems.filter(
+ (v) => !loadedVersionNumbers.has(v.version),
+ );
if (missingVersions.length === 0) return [];
@@ -195,14 +217,17 @@ export async function fetchRemainingVersions(
try {
const versionResponse = await fetch(
`/api/configs/${config_id}/versions/${versionItem.version}`,
- { headers: { 'X-API-KEY': apiKey } },
+ { headers: { "X-API-KEY": apiKey } },
);
const versionData: ConfigVersionResponse = await versionResponse.json();
if (versionData.success && versionData.data) {
return flattenConfigVersion(config, versionData.data);
}
} catch (e) {
- console.error(`Failed to fetch version ${versionItem.version} for config ${config_id}:`, e);
+ console.error(
+ `Failed to fetch version ${versionItem.version} for config ${config_id}:`,
+ e,
+ );
}
return null;
}),
@@ -226,14 +251,18 @@ export async function fetchNextConfigBatch(
newConfigMeta: Record;
}> {
const allMeta = configState.allConfigMeta;
- if (!allMeta) return { newVersions: [], newVersionCounts: {}, newConfigMeta: {} };
+ if (!allMeta)
+ return { newVersions: [], newVersionCounts: {}, newConfigMeta: {} };
- const remaining = allMeta.filter(c => !loadedIds.has(c.id));
+ const remaining = allMeta.filter((c) => !loadedIds.has(c.id));
const batch = remaining.slice(0, batchSize);
const newVersions: SavedConfig[] = [];
const newVersionCounts: Record = {};
- const newConfigMeta: Record = {};
+ const newConfigMeta: Record<
+ string,
+ { updated_at: string; version_count: number }
+ > = {};
const INNER_BATCH = 5;
for (let i = 0; i < batch.length; i += INNER_BATCH) {
@@ -241,21 +270,29 @@ export async function fetchNextConfigBatch(
const results = await Promise.all(
subBatch.map(async (config) => {
try {
- const versionsResponse = await fetch(`/api/configs/${config.id}/versions`, {
- headers: { 'X-API-KEY': apiKey },
- });
- const versionsData: ConfigVersionListResponse = await versionsResponse.json();
- if (versionsData.success && versionsData.data && versionsData.data.length > 0) {
+ const versionsData = await apiFetch(
+ `/api/configs/${config.id}/versions`,
+ apiKey,
+ );
+ if (
+ versionsData.success &&
+ versionsData.data &&
+ versionsData.data.length > 0
+ ) {
const versionCount = versionsData.data.length;
- newConfigMeta[config.id] = { updated_at: config.updated_at, version_count: versionCount };
+ newConfigMeta[config.id] = {
+ updated_at: config.updated_at,
+ version_count: versionCount,
+ };
newVersionCounts[config.id] = versionCount;
configState.versionItemsCache[config.id] = versionsData.data;
- const latestItem = versionsData.data.reduce((a, b) => (b.version > a.version ? b : a));
- const versionResponse = await fetch(
+ const latestItem = versionsData.data.reduce((a, b) =>
+ b.version > a.version ? b : a,
+ );
+ const versionData = await apiFetch(
`/api/configs/${config.id}/versions/${latestItem.version}`,
- { headers: { 'X-API-KEY': apiKey } },
+ apiKey,
);
- const versionData: ConfigVersionResponse = await versionResponse.json();
if (versionData.success && versionData.data) {
return [flattenConfigVersion(config, versionData.data)];
}
@@ -266,7 +303,7 @@ export async function fetchNextConfigBatch(
return [];
}),
);
- results.forEach(v => newVersions.push(...v));
+ results.forEach((v) => newVersions.push(...v));
}
return { newVersions, newVersionCounts, newConfigMeta };
diff --git a/app/lib/constants.ts b/app/lib/constants.ts
index 9ce1fe5..75a99ae 100644
--- a/app/lib/constants.ts
+++ b/app/lib/constants.ts
@@ -25,4 +25,26 @@ export const PROVIDER_TYPES = [
{ value: 'text', label: 'Text Completion' },
{ value: 'stt', label: 'Speech-to-Text (Coming Soon)' },
{ value: 'tts', label: 'Text-to-Speech (Coming Soon)' },
-]
\ No newline at end of file
+]
+
+// Provider-specific models
+export const MODEL_OPTIONS = {
+ openai: [
+ { value: "gpt-4o", label: "GPT-4o" },
+ { value: "gpt-4o-mini", label: "GPT-4o Mini" },
+ { value: "gpt-4-turbo", label: "GPT-4 Turbo" },
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
+ ],
+ // anthropic: [
+ // { value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
+ // { value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
+ // { value: 'claude-3-sonnet-20240229', label: 'Claude 3 Sonnet' },
+ // { value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
+ // ],
+ // google: [
+ // { value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
+ // { value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' },
+ // { value: 'gemini-pro', label: 'Gemini Pro' },
+ // ],
+};
\ No newline at end of file
From 07fcb6c80e5986e5f76183f0f9de90f9585d63b9 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Mon, 23 Mar 2026 12:59:44 +0530
Subject: [PATCH 06/10] fix(*): use the some existing code
---
app/lib/configFetchers.ts | 17 ++++++++---------
1 file changed, 8 insertions(+), 9 deletions(-)
diff --git a/app/lib/configFetchers.ts b/app/lib/configFetchers.ts
index a385722..4e1d244 100644
--- a/app/lib/configFetchers.ts
+++ b/app/lib/configFetchers.ts
@@ -130,7 +130,6 @@ export async function fetchAllConfigs(
version_count: versionCount,
};
versionCounts[config.id] = versionCount;
- // Cache the lightweight version list so loadVersionsForConfig doesn't re-fetch it
configState.versionItemsCache[config.id] = versionsData.data;
const latestItem = versionsData.data.reduce((a, b) =>
@@ -195,11 +194,11 @@ export async function fetchRemainingVersions(
let versionItems: ConfigVersionItems[] | undefined =
configState.versionItemsCache[config_id];
if (!versionItems) {
- const versionsResponse = await fetch(`/api/configs/${config_id}/versions`, {
- headers: { "X-API-KEY": apiKey },
- });
- const versionsData: ConfigVersionListResponse =
- await versionsResponse.json();
+ const versionsResponse = await apiFetch(
+ `/api/configs/${config_id}/versions`,
+ apiKey,
+ );
+ const versionsData: ConfigVersionListResponse = versionsResponse;
if (!versionsData.success || !versionsData.data) return [];
versionItems = versionsData.data;
configState.versionItemsCache[config_id] = versionItems;
@@ -215,11 +214,11 @@ export async function fetchRemainingVersions(
const fetchedVersions = await Promise.all(
missingVersions.map(async (versionItem) => {
try {
- const versionResponse = await fetch(
+ const versionDataResponse = await apiFetch(
`/api/configs/${config_id}/versions/${versionItem.version}`,
- { headers: { "X-API-KEY": apiKey } },
+ apiKey,
);
- const versionData: ConfigVersionResponse = await versionResponse.json();
+ const versionData: ConfigVersionResponse = versionDataResponse;
if (versionData.success && versionData.data) {
return flattenConfigVersion(config, versionData.data);
}
From 3b58f256a478ab8d374c881d7e377e4fbd0309b8 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Mon, 23 Mar 2026 13:18:57 +0530
Subject: [PATCH 07/10] fix(*): some cleanups
---
app/components/prompt-editor/DiffView.tsx | 20 ++++----------------
1 file changed, 4 insertions(+), 16 deletions(-)
diff --git a/app/components/prompt-editor/DiffView.tsx b/app/components/prompt-editor/DiffView.tsx
index 2fc3fd7..65cdd42 100644
--- a/app/components/prompt-editor/DiffView.tsx
+++ b/app/components/prompt-editor/DiffView.tsx
@@ -11,14 +11,9 @@ interface DiffViewProps {
commits: SavedConfig[];
onCompareChange: (commit: SavedConfig | null) => void;
onLoadVersion: (config: SavedConfig) => void;
- /** Lazily loads the lightweight version list for a given config_id (1 call or no-op) */
- loadVersionsForConfig?: (config_id: string) => Promise;
+ 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
- /**
- * Fetches a single version's full details on demand (1 GET call).
- * Called when the user picks a version that isn't yet in `commits`.
- */
- onFetchVersionDetail?: (config_id: string, version: number) => Promise;
+ onFetchVersionDetail?: (config_id: string, version: number) => Promise; // Fetches a single version's full details
}
// Group configs by name for the dropdown
@@ -91,7 +86,6 @@ export default function DiffView({
let detail = commits.find(c => c.config_id === config_id && c.version === version);
if (detail) { onCompareChange(detail); return; }
- // Fetch on demand — 1 API call
if (onFetchVersionDetail) {
setIsLoadingCompare(true);
detail = (await onFetchVersionDetail(config_id, version)) ?? undefined;
@@ -100,11 +94,6 @@ export default function DiffView({
onCompareChange(detail ?? null);
};
- // Format timestamp
- const formatTimestamp = (timestamp: string) => {
- return formatRelativeTime(timestamp);
- };
-
return (
- {formatTimestamp(selectedCommit.timestamp)} • {selectedCommit.provider}/{selectedCommit.modelName}
+ {formatRelativeTime(selectedCommit.timestamp)} • {selectedCommit.provider}/{selectedCommit.modelName}
{selectedCommit.commit_message && ` • ${selectedCommit.commit_message}`}
@@ -124,7 +113,6 @@ export default function DiffView({
{
- // Ensure lightweight version lists are populated for all configs
if (loadVersionsForConfig) {
configGroups.forEach(g => loadVersionsForConfig(g.config_id));
}
@@ -148,7 +136,7 @@ export default function DiffView({
.filter(v => !(v.config_id === selectedCommit.config_id && v.version === selectedCommit.version))
.map(item => (
- v{item.version} - {item.commit_message || 'No message'} ({formatTimestamp(item.inserted_at)})
+ v{item.version} - {item.commit_message || 'No message'} ({formatRelativeTime(item.inserted_at)})
))}
From ba88033603644a5ea070ea818ecee319f3dec58c Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Mon, 23 Mar 2026 13:20:31 +0530
Subject: [PATCH 08/10] fix(*): remove the some comment nitpicks
---
app/lib/utils.ts | 2 --
1 file changed, 2 deletions(-)
diff --git a/app/lib/utils.ts b/app/lib/utils.ts
index c3457b1..6875401 100644
--- a/app/lib/utils.ts
+++ b/app/lib/utils.ts
@@ -13,8 +13,6 @@ export function timeAgo(dateStr: string): string {
return formatDistanceToNow(new Date(dateStr), { addSuffix: true });
}
-// Format timestamp as relative time
-// Handles UTC timestamps from the database and converts them to local time
export function getExistingForProvider(
provider: ProviderDef,
creds: Credential[],
From 078dbb237c3f885b35cb2ac8f60647d61731b5ea Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Tue, 24 Mar 2026 23:30:36 +0530
Subject: [PATCH 09/10] fix(*): fix the prompt editor issue
---
.../prompt-editor/HistorySidebar.tsx | 944 +++++++++++-------
app/configurations/prompt-editor/page.tsx | 11 +-
2 files changed, 593 insertions(+), 362 deletions(-)
diff --git a/app/components/prompt-editor/HistorySidebar.tsx b/app/components/prompt-editor/HistorySidebar.tsx
index 1eacd93..2224725 100644
--- a/app/components/prompt-editor/HistorySidebar.tsx
+++ b/app/components/prompt-editor/HistorySidebar.tsx
@@ -1,6 +1,413 @@
-import { useEffect, useState } from 'react';
-import { colors } from '@/app/lib/colors';
-import { SavedConfig, ConfigVersionItems } from '@/app/lib/types/configs';
+import { useCallback, useEffect, useState } from "react";
+import { colors } from "@/app/lib/colors";
+import { SavedConfig, ConfigVersionItems } from "@/app/lib/types/configs";
+import { ConfigPublic } from "@/app/lib/configTypes";
+import { timeAgo } from "@/app/lib/utils";
+
+interface VersionRowProps {
+ item: ConfigVersionItems;
+ isFirst: boolean;
+ isSelected: boolean;
+ isFetching: boolean;
+ fullConfig?: SavedConfig;
+ onLoad: () => void;
+ onCompare: () => void;
+}
+
+function VersionRow({
+ item,
+ isFirst,
+ isSelected,
+ isFetching,
+ fullConfig,
+ onLoad,
+ onCompare,
+}: VersionRowProps) {
+ return (
+
+
+
+ v{item.version}
+
+ {isFirst && (
+
+ Latest
+
+ )}
+
+
+ {item.commit_message && (
+
+ {item.commit_message}
+
+ )}
+
+
+ {timeAgo(item.inserted_at)}
+ {fullConfig ? ` • ${fullConfig.provider}/${fullConfig.modelName}` : ""}
+
+
+
+ {
+ e.stopPropagation();
+ onLoad();
+ }}
+ disabled={isFetching}
+ className="px-2 py-1 rounded text-xs font-medium transition-colors"
+ style={{
+ backgroundColor: colors.accent.primary,
+ color: "#ffffff",
+ border: "none",
+ opacity: isFetching ? 0.6 : 1,
+ }}
+ onMouseEnter={(e) => {
+ if (!isFetching) e.currentTarget.style.opacity = "0.85";
+ }}
+ onMouseLeave={(e) => {
+ if (!isFetching) e.currentTarget.style.opacity = "1";
+ }}
+ >
+ {isFetching ? "…" : "Load"}
+
+ {
+ e.stopPropagation();
+ onCompare();
+ }}
+ disabled={isFetching}
+ className="px-2 py-1 rounded text-xs font-medium transition-colors"
+ style={{
+ backgroundColor: colors.bg.secondary,
+ color: colors.text.secondary,
+ border: `1px solid ${colors.border}`,
+ opacity: isFetching ? 0.6 : 1,
+ }}
+ onMouseEnter={(e) => {
+ if (!isFetching) {
+ e.currentTarget.style.backgroundColor = colors.bg.primary;
+ e.currentTarget.style.color = colors.text.primary;
+ }
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = colors.bg.secondary;
+ e.currentTarget.style.color = colors.text.secondary;
+ }}
+ >
+ Compare
+
+
+
+ );
+}
+
+// Single-config version history (when a config is loaded in the editor)
+interface SingleConfigHistoryProps {
+ configId: string;
+ configName: string;
+ sortedItems: ConfigVersionItems[];
+ savedConfigs: SavedConfig[];
+ selectedVersion: SavedConfig | null;
+ isExpanded: boolean;
+ onToggle: () => void;
+ onLoadVersion: (config: SavedConfig) => void;
+ onSelectVersion: (config: SavedConfig) => void;
+ onFetchVersionDetail?: (version: number) => Promise;
+}
+
+function SingleConfigHistory({
+ configId,
+ configName,
+ sortedItems,
+ savedConfigs,
+ selectedVersion,
+ isExpanded,
+ onToggle,
+ onLoadVersion,
+ onSelectVersion,
+ onFetchVersionDetail,
+}: SingleConfigHistoryProps) {
+ const [fetchingVersion, setFetchingVersion] = useState(null);
+
+ const handleAction = useCallback(
+ async (item: ConfigVersionItems, action: "load" | "compare") => {
+ let detail = savedConfigs.find(
+ (c) => c.config_id === configId && c.version === item.version,
+ );
+ if (!detail && onFetchVersionDetail) {
+ setFetchingVersion(item.version);
+ detail = (await onFetchVersionDetail(item.version)) ?? undefined;
+ setFetchingVersion(null);
+ }
+ if (!detail) return;
+ if (action === "load") onLoadVersion(detail);
+ else onSelectVersion(detail);
+ },
+ [
+ configId,
+ savedConfigs,
+ onFetchVersionDetail,
+ onLoadVersion,
+ onSelectVersion,
+ ],
+ );
+
+ return (
+
+
+ (e.currentTarget.style.backgroundColor = "#f5f5f5")
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.backgroundColor = colors.bg.secondary)
+ }
+ >
+
+
+ {isExpanded ? "▼" : "▶"}
+
+
+
+ {configName || "Config"}
+
+
+ {sortedItems.length} version{sortedItems.length !== 1 ? "s" : ""}{" "}
+ • Latest: v{sortedItems[0]?.version}
+
+
+
+
+
+ {isExpanded && (
+
+ {sortedItems.map((item, idx) => (
+ c.config_id === configId && c.version === item.version,
+ )}
+ onLoad={() => handleAction(item, "load")}
+ onCompare={() => handleAction(item, "compare")}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+// One config group in "All Configurations" mode
+interface AllConfigsGroupProps {
+ meta: ConfigPublic;
+ isExpanded: boolean;
+ isLoadingGroup: boolean;
+ items: ConfigVersionItems[] | null;
+ savedConfigs: SavedConfig[];
+ selectedVersion: SavedConfig | null;
+ onToggle: () => void;
+ onLoadVersion: (config: SavedConfig) => void;
+ onSelectVersion: (config: SavedConfig) => void;
+ loadSingleVersionForConfig?: (
+ config_id: string,
+ version: number,
+ ) => Promise;
+}
+
+function AllConfigsGroup({
+ meta,
+ isExpanded,
+ isLoadingGroup,
+ items,
+ savedConfigs,
+ selectedVersion,
+ onToggle,
+ onLoadVersion,
+ onSelectVersion,
+ loadSingleVersionForConfig,
+}: AllConfigsGroupProps) {
+ const [loadingVersionKey, setLoadingVersionKey] = useState(
+ null,
+ );
+
+ const handleAction = useCallback(
+ async (item: ConfigVersionItems, action: "load" | "compare") => {
+ let detail =
+ savedConfigs.find(
+ (c) => c.config_id === meta.id && c.version === item.version,
+ ) ?? null;
+ if (!detail && loadSingleVersionForConfig) {
+ const key = `${meta.id}:${item.version}`;
+ setLoadingVersionKey(key);
+ detail = await loadSingleVersionForConfig(meta.id, item.version);
+ setLoadingVersionKey(null);
+ }
+ if (!detail) return;
+ if (action === "load") onLoadVersion(detail);
+ else onSelectVersion(detail);
+ },
+ [
+ meta.id,
+ savedConfigs,
+ loadSingleVersionForConfig,
+ onLoadVersion,
+ onSelectVersion,
+ ],
+ );
+
+ const subtitle = items
+ ? `${items.length} version${items.length !== 1 ? "s" : ""} • Latest: v${items[0]?.version}`
+ : timeAgo(meta.updated_at);
+
+ return (
+
+
+ (e.currentTarget.style.backgroundColor = "#f5f5f5")
+ }
+ onMouseLeave={(e) =>
+ (e.currentTarget.style.backgroundColor = colors.bg.secondary)
+ }
+ >
+
+
+ {isExpanded ? "▼" : "▶"}
+
+
+
+ {meta.name}
+
+
+ {subtitle}
+
+
+ {isLoadingGroup && (
+
+
+
+
+ )}
+
+
+
+ {isExpanded && isLoadingGroup && (
+
+ Loading versions…
+
+ )}
+
+ {isExpanded && !isLoadingGroup && items && (
+
+ {items.map((item, idx) => (
+ c.config_id === meta.id && c.version === item.version,
+ )}
+ onLoad={() => handleAction(item, "load")}
+ onCompare={() => handleAction(item, "compare")}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+// ─── Main component ──────────────────────────────────────────────────────────
interface HistorySidebarProps {
savedConfigs: SavedConfig[];
@@ -8,12 +415,20 @@ interface HistorySidebarProps {
onSelectVersion: (version: SavedConfig) => void;
onLoadVersion: (version: SavedConfig) => void;
onBackToEditor: () => void;
- onToggle: () => void; // Callback to toggle the sidebar
- collapsed: boolean; // Whether the sidebar is collapsed
+ onToggle: () => void;
+ collapsed: boolean;
isLoading?: boolean;
- currentConfigId?: string; // To filter versions for current config only
+ currentConfigId?: string;
versionItems?: ConfigVersionItems[];
onFetchVersionDetail?: (version: number) => Promise;
+ // All-configs lazy-loading props (used when currentConfigId is not set)
+ allConfigMeta?: ConfigPublic[];
+ fullVersionItemsMap?: Record;
+ loadVersionsForConfig?: (config_id: string) => Promise;
+ loadSingleVersionForConfig?: (
+ config_id: string,
+ version: number,
+ ) => Promise;
}
export default function HistorySidebar({
@@ -28,125 +443,121 @@ export default function HistorySidebar({
currentConfigId,
versionItems,
onFetchVersionDetail,
+ allConfigMeta,
+ fullVersionItemsMap = {},
+ loadVersionsForConfig,
+ loadSingleVersionForConfig,
}: HistorySidebarProps) {
- const [expandedConfigs, setExpandedConfigs] = useState>(new Set());
- /** Which version number is currently being fetched on-demand (for loading indicator) */
- const [fetchingVersion, setFetchingVersion] = useState(null);
+ const [expandedConfigs, setExpandedConfigs] = useState>(
+ new Set(),
+ );
+ const [loadingAllConfigIds, setLoadingAllConfigIds] = useState>(
+ new Set(),
+ );
// Auto-expand the current config's group when arriving from URL params
useEffect(() => {
if (currentConfigId) {
- setExpandedConfigs(prev => {
- if (prev.has(currentConfigId)) return prev;
- return new Set([...prev, currentConfigId]);
- });
+ setExpandedConfigs((prev) =>
+ prev.has(currentConfigId) ? prev : new Set([...prev, currentConfigId]),
+ );
}
}, [currentConfigId]);
- // Toggle expand/collapse
- const toggleExpand = (configName: string) => {
- const newExpanded = new Set(expandedConfigs);
- if (newExpanded.has(configName)) {
- newExpanded.delete(configName);
- } else {
- newExpanded.add(configName);
- }
- setExpandedConfigs(newExpanded);
- };
-
- // Filter configs - if currentConfigId is provided, only show that config's versions
- const filteredConfigs = currentConfigId
- ? savedConfigs.filter(c => c.config_id === currentConfigId)
- : savedConfigs;
-
- // Group saved configs by name
- const groupedConfigs = filteredConfigs.reduce((acc, config) => {
- if (!acc[config.name]) {
- acc[config.name] = [];
- }
- acc[config.name].push(config);
- return acc;
- }, {} as Record);
+ const toggleExpand = useCallback((configKey: string) => {
+ setExpandedConfigs((prev) => {
+ const next = new Set(prev);
+ if (next.has(configKey)) next.delete(configKey);
+ else next.add(configKey);
+ return next;
+ });
+ }, []);
- // Sort versions within each group (newest first)
- Object.keys(groupedConfigs).forEach(name => {
- groupedConfigs[name].sort((a, b) => b.version - a.version);
- });
+ const handleExpandAllConfig = useCallback(
+ (meta: ConfigPublic) => {
+ const isExpanded = expandedConfigs.has(meta.id);
+ toggleExpand(meta.id);
+ if (
+ !isExpanded &&
+ !fullVersionItemsMap[meta.id] &&
+ loadVersionsForConfig
+ ) {
+ setLoadingAllConfigIds((prev) => new Set(prev).add(meta.id));
+ loadVersionsForConfig(meta.id).finally(() => {
+ setLoadingAllConfigIds((prev) => {
+ const next = new Set(prev);
+ next.delete(meta.id);
+ return next;
+ });
+ });
+ }
+ },
+ [expandedConfigs, fullVersionItemsMap, loadVersionsForConfig, toggleExpand],
+ );
- // When versionItems is provided for the current config, use it as the
- // authoritative (lightweight) list; the fully-loaded SavedConfig entries
const sortedVersionItems = versionItems
? [...versionItems].sort((a, b) => b.version - a.version)
: null;
- // Total version count for the header subtitle
- const totalVersionCount = currentConfigId
- ? (sortedVersionItems?.length ?? filteredConfigs.length)
- : filteredConfigs.length;
+ const titleText = currentConfigId ? "Version History" : "All Configurations";
- // Format timestamp - calculate relative time from UTC timestamps
- const formatTimestamp = (timestamp: string) => {
- const now = Date.now();
- const date = new Date(timestamp).getTime();
- const diff = now - date;
- const minutes = Math.floor(diff / 60000);
- const hours = Math.floor(diff / 3600000);
- const days = Math.floor(diff / 86400000);
+ const headerSubtitle = currentConfigId
+ ? `${sortedVersionItems?.length ?? 0} version${sortedVersionItems?.length !== 1 ? "s" : ""}`
+ : `${allConfigMeta?.length ?? 0} config${allConfigMeta?.length !== 1 ? "s" : ""}`;
- if (minutes < 1) return 'just now';
- if (minutes < 60) return `${minutes} min ago`;
- if (hours < 24) return `${hours} hr ago`;
- return `${days} day${days > 1 ? 's' : ''} ago`;
- };
-
- const titleText = currentConfigId ? 'Version History' : 'All Configurations';
+ const isEmpty =
+ !isLoading &&
+ (currentConfigId ? !sortedVersionItems?.length : !allConfigMeta?.length);
return (
- {/* Header - always visible */}
+ {/* Header */}
- {/* Title - hidden when collapsed */}
{!collapsed && (
-
+
{titleText}
-
- {totalVersionCount} version{totalVersionCount !== 1 ? 's' : ''}
- {!currentConfigId && ` • ${Object.keys(groupedConfigs).length} config${Object.keys(groupedConfigs).length !== 1 ? 's' : ''}`}
+
+ {headerSubtitle}
)}
- {/* Toggle button - chevron */}
{
e.currentTarget.style.backgroundColor = colors.bg.secondary;
@@ -156,7 +567,7 @@ export default function HistorySidebar({
e.currentTarget.style.backgroundColor = colors.bg.primary;
e.currentTarget.style.color = colors.text.secondary;
}}
- title={collapsed ? 'Show version history' : 'Hide version history'}
+ title={collapsed ? "Show version history" : "Hide version history"}
>
-
+
- {/* Vertical text when collapsed - at the top */}
+ {/* Vertical label when collapsed */}
{collapsed && (
{titleText}
@@ -194,271 +610,87 @@ export default function HistorySidebar({
)}
- {/* Content - hidden when collapsed */}
+ {/* Content */}
{!collapsed && (
- {isLoading ? (
-
+ {isLoading ? (
+
+
+
+ Loading configs...
+
+
+ ) : isEmpty ? (
-
- Loading versions...
-
-
- Fetching config history from backend
-
-
- ) : Object.keys(groupedConfigs).length === 0 && !sortedVersionItems?.length ? (
-
-
- No saved configurations yet
-
-
- Create and save your first config to see version history
-
-
- ) : (
-
- {currentConfigId && sortedVersionItems ? (() => {
- const configName = savedConfigs.find(c => c.config_id === currentConfigId)?.name ?? '';
- const isExpanded = expandedConfigs.has(currentConfigId);
-
- return (
-
- {/* Config header */}
-
toggleExpand(currentConfigId)}
- className="p-3 cursor-pointer"
- style={{ backgroundColor: colors.bg.secondary, transition: 'all 0.15s ease' }}
- onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
- onMouseLeave={(e) => e.currentTarget.style.backgroundColor = colors.bg.secondary}
- >
-
-
- {isExpanded ? '▼' : '▶'}
-
-
-
- {configName || 'Config'}
-
-
- {sortedVersionItems.length} version{sortedVersionItems.length !== 1 ? 's' : ''} • Latest: v{sortedVersionItems[0]?.version}
-
-
-
-
-
- {/* Lightweight version entries */}
- {isExpanded && (
-
- {sortedVersionItems.map((item, idx) => {
- // Look up the already-loaded full version if available
- const full = savedConfigs.find(
- c => c.config_id === currentConfigId && c.version === item.version
- );
- const isSelected = selectedVersion?.config_id === currentConfigId &&
- selectedVersion?.version === item.version;
- const isFetchingThis = fetchingVersion === item.version;
-
- const handleAction = async (action: 'load' | 'compare') => {
- let detail = full;
- if (!detail && onFetchVersionDetail) {
- setFetchingVersion(item.version);
- detail = (await onFetchVersionDetail(item.version)) ?? undefined;
- setFetchingVersion(null);
- }
- if (!detail) return;
- if (action === 'load') onLoadVersion(detail);
- else onSelectVersion(detail);
- };
-
- return (
-
0 ? `1px solid ${colors.border}` : 'none',
- transition: 'all 0.15s ease',
- }}
- >
-
-
- v{item.version}
-
- {idx === 0 && (
-
- Latest
-
- )}
-
-
- {item.commit_message && (
-
- {item.commit_message}
-
- )}
-
-
- {formatTimestamp(item.inserted_at)}
- {full ? ` • ${full.provider}/${full.modelName}` : ''}
-
-
- {/* Action buttons */}
-
- { e.stopPropagation(); handleAction('load'); }}
- disabled={isFetchingThis}
- className="px-2 py-1 rounded text-xs font-medium transition-colors"
- style={{ backgroundColor: colors.accent.primary, color: '#ffffff', border: 'none', opacity: isFetchingThis ? 0.6 : 1 }}
- onMouseEnter={(e) => { if (!isFetchingThis) e.currentTarget.style.opacity = '0.85'; }}
- onMouseLeave={(e) => { if (!isFetchingThis) e.currentTarget.style.opacity = '1'; }}
- >
- {isFetchingThis ? '…' : 'Load'}
-
- { e.stopPropagation(); handleAction('compare'); }}
- disabled={isFetchingThis}
- className="px-2 py-1 rounded text-xs font-medium transition-colors"
- style={{ backgroundColor: colors.bg.secondary, color: colors.text.secondary, border: `1px solid ${colors.border}`, opacity: isFetchingThis ? 0.6 : 1 }}
- onMouseEnter={(e) => { if (!isFetchingThis) { e.currentTarget.style.backgroundColor = colors.bg.primary; e.currentTarget.style.color = colors.text.primary; } }}
- onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = colors.bg.secondary; e.currentTarget.style.color = colors.text.secondary; }}
- >
- Compare
-
-
-
- );
- })}
-
- )}
-
- );
- })() : Object.entries(groupedConfigs).map(([configName, versions]) => {
- const isExpanded = expandedConfigs.has(configName);
- const latestVersion = versions[0];
-
- return (
-
- {/* Config Header */}
-
toggleExpand(configName)}
- className="p-3 cursor-pointer"
- style={{ backgroundColor: colors.bg.secondary, transition: 'all 0.15s ease' }}
- onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
- onMouseLeave={(e) => e.currentTarget.style.backgroundColor = colors.bg.secondary}
- >
-
-
- {isExpanded ? '▼' : '▶'}
-
-
-
- {configName}
-
-
- {versions.length} version{versions.length > 1 ? 's' : ''} • Latest: v{latestVersion.version}
-
-
-
-
-
- {/* Versions List (already-loaded SavedConfigs) */}
- {isExpanded && (
-
- {versions.map((version, idx) => (
-
0 ? `1px solid ${colors.border}` : 'none',
- transition: 'all 0.15s ease',
- }}
- >
-
-
-
- v{version.version}
-
- {idx === 0 && (
-
- Latest
-
- )}
-
-
-
- {version.commit_message && (
-
- {version.commit_message}
-
- )}
-
-
- {formatTimestamp(version.timestamp)} • {version.provider}/{version.modelName}
-
-
- {/* Action buttons */}
-
- { e.stopPropagation(); onLoadVersion(version); }}
- className="px-2 py-1 rounded text-xs font-medium transition-colors"
- style={{ backgroundColor: colors.accent.primary, color: '#ffffff', border: 'none' }}
- onMouseEnter={(e) => e.currentTarget.style.opacity = '0.85'}
- onMouseLeave={(e) => e.currentTarget.style.opacity = '1'}
- >
- Load
-
- { e.stopPropagation(); onSelectVersion(version); }}
- className="px-2 py-1 rounded text-xs font-medium transition-colors"
- style={{ backgroundColor: colors.bg.secondary, color: colors.text.secondary, border: `1px solid ${colors.border}` }}
- onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = colors.bg.primary; e.currentTarget.style.color = colors.text.primary; }}
- onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = colors.bg.secondary; e.currentTarget.style.color = colors.text.secondary; }}
- >
- Compare
-
-
-
- ))}
-
- )}
-
- );
- })}
-
- )}
+ className="border-2 border-dashed rounded-lg p-6 text-center"
+ style={{ borderColor: colors.border }}
+ >
+
+ No saved configurations yet
+
+
+ Create and save your first config to see version history
+
+
+ ) : (
+
+ {currentConfigId && sortedVersionItems && (
+
c.config_id === currentConfigId)
+ ?.name ?? ""
+ }
+ sortedItems={sortedVersionItems}
+ savedConfigs={savedConfigs}
+ selectedVersion={selectedVersion}
+ isExpanded={expandedConfigs.has(currentConfigId)}
+ onToggle={() => toggleExpand(currentConfigId)}
+ onLoadVersion={onLoadVersion}
+ onSelectVersion={onSelectVersion}
+ onFetchVersionDetail={onFetchVersionDetail}
+ />
+ )}
+
+ {/* All Configurations mode */}
+ {!currentConfigId &&
+ allConfigMeta?.map((meta) => (
+ b.version - a.version,
+ )
+ : null
+ }
+ savedConfigs={savedConfigs}
+ selectedVersion={selectedVersion}
+ onToggle={() => handleExpandAllConfig(meta)}
+ onLoadVersion={onLoadVersion}
+ onSelectVersion={onSelectVersion}
+ loadSingleVersionForConfig={loadSingleVersionForConfig}
+ />
+ ))}
+
+ )}
)}
@@ -469,13 +701,13 @@ export default function HistorySidebar({
className="w-full px-4 py-2 rounded-md text-sm font-medium"
style={{
backgroundColor: colors.accent.primary,
- color: '#ffffff',
- border: 'none',
- cursor: 'pointer',
- transition: 'all 0.15s ease'
+ color: "#ffffff",
+ border: "none",
+ cursor: "pointer",
+ transition: "all 0.15s ease",
}}
- onMouseEnter={(e) => e.currentTarget.style.opacity = '0.85'}
- onMouseLeave={(e) => e.currentTarget.style.opacity = '1'}
+ onMouseEnter={(e) => (e.currentTarget.style.opacity = "0.85")}
+ onMouseLeave={(e) => (e.currentTarget.style.opacity = "1")}
>
← Back to Editor
diff --git a/app/configurations/prompt-editor/page.tsx b/app/configurations/prompt-editor/page.tsx
index dde07fc..f49aaa4 100644
--- a/app/configurations/prompt-editor/page.tsx
+++ b/app/configurations/prompt-editor/page.tsx
@@ -72,13 +72,8 @@ function PromptEditorContent() {
const [isSaving, setIsSaving] = useState
(false);
const initialLoadComplete = !isLoading;
const editorInitialized = React.useRef(false);
- // False while we are still fetching the URL-specified config — prevents the flash
- // of empty/default state that would otherwise show before applyConfig() is called.
const [editorReady, setEditorReady] = useState(!urlConfigId);
- // Stable version items map that only grows — never wiped by background refetches.
- // Background revalidation clears configState.versionItemsCache then re-fetches;
- // we merge the hook's map into our local copy so already-loaded entries survive.
const [stableVersionItemsMap, setStableVersionItemsMap] = useState<
Record
>({});
@@ -464,7 +459,7 @@ function PromptEditorContent() {
setSelectedVersion(null);
setCompareWith(null);
}}
- isLoading={!editorReady}
+ isLoading={isLoading}
versionItems={
currentConfigParentId
? (versionItemsMap[currentConfigParentId] ?? [])
@@ -476,6 +471,10 @@ function PromptEditorContent() {
loadSingleVersion(currentConfigParentId, version)
: undefined
}
+ allConfigMeta={allConfigMeta}
+ fullVersionItemsMap={versionItemsMap}
+ loadVersionsForConfig={loadVersionsForConfig}
+ loadSingleVersionForConfig={loadSingleVersion}
/>
{/* Show DiffView only when comparing versions (sidebar open + version selected) */}
From 557538fa0ba663d31dd2b70bf6d351acf5e78c88 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Thu, 26 Mar 2026 19:41:12 +0530
Subject: [PATCH 10/10] fix(*): added the model provide and name in the
dropdwon
---
app/components/ConfigSelector.tsx | 40 +++++++++++++++++++++++++------
1 file changed, 33 insertions(+), 7 deletions(-)
diff --git a/app/components/ConfigSelector.tsx b/app/components/ConfigSelector.tsx
index 54c9248..fa604ad 100644
--- a/app/components/ConfigSelector.tsx
+++ b/app/components/ConfigSelector.tsx
@@ -60,9 +60,20 @@ export default function ConfigSelector({
); // State for use which config groups are currently loading their version list
const [isLoadingPreview, setIsLoadingPreview] = useState(false); // True while full config details are being fetched for the preview pane
+ // When a config group is expanded, eagerly load full version details (provider,
+ // modelName, temperature).
+ useEffect(() => {
+ if (!expandedConfigId) return;
+ const items = versionItemsMap[expandedConfigId];
+ if (!items || items.length === 0) return;
+ items.forEach((item) => {
+ loadSingleVersion(item.config_id, item.version);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [expandedConfigId, versionItemsMap]);
+
// Reset expanded state and recheck overflow whenever selected config changes.
useLayoutEffect(() => {
-
setPromptExpanded(false);
const el = promptRef.current;
if (!el) return;
@@ -305,6 +316,26 @@ export default function ConfigSelector({
);
};
+ const getModelVersionAndTime = (item: {
+ config_id: string;
+ version: number;
+ inserted_at: string;
+ }) => {
+ const full = configs.find(
+ (c) => c.config_id === item.config_id && c.version === item.version,
+ );
+ return (
+
+ {full
+ ? `${full.provider}/${full.modelName} • T:${full.temperature.toFixed(2)} • ${formatRelativeTime(item.inserted_at)}`
+ : formatRelativeTime(item.inserted_at)}
+
+ );
+ };
+
return (
-
- {formatRelativeTime(item.inserted_at)}
-
+ {getModelVersionAndTime(item)}
{isSelected && (