"use client"; import React, { useCallback, useEffect, useMemo, useState, type ReactNode, } from "react"; import { useDraggable } from "@dnd-kit/core"; import { Star, StarOff, Search, Filter, Sparkles, SlidersHorizontal, FolderPlus, User, Bot, GitBranch, Eye, X, Layers, } from "lucide-react"; import { Input } from "~/components/ui/input"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; import { Separator } from "~/components/ui/separator"; import { ScrollArea } from "~/components/ui/scroll-area"; import { cn } from "~/lib/utils"; import { useActionRegistry } from "../ActionRegistry"; import type { ActionDefinition } from "~/lib/experiment-designer/types"; export type ActionCategory = ActionDefinition["category"]; interface FavoritesState { favorites: Set; } const FAVORITES_STORAGE_KEY = "hristudio-action-favorites-v1"; interface DraggableActionProps { action: ActionDefinition; compact: boolean; isFavorite: boolean; onToggleFavorite: (id: string) => void; highlight?: string; } const iconMap: Record> = { User, Bot, GitBranch, Eye, Sparkles, Layers, }; function highlightMatch(text: string, query: string): ReactNode { if (!query.trim()) return text; const idx = text.toLowerCase().indexOf(query.toLowerCase()); if (idx === -1) return text; return ( <> {text.slice(0, idx)} {text.slice(idx, idx + query.length)} {text.slice(idx + query.length)} ); } function DraggableAction({ action, compact, isFavorite, onToggleFavorite, highlight, }: DraggableActionProps) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `action-${action.id}`, data: { action }, }); const style: React.CSSProperties = transform ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, } : {}; const IconComponent = iconMap[action.icon] ?? Sparkles; const categoryColors: Record = { wizard: "bg-blue-500", robot: "bg-emerald-600", control: "bg-amber-500", observation: "bg-purple-600", }; return (
{action.source.kind === "plugin" ? ( P ) : ( C )} {highlight ? highlightMatch(action.name, highlight) : action.name}
{action.description && !compact && (
{highlight ? highlightMatch(action.description, highlight) : action.description}
)}
); } export function ActionLibraryPanel() { const registry = useActionRegistry(); const [search, setSearch] = useState(""); const [selectedCategories, setSelectedCategories] = useState< Set >(new Set(["wizard", "robot", "control", "observation"])); const [favorites, setFavorites] = useState({ favorites: new Set(), }); const [showOnlyFavorites, setShowOnlyFavorites] = useState(false); const [density, setDensity] = useState<"comfortable" | "compact">( "comfortable", ); const allActions = registry.getAllActions(); useEffect(() => { try { const raw = localStorage.getItem(FAVORITES_STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw) as { favorites?: string[] }; if (Array.isArray(parsed.favorites)) { setFavorites({ favorites: new Set(parsed.favorites) }); } } } catch { /* noop */ } }, []); const persistFavorites = useCallback((next: Set) => { try { localStorage.setItem( FAVORITES_STORAGE_KEY, JSON.stringify({ favorites: Array.from(next) }), ); } catch { /* noop */ } }, []); const toggleFavorite = useCallback( (id: string) => { setFavorites((prev) => { const copy = new Set(prev.favorites); if (copy.has(id)) copy.delete(id); else copy.add(id); persistFavorites(copy); return { favorites: copy }; }); }, [persistFavorites], ); const categories = useMemo( () => [ { key: "wizard", label: "Wizard", icon: User, color: "bg-blue-500" }, { key: "robot", label: "Robot", icon: Bot, color: "bg-emerald-600" }, { key: "control", label: "Control", icon: GitBranch, color: "bg-amber-500", }, { key: "observation", label: "Observe", icon: Eye, color: "bg-purple-600", }, ] as const, [], ); /** * Enforce invariant: * - Either ALL categories selected * - Or EXACTLY ONE selected * * Behaviors: * - From ALL -> clicking a category selects ONLY that category * - From single selected -> clicking same category returns to ALL * - From single selected -> clicking different category switches to that single * - Any multi-subset attempt collapses to the clicked category (prevents ambiguous subset) */ const toggleCategory = useCallback( (c: ActionCategory) => { setSelectedCategories((prev) => { const allKeys = categories.map((k) => k.key) as ActionCategory[]; const fullSize = allKeys.length; const isFull = prev.size === fullSize; const isSingle = prev.size === 1; const has = prev.has(c); // Case: full set -> reduce to single clicked if (isFull) { return new Set([c]); } // Case: single selection if (isSingle) { // Clicking the same => expand to all if (has) { return new Set(allKeys); } // Clicking different => switch single return new Set([c]); } // (Should not normally reach: ambiguous multi-subset) // Collapse to single clicked to restore invariant return new Set([c]); }); }, [categories], ); const clearFilters = useCallback(() => { setSelectedCategories(new Set(categories.map((c) => c.key))); setSearch(""); setShowOnlyFavorites(false); }, [categories]); const filtered = useMemo(() => { const activeCats = selectedCategories; const q = search.trim().toLowerCase(); return allActions.filter((a) => { if (!activeCats.has(a.category)) return false; if (showOnlyFavorites && !favorites.favorites.has(a.id)) return false; if (!q) return true; return ( a.name.toLowerCase().includes(q) || (a.description?.toLowerCase().includes(q) ?? false) || a.id.toLowerCase().includes(q) ); }); }, [ allActions, selectedCategories, search, showOnlyFavorites, favorites.favorites, ]); const countsByCategory = useMemo(() => { const map: Record = { wizard: 0, robot: 0, control: 0, observation: 0, }; for (const a of allActions) map[a.category] += 1; return map; }, [allActions]); const visibleFavoritesCount = Array.from(favorites.favorites).filter((id) => filtered.some((a) => a.id === id), ).length; return (
setSearch(e.target.value)} placeholder="Search" className="h-8 w-full pl-7 text-xs" aria-label="Search actions" />
{categories.map((cat) => { const active = selectedCategories.has(cat.key); const Icon = cat.icon; return ( ); })}
{filtered.length} / {allActions.length}
{registry.getDebugInfo().pluginActionsLoaded ? "Plugins ✓" : "Plugins …"}
{filtered.length === 0 ? (
No actions
) : ( filtered.map((action) => ( )) )}
{allActions.length} total {showOnlyFavorites && ( {visibleFavoritesCount} fav )}
Core: {registry.getDebugInfo().coreActionsLoaded ? "✓" : "…"}

Drag actions into the flow. Star frequent actions.

); } // Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories export default React.memo(ActionLibraryPanel);