"use client"; import React, { useState, useEffect, useCallback, useRef } from "react"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from "~/components/ui/select"; import { Switch } from "~/components/ui/switch"; import { Slider } from "~/components/ui/slider"; import { Badge } from "~/components/ui/badge"; import { cn } from "~/lib/utils"; import { TRIGGER_OPTIONS, type ExperimentAction, type ExperimentStep, type StepType, type TriggerType, type ExperimentDesign, } from "~/lib/experiment-designer/types"; import { actionRegistry } from "./ActionRegistry"; import { Button } from "~/components/ui/button"; import { Settings, Zap, MessageSquare, Hand, Navigation, Volume2, Clock, Eye, Bot, User, Timer, MousePointer, Mic, Activity, Play, Plus, GitBranch, Trash2, PlayCircle, Square, Loader2, CheckCircle2, XCircle, } from "lucide-react"; import { toast } from "sonner"; import { getWizardRosService, initWizardRosService, resetWizardRosService } from "~/lib/ros/wizard-ros-service"; /** * PropertiesPanel * * Extracted modular panel for editing either: * - Action properties (when an action is selected) * - Step properties (when a step is selected and no action selected) * - Empty instructional state otherwise * * Enhancements: * - Boolean parameters render as Switch * - Number parameters with min/max render as Slider (with live value) * - Number parameters without bounds fall back to numeric input * - Select and text remain standard controls * - Provenance + category badges retained */ export interface PropertiesPanelProps { design: ExperimentDesign; selectedStep?: ExperimentStep; selectedAction?: ExperimentAction; onActionUpdate: ( stepId: string, actionId: string, updates: Partial, ) => void; onStepUpdate: (stepId: string, updates: Partial) => void; className?: string; } export function PropertiesPanelBase({ design, selectedStep, selectedAction, onActionUpdate, onStepUpdate, className, }: PropertiesPanelProps) { const registry = actionRegistry; // Local state for controlled inputs const [localActionName, setLocalActionName] = useState(""); const [localStepName, setLocalStepName] = useState(""); const [localStepDescription, setLocalStepDescription] = useState(""); const [localParams, setLocalParams] = useState>({}); // Test action state const [isTesting, setIsTesting] = useState(false); const [testStatus, setTestStatus] = useState<"idle" | "running" | "success" | "error">("idle"); // Debounce timers const actionUpdateTimer = useRef(undefined); const stepUpdateTimer = useRef(undefined); const paramUpdateTimers = useRef(new Map()); // Sync local state when selection ID changes (not on every object recreation) useEffect(() => { if (selectedAction) { setLocalActionName(selectedAction.name); setLocalParams(selectedAction.parameters); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedAction?.id]); useEffect(() => { if (selectedStep) { setLocalStepName(selectedStep.name); setLocalStepDescription(selectedStep.description ?? ""); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedStep?.id]); // Cleanup timers on unmount useEffect(() => { const timersMap = paramUpdateTimers.current; return () => { if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current); if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current); timersMap.forEach((timer) => clearTimeout(timer)); }; }, []); // Debounced update handlers const debouncedActionUpdate = useCallback( (stepId: string, actionId: string, updates: Partial) => { if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current); actionUpdateTimer.current = setTimeout(() => { onActionUpdate(stepId, actionId, updates); }, 300); }, [onActionUpdate], ); const debouncedStepUpdate = useCallback( (stepId: string, updates: Partial) => { if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current); stepUpdateTimer.current = setTimeout(() => { onStepUpdate(stepId, updates); }, 300); }, [onStepUpdate], ); const debouncedParamUpdate = useCallback( (stepId: string, actionId: string, paramId: string, value: unknown) => { const existing = paramUpdateTimers.current.get(paramId); if (existing) clearTimeout(existing); const timer = setTimeout(() => { onActionUpdate(stepId, actionId, { parameters: { ...selectedAction?.parameters, [paramId]: value, }, }); paramUpdateTimers.current.delete(paramId); }, 300); paramUpdateTimers.current.set(paramId, timer); }, [onActionUpdate, selectedAction?.parameters], ); // Find containing step for selected action (if any) const containingStep = selectedAction && design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id)); // Test action handler const handleTestAction = useCallback(async () => { if (!selectedAction || !containingStep) return; setIsTesting(true); setTestStatus("running"); try { console.log("[Test Action] Starting test for action:", selectedAction.name, selectedAction.type); console.log("[Test Action] Execution config:", JSON.stringify(selectedAction.execution, null, 2)); console.log("[Test Action] Parameters:", selectedAction.parameters); // Reset service to ensure clean state for testing resetWizardRosService(); // Initialize with actual robot connection (not simulation) const rosService = await initWizardRosService(false); console.log("[Test Action] ROS service initialized, connected:", rosService.getConnectionStatus()); // Build action config from execution descriptor const execution = selectedAction.execution; let actionConfig: { topic: string; messageType: string; payloadMapping: { type: string; payload?: Record; transformFn?: string; }; } | undefined; if (execution?.transport === "ros2" && execution.ros2) { const ros2 = execution.ros2 as any; actionConfig = { topic: ros2.topic || "/speech", messageType: ros2.messageType || "std_msgs/msg/String", payloadMapping: { type: ros2.payloadMapping?.type || "static", payload: ros2.payloadMapping?.payload, transformFn: ros2.payloadMapping?.transformFn, }, }; console.log("[Test Action] Action config built:", JSON.stringify(actionConfig, null, 2)); } // Execute the action on the real robot const result = await rosService.executeRobotAction( selectedAction.source?.kind === "plugin" ? (selectedAction.source?.pluginId || "core") : "core", selectedAction.type, selectedAction.parameters, actionConfig, ); console.log("[Test Action] Execution result:", result); setTestStatus("success"); toast.success(`Action "${selectedAction.name}" executed on robot`); } catch (error) { setTestStatus("error"); const message = error instanceof Error ? error.message : "Action execution failed"; toast.error(message); console.error("Test action error:", error); } finally { setIsTesting(false); // Reset status after a delay setTimeout(() => setTestStatus("idle"), 2000); } }, [selectedAction, containingStep]); /* -------------------------- Action Properties View -------------------------- */ if (selectedAction && containingStep) { let def = registry.getAction(selectedAction.type); // Fallback: If action not found in registry, try without plugin prefix if (!def && selectedAction.type.includes(".")) { const baseType = selectedAction.type.split(".").pop(); if (baseType) { def = registry.getAction(baseType); } } // Final fallback: Create minimal definition from action data if (!def) { def = { id: selectedAction.type, type: selectedAction.type, name: selectedAction.name, description: `Action type: ${selectedAction.type}`, category: selectedAction.category || "control", icon: "Zap", color: "#6366f1", parameters: [], source: selectedAction.source, }; } const categoryColors = { wizard: "bg-blue-500", robot: "bg-emerald-500", control: "bg-amber-500", observation: "bg-purple-500", } as const; // Icon resolution uses statically imported lucide icons (no dynamic require) // Icon resolution uses statically imported lucide icons (no dynamic require) const iconComponents: Record< string, React.ComponentType<{ className?: string }> > = { Zap, MessageSquare, Hand, Navigation, Volume2, Clock, Eye, Bot, User, Timer, MousePointer, Mic, Activity, Play, }; const ResolvedIcon: React.ComponentType<{ className?: string }> = def?.icon && iconComponents[def.icon] ? (iconComponents[def.icon] as React.ComponentType<{ className?: string; }>) : Zap; return (
{/* Header / Metadata */}
{def && (
)}

{selectedAction.name}

{def?.category}

{selectedAction.source.kind === "plugin" ? "Plugin" : "Core"} {/* internal plugin identifiers hidden from UI */} {selectedAction.execution?.transport} {selectedAction.execution?.retryable && ( retryable )}
{def?.description && (

{def.description}

)}
{/* Test Action Button */} {selectedAction.execution?.transport !== "internal" && (
)} {/* General */}
General
{ const newName = e.target.value; setLocalActionName(newName); debouncedActionUpdate(containingStep.id, selectedAction.id, { name: newName, }); }} onBlur={() => { if (localActionName !== selectedAction.name) { onActionUpdate(containingStep.id, selectedAction.id, { name: localActionName, }); } }} className="mt-1 h-7 w-full text-xs" />
{/* Branching Configuration (Special Case) */} {selectedAction.type === "branch" ? (
Branch Options
{( ((containingStep.trigger.conditions as any).options as any[]) || [] ).map((opt: any, idx: number) => (
{ const currentOptions = ((containingStep.trigger.conditions as any) .options as any[]) || []; const newOpts = [...currentOptions]; newOpts[idx] = { ...newOpts[idx], label: e.target.value, }; onStepUpdate(containingStep.id, { trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts, }, }, }); onActionUpdate(containingStep.id, selectedAction.id, { parameters: { ...selectedAction.parameters, options: newOpts, }, }); }} className="h-7 text-xs" />
{design.steps.length <= 1 ? (
No linkable steps
) : ( )}
))} {!((containingStep.trigger.conditions as any).options as any[]) ?.length && (
No options defined.
Click + to add a branch.
)}
) : selectedAction.type === "loop" ? ( /* Loop Configuration */
Loop Configuration
{/* Iterations */}
{ onActionUpdate(containingStep.id, selectedAction.id, { parameters: { ...selectedAction.parameters, iterations: vals[0], }, }); }} /> {Number(selectedAction.parameters.iterations || 1)}
) : /* Standard Parameters */ def?.parameters.length ? (
Parameters
{def.parameters.map((param) => ( { onActionUpdate(containingStep.id, selectedAction.id, { parameters: { ...selectedAction.parameters, [param.id]: val, }, }); }} onCommit={() => {}} /> ))}
) : (
No parameters for this action.
)}
); } /* --------------------------- Step Properties View --------------------------- */ if (selectedStep) { return (

Step Settings

General
{ const newName = e.target.value; setLocalStepName(newName); debouncedStepUpdate(selectedStep.id, { name: newName }); }} onBlur={() => { if (localStepName !== selectedStep.name) { onStepUpdate(selectedStep.id, { name: localStepName }); } }} className="mt-1 h-7 w-full text-xs" />
{ const newDesc = e.target.value; setLocalStepDescription(newDesc); debouncedStepUpdate(selectedStep.id, { description: newDesc, }); }} onBlur={() => { if ( localStepDescription !== (selectedStep.description ?? "") ) { onStepUpdate(selectedStep.id, { description: localStepDescription, }); } }} className="mt-1 h-7 w-full text-xs" />
Behavior

