"use client"; import React, { useMemo, useState, useCallback } from "react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs"; import { Button } from "~/components/ui/button"; 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, PackageSearch, PanelRightClose, } 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; /** * Collapse state and handler */ collapsed?: boolean; onCollapse?: (collapsed: boolean) => void; /** * If true, auto-switch to "properties" when a selection occurs. */ autoFocusOnSelection?: boolean; /** * Study plugins with name and metadata */ studyPlugins?: Array<{ id: string; robotId: string; name: string; version: string; }>; /** * Called to clear all validation issues. */ onClearAll?: () => void; } export function InspectorPanel({ className, activeTab, onTabChange, autoFocusOnSelection = true, studyPlugins, collapsed, onCollapse, onClearAll, }: 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), [selectedStep, selectedActionId], ); /* ------------------------------------------------------------------------ */ /* Local Active Tab State (uncontrolled mode) */ /* ------------------------------------------------------------------------ */ const INSPECTOR_TAB_STORAGE_KEY = "hristudio-designer-inspector-tab-v1"; const [internalTab, setInternalTab] = useState< "properties" | "issues" | "dependencies" >(() => { try { const raw = typeof window !== "undefined" ? localStorage.getItem(INSPECTOR_TAB_STORAGE_KEY) : null; if (raw === "properties" || raw === "issues" || raw === "dependencies") { return raw; } } catch { /* noop */ } 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"); // Scroll properties panel to top and focus first field requestAnimationFrame(() => { const activeTabpanel = document.querySelector( '[role="tabpanel"][data-state="active"]', ); if (!(activeTabpanel instanceof HTMLElement)) return; const viewportEl = activeTabpanel.querySelector( '[data-slot="scroll-area-viewport"]', ); if (viewportEl instanceof HTMLElement) { viewportEl.scrollTop = 0; const firstField = viewportEl.querySelector( "input, select, textarea, button", ); if (firstField instanceof HTMLElement) { firstField.focus(); } } }); } }, [selectedStepId, selectedActionId, autoFocusOnSelection]); const handleTabChange = useCallback( (val: string) => { if (val === "properties" || val === "issues" || val === "dependencies") { if (activeTab) { onTabChange?.(val); } else { setInternalTab(val); try { localStorage.setItem(INSPECTOR_TAB_STORAGE_KEY, val); } catch { /* noop */ } } } }, [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 */ /* ------------------------------------------------------------------------ */ const designObject = useMemo( () => ({ id: "design", name: "Design", description: "", version: 1, steps, lastSaved: new Date(), }), [steps], ); return (
{/* Tab Header */}
Props Issues{issueCount > 0 ? ` (${issueCount})` : ""} {issueCount > 0 && ( {issueCount} )} Deps{driftCount > 0 ? ` (${driftCount})` : ""} {driftCount > 0 && ( {driftCount} )}
{/* Content */}
{/* Force consistent width for tab bodies to prevent reflow when switching between content with different intrinsic widths. */} {/* Properties */} {propertiesEmpty ? (

Select a Step or Action

Click within the flow to edit its properties here.

) : (
)}
{/* Issues */} { if (entityId.startsWith("action-")) { for (const s of steps) { const a = s.actions.find((x) => x.id === entityId); if (a) return `${a.name} • ${s.name}`; } } if (entityId.startsWith("step-")) { const st = steps.find((s) => s.id === entityId); if (st) return st.name; } return "Unknown"; }} onIssueClick={(issue) => { if (issue.stepId) { selectStep(issue.stepId); if (issue.actionId) { selectAction(issue.stepId, issue.actionId); } else { selectAction(issue.stepId, undefined); } if (autoFocusOnSelection) { handleTabChange("properties"); } } }} /> {/* Dependencies */}
{ // Placeholder: future diff modal / signature update console.log("Reconcile TODO for action:", actionId); }} onRefreshDependencies={() => { console.log("Refresh dependencies TODO"); }} onInstallPlugin={(pluginId) => { console.log("Install plugin TODO:", pluginId); }} />
{/* Footer (lightweight) */}
Inspector • {selectedStep ? "Step" : selectedAction ? "Action" : "None"}{" "} • {issueCount} issues • {driftCount} drift
); } export default InspectorPanel;