From 245150e9ef3acc9f8ba989f0f6f37e5d3e795c39 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Mon, 11 Aug 2025 16:43:58 -0400 Subject: [PATCH] feat(designer): structural refactor scaffold (PanelsContainer, DesignerRoot, ActionLibraryPanel, InspectorPanel, FlowListView, BottomStatusBar) --- .../experiments/[id]/designer/page.tsx | 4 +- .../experiments/designer/DesignerRoot.tsx | 554 ++++++++++++++++++ .../designer/flow/FlowListView.tsx | 150 +++++ .../designer/layout/BottomStatusBar.tsx | 339 +++++++++++ .../designer/layout/PanelsContainer.tsx | 389 ++++++++++++ .../designer/panels/ActionLibraryPanel.tsx | 478 +++++++++++++++ .../designer/panels/InspectorPanel.tsx | 345 +++++++++++ tsconfig.json | 3 +- 8 files changed, 2259 insertions(+), 3 deletions(-) create mode 100644 src/components/experiments/designer/DesignerRoot.tsx create mode 100644 src/components/experiments/designer/flow/FlowListView.tsx create mode 100644 src/components/experiments/designer/layout/BottomStatusBar.tsx create mode 100644 src/components/experiments/designer/layout/PanelsContainer.tsx create mode 100644 src/components/experiments/designer/panels/ActionLibraryPanel.tsx create mode 100644 src/components/experiments/designer/panels/InspectorPanel.tsx diff --git a/src/app/(dashboard)/experiments/[id]/designer/page.tsx b/src/app/(dashboard)/experiments/[id]/designer/page.tsx index 4ed58d4..310b600 100644 --- a/src/app/(dashboard)/experiments/[id]/designer/page.tsx +++ b/src/app/(dashboard)/experiments/[id]/designer/page.tsx @@ -1,5 +1,5 @@ import { notFound } from "next/navigation"; -import { DesignerShell } from "~/components/experiments/designer/DesignerShell"; +import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot"; import type { ExperimentStep } from "~/lib/experiment-designer/types"; import { api } from "~/trpc/server"; @@ -44,7 +44,7 @@ export default async function ExperimentDesignerPage({ : undefined; return ( - diff --git a/src/components/experiments/designer/DesignerRoot.tsx b/src/components/experiments/designer/DesignerRoot.tsx new file mode 100644 index 0000000..b725d37 --- /dev/null +++ b/src/components/experiments/designer/DesignerRoot.tsx @@ -0,0 +1,554 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Play, Plus } from "lucide-react"; + +import { PageHeader, ActionButton } from "~/components/ui/page-header"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { api } from "~/trpc/react"; + +import { PanelsContainer } from "./layout/PanelsContainer"; +import { BottomStatusBar } from "./layout/BottomStatusBar"; +import { ActionLibraryPanel } from "./panels/ActionLibraryPanel"; +import { InspectorPanel } from "./panels/InspectorPanel"; +import { FlowListView } from "./flow/FlowListView"; + +import { + type ExperimentDesign, + type ExperimentStep, +} from "~/lib/experiment-designer/types"; + +import { useDesignerStore } from "./state/store"; +import { actionRegistry } from "./ActionRegistry"; +import { computeDesignHash } from "./state/hashing"; +import { validateExperimentDesign } from "./state/validators"; + +/** + * DesignerRoot + * + * New high-level orchestrator for the Experiment Designer refactor. + * Replaces the previous monolithic DesignerShell with a composable, + * panel-based layout (left: action library, center: flow workspace, + * right: contextual inspector, bottom: status bar). + * + * Responsibilities: + * - Remote experiment fetch + initial design hydration + * - Store initialization (steps, persisted / validated hash) + * - Save / validate / export orchestration + * - Keyboard shortcut wiring + * - Action / plugin registry initialization + * + * Non-Responsibilities: + * - Raw per-field editing (delegated to sub-panels) + * - Drag/drop internals (delegated to flow + library components) + * - Low-level hashing/incremental logic (state/store) + * + * Future Enhancements (planned hooks / modules): + * - Command Palette (action insertion & navigation) + * - Virtualized step list + * - Graph view toggle + * - Drift diff / reconciliation modal + * - Autosave throttling controls + */ + +export interface DesignerRootProps { + experimentId: string; + initialDesign?: ExperimentDesign; + autoCompile?: boolean; + onPersist?: (design: ExperimentDesign) => void; +} + +interface RawExperiment { + id: string; + name: string; + description: string | null; + studyId: string; + integrityHash?: string | null; + pluginDependencies?: string[] | null; + visualDesign?: unknown; +} + +/* -------------------------------------------------------------------------- */ +/* Util: Adapt Existing Stored Visual Design */ +/* -------------------------------------------------------------------------- */ + +function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined { + if ( + !exp.visualDesign || + typeof exp.visualDesign !== "object" || + !("steps" in (exp.visualDesign as Record)) + ) { + return undefined; + } + const vd = exp.visualDesign as { + steps?: ExperimentStep[]; + version?: number; + lastSaved?: string; + }; + if (!Array.isArray(vd.steps)) return undefined; + return { + id: exp.id, + name: exp.name, + description: exp.description ?? "", + steps: vd.steps, + version: vd.version ?? 1, + lastSaved: + vd.lastSaved && typeof vd.lastSaved === "string" + ? new Date(vd.lastSaved) + : new Date(), + }; +} + +function buildEmptyDesign( + experimentId: string, + name?: string, + description?: string | null, +): ExperimentDesign { + return { + id: experimentId, + name: name?.trim().length ? name : "Untitled Experiment", + description: description ?? "", + version: 1, + steps: [], + lastSaved: new Date(), + }; +} + +/* -------------------------------------------------------------------------- */ +/* Component */ +/* -------------------------------------------------------------------------- */ + +export function DesignerRoot({ + experimentId, + initialDesign, + autoCompile = true, + onPersist, +}: DesignerRootProps) { + /* ----------------------------- Remote Experiment ------------------------- */ + const { + data: experiment, + isLoading: loadingExperiment, + refetch: refetchExperiment, + } = api.experiments.get.useQuery({ id: experimentId }); + + const updateExperiment = api.experiments.update.useMutation({ + onSuccess: async () => { + toast.success("Experiment saved"); + await refetchExperiment(); + }, + onError: (err) => { + toast.error(`Save failed: ${err.message}`); + }, + }); + + const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery( + { studyId: experiment?.studyId ?? "" }, + { enabled: !!experiment?.studyId }, + ); + + /* ------------------------------ Store Access ----------------------------- */ + const steps = useDesignerStore((s) => s.steps); + const setSteps = useDesignerStore((s) => s.setSteps); + const recomputeHash = useDesignerStore((s) => s.recomputeHash); + const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash); + const currentDesignHash = useDesignerStore((s) => s.currentDesignHash); + const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash); + const setPersistedHash = useDesignerStore((s) => s.setPersistedHash); + const setValidatedHash = useDesignerStore((s) => s.setValidatedHash); + const upsertStep = useDesignerStore((s) => s.upsertStep); + + /* ------------------------------- Local Meta ------------------------------ */ + const [designMeta, setDesignMeta] = useState<{ + name: string; + description: string; + version: number; + }>(() => { + // Determine initial local meta (prefer server design) + const existing = + initialDesign ?? + (experiment + ? adaptExistingDesign(experiment as RawExperiment) + : undefined); + const base = + existing ?? + buildEmptyDesign( + experimentId, + experiment?.name, + experiment?.description ?? "", + ); + return { + name: base.name, + description: base.description, + version: base.version, + }; + }); + + const [initialized, setInitialized] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isValidating, setIsValidating] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [lastSavedAt, setLastSavedAt] = useState(undefined); + + /* ----------------------------- Initialization ---------------------------- */ + useEffect(() => { + if (initialized || loadingExperiment) return; + const adapted = + initialDesign ?? + (experiment + ? adaptExistingDesign(experiment as RawExperiment) + : undefined); + const resolved = + adapted ?? + buildEmptyDesign( + experimentId, + experiment?.name, + experiment?.description ?? "", + ); + setDesignMeta({ + name: resolved.name, + description: resolved.description, + version: resolved.version, + }); + setSteps(resolved.steps); + if ((experiment as RawExperiment | undefined)?.integrityHash) { + const ih = (experiment as RawExperiment).integrityHash!; + setPersistedHash(ih); + setValidatedHash(ih); + } + setInitialized(true); + // Kick initial hash + void recomputeHash(); + }, [ + initialized, + loadingExperiment, + experiment, + initialDesign, + experimentId, + setSteps, + setPersistedHash, + setValidatedHash, + recomputeHash, + ]); + + /* ---------------------------- Action Registry ---------------------------- */ + // Load core actions once + useEffect(() => { + actionRegistry + .loadCoreActions() + .catch((err) => console.error("Core action load failed:", err)); + }, []); + + // Load plugin actions when study plugins available + useEffect(() => { + if (!experiment?.studyId) return; + if (!studyPlugins || studyPlugins.length === 0) return; + actionRegistry.loadPluginActions( + experiment.studyId, + studyPlugins.map((sp) => ({ + plugin: { + id: sp.plugin.id, + robotId: sp.plugin.robotId, + version: sp.plugin.version, + actionDefinitions: Array.isArray(sp.plugin.actionDefinitions) + ? sp.plugin.actionDefinitions + : undefined, + }, + })), + ); + }, [experiment?.studyId, studyPlugins]); + + /* ----------------------------- Derived State ----------------------------- */ + const hasUnsavedChanges = + !!currentDesignHash && lastPersistedHash !== currentDesignHash; + + const driftStatus = useMemo<"unvalidated" | "drift" | "validated">(() => { + if (!currentDesignHash || !lastValidatedHash) return "unvalidated"; + if (currentDesignHash !== lastValidatedHash) return "drift"; + return "validated"; + }, [currentDesignHash, lastValidatedHash]); + + /* ------------------------------- Step Ops -------------------------------- */ + const createNewStep = useCallback(() => { + const newStep: ExperimentStep = { + id: `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + name: `Step ${steps.length + 1}`, + description: "", + type: "sequential", + order: steps.length, + trigger: { + type: "trial_start", + conditions: {}, + }, + actions: [], + expanded: true, + }; + upsertStep(newStep); + toast.success(`Created ${newStep.name}`); + }, [steps.length, upsertStep]); + + /* ------------------------------- Validation ------------------------------ */ + const validateDesign = useCallback(async () => { + if (!initialized) return; + setIsValidating(true); + try { + const currentSteps = [...steps]; + const result = validateExperimentDesign(currentSteps, { + steps: currentSteps, + actionDefinitions: actionRegistry.getAllActions(), + }); + const hash = await computeDesignHash(currentSteps); + setValidatedHash(hash); + if (result.valid) { + toast.success(`Validated • ${hash.slice(0, 10)}… • No issues`); + } else { + toast.warning( + `Validated with ${result.errorCount} errors, ${result.warningCount} warnings`, + ); + } + } catch (err) { + toast.error( + `Validation error: ${ + err instanceof Error ? err.message : "Unknown error" + }`, + ); + } finally { + setIsValidating(false); + } + }, [initialized, steps, setValidatedHash]); + + /* --------------------------------- Save ---------------------------------- */ + const persist = useCallback(async () => { + if (!initialized) return; + setIsSaving(true); + try { + const visualDesign = { + steps, + version: designMeta.version, + lastSaved: new Date().toISOString(), + }; + updateExperiment.mutate({ + id: experimentId, + visualDesign, + createSteps: true, + compileExecution: autoCompile, + }); + // Optimistic hash recompute + await recomputeHash(); + setLastSavedAt(new Date()); + onPersist?.({ + id: experimentId, + name: designMeta.name, + description: designMeta.description, + steps, + version: designMeta.version, + lastSaved: new Date(), + }); + } finally { + setIsSaving(false); + } + }, [ + initialized, + steps, + designMeta, + experimentId, + updateExperiment, + recomputeHash, + onPersist, + autoCompile, + ]); + + /* -------------------------------- Export --------------------------------- */ + const handleExport = useCallback(async () => { + setIsExporting(true); + try { + const designHash = currentDesignHash ?? (await computeDesignHash(steps)); + const bundle = { + format: "hristudio.design.v1", + exportedAt: new Date().toISOString(), + experiment: { + id: experimentId, + name: designMeta.name, + version: designMeta.version, + integrityHash: designHash, + steps, + pluginDependencies: + (experiment as RawExperiment | undefined)?.pluginDependencies + ?.slice() + .sort() ?? [], + }, + compiled: null, + }; + const blob = new Blob([JSON.stringify(bundle, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${designMeta.name + .replace(/[^a-z0-9-_]+/gi, "_") + .toLowerCase()}_design.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast.success("Exported design bundle"); + } catch (err) { + toast.error( + `Export failed: ${ + err instanceof Error ? err.message : "Unknown error" + }`, + ); + } finally { + setIsExporting(false); + } + }, [currentDesignHash, steps, experimentId, designMeta, experiment]); + + /* ---------------------------- Incremental Hash --------------------------- */ + useEffect(() => { + if (!initialized) return; + void recomputeHash(); + }, [steps.length, initialized, recomputeHash]); + + /* -------------------------- Keyboard Shortcuts --------------------------- */ + const keyHandler = useCallback( + (e: globalThis.KeyboardEvent) => { + if ( + (e.metaKey || e.ctrlKey) && + e.key.toLowerCase() === "s" && + hasUnsavedChanges + ) { + e.preventDefault(); + void persist(); + } else if (e.key === "v" && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + void validateDesign(); + } else if (e.key === "e" && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + void handleExport(); + } else if (e.key === "n" && e.shiftKey) { + e.preventDefault(); + createNewStep(); + } + }, + [hasUnsavedChanges, persist, validateDesign, handleExport, createNewStep], + ); + + useEffect(() => { + const listener = (ev: globalThis.KeyboardEvent) => keyHandler(ev); + window.addEventListener("keydown", listener, { passive: true }); + return () => { + window.removeEventListener("keydown", listener); + }; + }, [keyHandler]); + + /* ------------------------------ Header Badges ---------------------------- */ + const validationBadge = + driftStatus === "drift" ? ( + Drift + ) : driftStatus === "validated" ? ( + + Validated + + ) : ( + Unvalidated + ); + + /* ------------------------------- Render ---------------------------------- */ + if (loadingExperiment && !initialized) { + return ( +
+ Loading experiment design… +
+ ); + } + + return ( +
+ + {validationBadge} + {experiment?.integrityHash && ( + + Hash: {experiment.integrityHash.slice(0, 10)}… + + )} + + {steps.length} steps + + + {steps.reduce((s, st) => s + st.actions.length, 0)} actions + + {hasUnsavedChanges && ( + + Unsaved + + )} + persist()} + disabled={!hasUnsavedChanges || isSaving} + > + {isSaving ? "Saving…" : "Save"} + + validateDesign()} + disabled={isValidating} + > + {isValidating ? "Validating…" : "Validate"} + + handleExport()} + disabled={isExporting} + > + {isExporting ? "Exporting…" : "Export"} + + +
+ } + /> + +
+ } + center={} + right={} + initialLeftWidth={300} + initialRightWidth={360} + className="flex-1" + /> + persist()} + onValidate={() => validateDesign()} + onExport={() => handleExport()} + lastSavedAt={lastSavedAt} + saving={isSaving} + validating={isValidating} + exporting={isExporting} + /> +
+ + ); +} + +export default DesignerRoot; diff --git a/src/components/experiments/designer/flow/FlowListView.tsx b/src/components/experiments/designer/flow/FlowListView.tsx new file mode 100644 index 0000000..2b2b96c --- /dev/null +++ b/src/components/experiments/designer/flow/FlowListView.tsx @@ -0,0 +1,150 @@ +import React, { useCallback, useMemo } from "react"; +import { useDesignerStore } from "../state/store"; +import { StepFlow } from "../StepFlow"; +import type { + ExperimentAction, + ExperimentStep, +} from "~/lib/experiment-designer/types"; + +/** + * FlowListView (Transitional) + * + * This component is a TEMPORARY compatibility wrapper around the legacy + * StepFlow component while the new virtualized / dual-mode (List vs Graph) + * flow workspace is implemented. + * + * Responsibilities (current): + * - Read step + selection state from the designer store + * - Provide mutation handlers (upsert, delete, reorder placeholder) + * - Emit structured callbacks (reserved for future instrumentation) + * + * Planned Enhancements: + * - Virtualization for large step counts + * - Inline step creation affordances between steps + * - Multi-select + bulk operations + * - Drag reordering at step level (currently delegated to DnD kit) + * - Graph mode toggle (will lift state to higher DesignerRoot) + * - Performance memoization / fine-grained selectors + * + * Until the new system is complete, this wrapper allows incremental + * replacement without breaking existing behavior. + */ + +export interface FlowListViewProps { + /** + * Optional callbacks for higher-level orchestration (e.g. autosave triggers) + */ + onStepMutated?: (step: ExperimentStep, kind: "create" | "update" | "delete") => void; + onActionMutated?: ( + action: ExperimentAction, + step: ExperimentStep, + kind: "create" | "update" | "delete", + ) => void; + className?: string; +} + +export function FlowListView({ + onStepMutated, + onActionMutated, + className, +}: FlowListViewProps) { + /* ----------------------------- Store Selectors ---------------------------- */ + const steps = useDesignerStore((s) => s.steps); + const selectedStepId = useDesignerStore((s) => s.selectedStepId); + const selectedActionId = useDesignerStore((s) => s.selectedActionId); + + const selectStep = useDesignerStore((s) => s.selectStep); + const selectAction = useDesignerStore((s) => s.selectAction); + + const upsertStep = useDesignerStore((s) => s.upsertStep); + const removeStep = useDesignerStore((s) => s.removeStep); + const upsertAction = useDesignerStore((s) => s.upsertAction); + const removeAction = useDesignerStore((s) => s.removeAction); + + /* ------------------------------- Handlers --------------------------------- */ + + const handleStepUpdate = useCallback( + (stepId: string, updates: Partial) => { + const existing = steps.find((s) => s.id === stepId); + if (!existing) return; + const next: ExperimentStep = { ...existing, ...updates }; + upsertStep(next); + onStepMutated?.(next, "update"); + }, + [steps, upsertStep, onStepMutated], + ); + + const handleStepDelete = useCallback( + (stepId: string) => { + const existing = steps.find((s) => s.id === stepId); + if (!existing) return; + removeStep(stepId); + onStepMutated?.(existing, "delete"); + }, + [steps, removeStep, onStepMutated], + ); + + const handleActionDelete = useCallback( + (stepId: string, actionId: string) => { + const step = steps.find((s) => s.id === stepId); + const action = step?.actions.find((a) => a.id === actionId); + removeAction(stepId, actionId); + if (step && action) { + onActionMutated?.(action, step, "delete"); + } + }, + [steps, removeAction, onActionMutated], + ); + + const totalActions = useMemo( + () => steps.reduce((sum, s) => sum + s.actions.length, 0), + [steps], + ); + + /* ------------------------------- Render ----------------------------------- */ + + return ( +
+ {/* NOTE: Header / toolbar will be hoisted into the main workspace toolbar in later iterations */} +
+
+ Flow (List View) + + {steps.length} steps • {totalActions} actions + +
+
+ Transitional component +
+
+
+ selectStep(id)} + onActionSelect={(actionId) => + selectedStepId && actionId + ? selectAction(selectedStepId, actionId) + : undefined + } + onStepDelete={handleStepDelete} + onStepUpdate={handleStepUpdate} + onActionDelete={handleActionDelete} + emptyState={ +
+ No steps yet. Use the + Step button to add your first step. +
+ } + headerRight={ +
+ (Add Step control will move to global toolbar) +
+ } + /> +
+
+ ); +} + +export default FlowListView; diff --git a/src/components/experiments/designer/layout/BottomStatusBar.tsx b/src/components/experiments/designer/layout/BottomStatusBar.tsx new file mode 100644 index 0000000..86bd199 --- /dev/null +++ b/src/components/experiments/designer/layout/BottomStatusBar.tsx @@ -0,0 +1,339 @@ +"use client"; + +import React, { useCallback, useMemo } from "react"; +import { + Save, + RefreshCw, + Download, + Hash, + AlertTriangle, + CheckCircle2, + UploadCloud, + Wand2, + Sparkles, + GitBranch, + Keyboard, +} from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; +import { Separator } from "~/components/ui/separator"; +import { cn } from "~/lib/utils"; +import { useDesignerStore } from "../state/store"; + +/** + * BottomStatusBar + * + * Compact, persistent status + quick-action bar for the Experiment Designer. + * Shows: + * - Validation / drift / unsaved state + * - Short design hash & version + * - Aggregate counts (steps / actions) + * - Last persisted hash (if available) + * - Quick actions (Save, Validate, Export, Command Palette) + * + * The bar is intentionally UI-only: callback props are used so that higher-level + * orchestration (e.g. DesignerRoot / Shell) controls actual side effects. + */ + +export interface BottomStatusBarProps { + onSave?: () => void; + onValidate?: () => void; + onExport?: () => void; + onOpenCommandPalette?: () => void; + onToggleVersionStrategy?: () => void; + className?: string; + saving?: boolean; + validating?: boolean; + exporting?: boolean; + /** + * Optional externally supplied last saved Date for relative display. + */ + lastSavedAt?: Date; +} + +export function BottomStatusBar({ + onSave, + onValidate, + onExport, + onOpenCommandPalette, + onToggleVersionStrategy, + className, + saving, + validating, + exporting, + lastSavedAt, +}: BottomStatusBarProps) { + /* ------------------------------------------------------------------------ */ + /* Store Selectors */ + /* ------------------------------------------------------------------------ */ + const steps = useDesignerStore((s) => s.steps); + const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash); + const currentDesignHash = useDesignerStore((s) => s.currentDesignHash); + const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash); + const pendingSave = useDesignerStore((s) => s.pendingSave); + const versionStrategy = useDesignerStore((s) => s.versionStrategy); + const autoSaveEnabled = useDesignerStore((s) => s.autoSaveEnabled); + + const actionCount = useMemo( + () => steps.reduce((sum, st) => sum + st.actions.length, 0), + [steps], + ); + + const hasUnsaved = useMemo( + () => + Boolean(currentDesignHash) && + currentDesignHash !== lastPersistedHash && + !pendingSave, + [currentDesignHash, lastPersistedHash, pendingSave], + ); + + const validationStatus = useMemo<"unvalidated" | "valid" | "drift">(() => { + if (!currentDesignHash || !lastValidatedHash) return "unvalidated"; + if (currentDesignHash !== lastValidatedHash) return "drift"; + return "valid"; + }, [currentDesignHash, lastValidatedHash]); + + const shortHash = useMemo( + () => (currentDesignHash ? currentDesignHash.slice(0, 8) : "—"), + [currentDesignHash], + ); + + const lastPersistedShort = useMemo( + () => (lastPersistedHash ? lastPersistedHash.slice(0, 8) : null), + [lastPersistedHash], + ); + + /* ------------------------------------------------------------------------ */ + /* Derived Display Helpers */ + /* ------------------------------------------------------------------------ */ + function formatRelative(date?: Date): string { + if (!date) return "—"; + const now = Date.now(); + const diffMs = now - date.getTime(); + if (diffMs < 30_000) return "just now"; + const mins = Math.floor(diffMs / 60_000); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; + } + + const relSaved = formatRelative(lastSavedAt); + + const validationBadge = (() => { + switch (validationStatus) { + case "valid": + return ( + + + Validated + + ); + case "drift": + return ( + + + Drift + + ); + default: + return ( + + + Unvalidated + + ); + } + })(); + + const unsavedBadge = + hasUnsaved && !pendingSave ? ( + + ● Unsaved + + ) : null; + + const savingIndicator = + pendingSave || saving ? ( + + + Saving… + + ) : null; + + /* ------------------------------------------------------------------------ */ + /* Handlers */ + /* ------------------------------------------------------------------------ */ + const handleSave = useCallback(() => { + if (onSave) onSave(); + }, [onSave]); + + const handleValidate = useCallback(() => { + if (onValidate) onValidate(); + }, [onValidate]); + + const handleExport = useCallback(() => { + if (onExport) onExport(); + }, [onExport]); + + const handlePalette = useCallback(() => { + if (onOpenCommandPalette) onOpenCommandPalette(); + }, [onOpenCommandPalette]); + + const handleToggleVersionStrategy = useCallback(() => { + if (onToggleVersionStrategy) onToggleVersionStrategy(); + }, [onToggleVersionStrategy]); + + /* ------------------------------------------------------------------------ */ + /* Render */ + /* ------------------------------------------------------------------------ */ + + return ( +
+ {/* Left Cluster: Validation & Hash */} +
+ {validationBadge} + {unsavedBadge} + {savingIndicator} + +
+ + {shortHash} + {lastPersistedShort && lastPersistedShort !== shortHash && ( + + / {lastPersistedShort} + + )} +
+
+ + {/* Middle Cluster: Aggregate Counts */} +
+
+ + {steps.length} steps +
+
+ + {actionCount} actions +
+
+ + {autoSaveEnabled ? "auto-save on" : "auto-save off"} +
+
+ + {versionStrategy.replace(/_/g, " ")} +
+
+ Saved {relSaved} +
+
+ + {/* Flexible Spacer */} +
+ + {/* Right Cluster: Quick Actions */} +
+ + + + + +
+
+ ); +} + +export default BottomStatusBar; diff --git a/src/components/experiments/designer/layout/PanelsContainer.tsx b/src/components/experiments/designer/layout/PanelsContainer.tsx new file mode 100644 index 0000000..0364dd0 --- /dev/null +++ b/src/components/experiments/designer/layout/PanelsContainer.tsx @@ -0,0 +1,389 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { cn } from "~/lib/utils"; + +/** + * PanelsContainer + * + * Structural layout component for the Experiment Designer refactor. + * Provides: + * - Optional left + right side panels (resizable + collapsible) + * - Central workspace (always present) + * - Persistent panel widths (localStorage) + * - Keyboard-accessible resize handles + * - Minimal DOM repaint during drag (inline styles) + * + * NOT responsible for: + * - Business logic or data fetching + * - Panel content semantics (passed via props) + * + * Accessibility: + * - Resize handles are + )} + + {/* Collapse / Expand Toggle (Left) */} + {hasLeft && ( + + )} + + {/* Center (Workspace) */} +
+
{center}
+
+ + {/* Right Resize Handle */} + {hasRight && !rightCollapsed && ( + + )} + + {/* Right Panel */} + {hasRight && ( +
+ {!rightCollapsed && ( +
{right}
+ )} +
+ )} + + {/* Collapse / Expand Toggle (Right) */} + {hasRight && ( + + )} +
+ ); +} + +export default PanelsContainer; diff --git a/src/components/experiments/designer/panels/ActionLibraryPanel.tsx b/src/components/experiments/designer/panels/ActionLibraryPanel.tsx new file mode 100644 index 0000000..fd59e3d --- /dev/null +++ b/src/components/experiments/designer/panels/ActionLibraryPanel.tsx @@ -0,0 +1,478 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + 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"; + +/** + * ActionLibraryPanel + * + * Enhanced wrapper panel for the experiment designer left side: + * - Fuzzy-ish search (case-insensitive substring) over name, description, id + * - Multi-category filtering (toggle chips) + * - Favorites (local persisted) + * - Density toggle (comfortable / compact) + * - Star / unstar actions inline + * - Drag support (DndKit) identical to legacy ActionLibrary + * + * Does NOT own persistence of actions themselves—delegates to action registry. + */ + +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] ?? + // fallback icon (Sparkles) + 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} +
+ )} +
+ +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Panel Component */ +/* -------------------------------------------------------------------------- */ + +export function ActionLibraryPanel() { + const registry = useActionRegistry(); + + const [search, setSearch] = useState(""); + const [selectedCategories, setSelectedCategories] = useState< + Set + >(new Set(["wizard"])); + const [favorites, setFavorites] = useState({ + favorites: new Set(), + }); + const [showOnlyFavorites, setShowOnlyFavorites] = useState(false); + const [density, setDensity] = useState<"comfortable" | "compact">( + "comfortable", + ); + + const allActions = registry.getAllActions(); + + /* ------------------------------- Favorites -------------------------------- */ + 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], + ); + + /* ----------------------------- Category List ------------------------------ */ + const categories: Array<{ + key: ActionCategory; + label: string; + icon: React.ComponentType<{ className?: string }>; + color: string; + }> = [ + { 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" }, + ]; + + const toggleCategory = useCallback((c: ActionCategory) => { + setSelectedCategories((prev) => { + const next = new Set(prev); + if (next.has(c)) { + next.delete(c); + } else { + next.add(c); + } + if (next.size === 0) { + // Keep at least one category selected + next.add(c); + } + return next; + }); + }, []); + + const clearFilters = useCallback(() => { + setSelectedCategories(new Set(categories.map((c) => c.key))); + setSearch(""); + setShowOnlyFavorites(false); + }, [categories]); + + useEffect(() => { + // On mount select all categories for richer initial view + setSelectedCategories(new Set(categories.map((c) => c.key))); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + /* ------------------------------- Filtering -------------------------------- */ + 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; + + /* ------------------------------- Rendering -------------------------------- */ + return ( +
+ {/* Toolbar */} +
+
+
+ + setSearch(e.target.value)} + placeholder="Search actions" + className="h-8 pl-7 text-xs" + aria-label="Search actions" + /> +
+ + + +
+ + {/* Category Filters */} +
+ {categories.map((cat) => { + const active = selectedCategories.has(cat.key); + const Icon = cat.icon; + return ( + + ); + })} +
+ +
+
+ {filtered.length} shown / {allActions.length} total +
+
+ + Plugins: {registry.getDebugInfo().pluginActionsLoaded ? "✓" : "…"} +
+
+
+ + {/* Actions List */} + +
+ {filtered.length === 0 ? ( +
+ +
No actions match filters
+
+ ) : ( + filtered.map((action) => ( + + )) + )} +
+
+ + {/* Footer Summary */} +
+
+
+ + {allActions.length} total + + {showOnlyFavorites && ( + + {visibleFavoritesCount} favorites + + )} +
+
+ + Core: {registry.getDebugInfo().coreActionsLoaded ? "✓" : "…"} +
+
+ +

+ Drag actions into the flow. Use search / category filters to narrow + results. Star actions you use frequently. +

+
+
+ ); +} + +export default ActionLibraryPanel; diff --git a/src/components/experiments/designer/panels/InspectorPanel.tsx b/src/components/experiments/designer/panels/InspectorPanel.tsx new file mode 100644 index 0000000..aa14b05 --- /dev/null +++ b/src/components/experiments/designer/panels/InspectorPanel.tsx @@ -0,0 +1,345 @@ +"use client"; + +import React, { useMemo, useState, useCallback } from "react"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { cn } from "~/lib/utils"; +import { useDesignerStore } from "../state/store"; +import { actionRegistry } from "../ActionRegistry"; +import { PropertiesPanel } from "../PropertiesPanel"; +import { ValidationPanel } from "../ValidationPanel"; +import { DependencyInspector } from "../DependencyInspector"; +import type { + ExperimentStep, + ExperimentAction, +} from "~/lib/experiment-designer/types"; +import { + Settings, + AlertTriangle, + GitBranch, + ListChecks, + PackageSearch, +} from "lucide-react"; + +/** + * InspectorPanel + * + * Collapsible / dockable right-side panel presenting contextual information: + * - Properties (Step or Action) + * - Validation Issues + * - Dependencies (action definitions & drift) + * + * This is a skeleton implementation bridging existing sub-panels. Future + * enhancements (planned): + * - Lazy loading heavy panels + * - Diff / reconciliation modal for action signature drift + * - Parameter schema visualization popovers + * - Step / Action navigation breadcrumbs + * - Split / pop-out inspector + */ + +export interface InspectorPanelProps { + className?: string; + /** + * Optional forced active tab; if undefined, internal state manages it. + */ + activeTab?: "properties" | "issues" | "dependencies"; + /** + * Called when user changes tab (only if activeTab not externally controlled). + */ + onTabChange?: (tab: "properties" | "issues" | "dependencies") => void; + /** + * Whether to auto-switch to properties tab when selection changes. + */ + autoFocusOnSelection?: boolean; +} + +export function InspectorPanel({ + className, + activeTab, + onTabChange, + autoFocusOnSelection = true, +}: InspectorPanelProps) { + /* ------------------------------------------------------------------------ */ + /* Store Selectors */ + /* ------------------------------------------------------------------------ */ + const steps = useDesignerStore((s) => s.steps); + const selectedStepId = useDesignerStore((s) => s.selectedStepId); + const selectedActionId = useDesignerStore((s) => s.selectedActionId); + const validationIssues = useDesignerStore((s) => s.validationIssues); + const actionSignatureDrift = useDesignerStore((s) => s.actionSignatureDrift); + + const upsertStep = useDesignerStore((s) => s.upsertStep); + const upsertAction = useDesignerStore((s) => s.upsertAction); + const selectStep = useDesignerStore((s) => s.selectStep); + const selectAction = useDesignerStore((s) => s.selectAction); + + /* ------------------------------------------------------------------------ */ + /* Derived Selection */ + /* ------------------------------------------------------------------------ */ + const selectedStep: ExperimentStep | undefined = useMemo( + () => steps.find((s) => s.id === selectedStepId), + [steps, selectedStepId], + ); + + const selectedAction: ExperimentAction | undefined = useMemo( + () => + selectedStep?.actions.find( + (a) => a.id === selectedActionId, + ) as ExperimentAction | undefined, + [selectedStep, selectedActionId], + ); + + /* ------------------------------------------------------------------------ */ + /* Local Active Tab State (uncontrolled mode) */ + /* ------------------------------------------------------------------------ */ + const [internalTab, setInternalTab] = useState< + "properties" | "issues" | "dependencies" + >(() => { + if (selectedStepId) return "properties"; + return "issues"; + }); + + const effectiveTab = activeTab ?? internalTab; + + // Auto switch to properties on new selection if permitted + React.useEffect(() => { + if (!autoFocusOnSelection) return; + if (selectedStepId || selectedActionId) { + setInternalTab("properties"); + } + }, [selectedStepId, selectedActionId, autoFocusOnSelection]); + + const handleTabChange = useCallback( + (val: string) => { + if ( + val === "properties" || + val === "issues" || + val === "dependencies" + ) { + if (activeTab) { + onTabChange?.(val); + } else { + setInternalTab(val); + } + } + }, + [activeTab, onTabChange], + ); + + /* ------------------------------------------------------------------------ */ + /* Mutation Handlers (pass-through to store) */ + /* ------------------------------------------------------------------------ */ + const handleActionUpdate = useCallback( + ( + stepId: string, + actionId: string, + updates: Partial, + ) => { + const step = steps.find((s) => s.id === stepId); + if (!step) return; + const action = step.actions.find((a) => a.id === actionId); + if (!action) return; + upsertAction(stepId, { ...action, ...updates }); + }, + [steps, upsertAction], + ); + + const handleStepUpdate = useCallback( + (stepId: string, updates: Partial) => { + const step = steps.find((s) => s.id === stepId); + if (!step) return; + upsertStep({ ...step, ...updates }); + }, + [steps, upsertStep], + ); + + /* ------------------------------------------------------------------------ */ + /* Counts & Badges */ + /* ------------------------------------------------------------------------ */ + const issueCount = useMemo( + () => + Object.values(validationIssues).reduce( + (sum, arr) => sum + arr.length, + 0, + ), + [validationIssues], + ); + + const driftCount = actionSignatureDrift.size; + + /* ------------------------------------------------------------------------ */ + /* Empty States */ + /* ------------------------------------------------------------------------ */ + const propertiesEmpty = !selectedStep && !selectedAction; + + /* ------------------------------------------------------------------------ */ + /* Render */ + /* ------------------------------------------------------------------------ */ + return ( +
+ {/* Tab Header */} +
+ + + + + Props + + + + + Issues{issueCount > 0 ? ` (${issueCount})` : ""} + + {issueCount > 0 && ( + + {issueCount} + + )} + + + + + Deps{driftCount > 0 ? ` (${driftCount})` : ""} + + {driftCount > 0 && ( + + {driftCount} + + )} + + + +
+ + {/* Content */} +
+ + {/* Properties */} + + {propertiesEmpty ? ( +
+
+ +
+
+

+ Select a Step or Action +

+

+ Click within the flow to edit its properties here. +

+
+
+ ) : ( + +
+ +
+
+ )} +
+ + {/* Issues */} + + +
+ { + if (issue.stepId) { + selectStep(issue.stepId); + if (issue.actionId) { + selectAction(issue.stepId, issue.actionId); + if (autoFocusOnSelection) { + handleTabChange("properties"); + } + } + } + }} + /> +
+
+
+ + {/* Dependencies */} + + +
+ { + // Placeholder: future diff modal / signature update + // eslint-disable-next-line no-console + console.log("Reconcile TODO for action:", actionId); + }} + onRefreshDependencies={() => { + // eslint-disable-next-line no-console + console.log("Refresh dependencies TODO"); + }} + onInstallPlugin={(pluginId) => { + // eslint-disable-next-line no-console + console.log("Install plugin TODO:", pluginId); + }} + /> +
+
+
+
+
+ + {/* Footer (lightweight) */} +
+ Inspector • {selectedStep ? "Step" : selectedAction ? "Action" : "None"}{" "} + • {issueCount} issues • {driftCount} drift +
+
+ ); +} + +export default InspectorPanel; diff --git a/tsconfig.json b/tsconfig.json index 3b1631d..1e97358 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,7 +38,8 @@ "**/*.cjs", "**/*.js", ".next/types/**/*.ts", - "src/components/experiments/designer/state/**/*.ts" + "src/components/experiments/designer/**/*.ts", + "src/components/experiments/designer/**/*.tsx" ], "exclude": ["node_modules", "robot-plugins"] }