Steps always execute sequentially. Use control flow actions for parallel/conditional logic.

); } /* ------------------------------- Empty State ------------------------------- */ return (

No selection

Select a step or action in the flow to edit its properties.

); } export const PropertiesPanel = React.memo(PropertiesPanelBase); /* -------------------------------------------------------------------------- */ /* Isolated Parameter Editor (Optimized) */ /* -------------------------------------------------------------------------- */ interface ParameterEditorProps { param: any; value: unknown; onUpdate: (value: unknown) => void; onCommit: () => void; } const ParameterEditor = React.memo(function ParameterEditor({ param, value: rawValue, onUpdate, onCommit, }: ParameterEditorProps) { // Local state for immediate feedback const [localValue, setLocalValue] = useState(rawValue); const debounceRef = useRef(undefined); // Sync from prop if it changes externally useEffect(() => { setLocalValue(rawValue); }, [rawValue]); const handleUpdate = useCallback( (newVal: unknown, immediate = false) => { setLocalValue(newVal); if (debounceRef.current) clearTimeout(debounceRef.current); if (immediate) { onUpdate(newVal); } else { debounceRef.current = setTimeout(() => { onUpdate(newVal); }, 300); } }, [onUpdate], ); const handleCommit = useCallback(() => { if (localValue !== rawValue) { onUpdate(localValue); } }, [localValue, rawValue, onUpdate]); let control: React.ReactNode = null; if (param.type === "text") { control = (