"use client"; import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Bot, Play, Settings, AlertCircle, CheckCircle, Loader2, Volume2, Move, Eye, Hand, Zap, Wifi, WifiOff, } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Textarea } from "~/components/ui/textarea"; import { Slider } from "~/components/ui/slider"; import { Switch } from "~/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Separator } from "~/components/ui/separator"; import { Alert, AlertDescription } from "~/components/ui/alert"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "~/components/ui/collapsible"; import { api } from "~/trpc/react"; import { toast } from "sonner"; import { useWizardRos } from "~/hooks/useWizardRos"; interface RobotAction { id: string; name: string; description: string; category: string; parameters?: Array<{ name: string; type: "text" | "number" | "boolean" | "select"; description: string; required: boolean; min?: number; max?: number; step?: number; default?: unknown; options?: Array<{ value: string; label: string }>; placeholder?: string; maxLength?: number; }>; } interface Plugin { plugin: { id: string; name: string; version: string; description: string; trustLevel: string; actionDefinitions: RobotAction[]; }; installation: { id: string; configuration: Record; installedAt: Date; }; } interface RobotActionsPanelProps { studyId: string; trialId: string; onExecuteAction?: ( pluginName: string, actionId: string, parameters: Record, ) => Promise; } // Helper functions moved outside component to prevent re-renders const getCategoryIcon = (category: string) => { switch (category.toLowerCase()) { case "movement": return Move; case "speech": return Volume2; case "sensors": return Eye; case "interaction": return Hand; default: return Zap; } }; const groupActionsByCategory = (actions: RobotAction[]) => { const grouped: Record = {}; actions.forEach((action) => { const category = action.category ?? "other"; if (!grouped[category]) { grouped[category] = []; } grouped[category]!.push(action); }); return grouped; }; export function RobotActionsPanel({ studyId, trialId: _trialId, onExecuteAction, }: RobotActionsPanelProps) { const [selectedPlugin, setSelectedPlugin] = useState(""); const [selectedAction, setSelectedAction] = useState( null, ); const [actionParameters, setActionParameters] = useState< Record >({}); const [executingActions, setExecutingActions] = useState>( new Set(), ); const [expandedCategories, setExpandedCategories] = useState>( new Set(["movement", "speech"]), ); // WebSocket ROS integration const { isConnected: rosConnected, isConnecting: rosConnecting, connectionError: rosError, robotStatus, activeActions, connect: connectRos, disconnect: disconnectRos, executeRobotAction: executeRosAction, } = useWizardRos({ autoConnect: false, // Let WizardInterface handle connection onActionCompleted: (execution) => { toast.success(`Completed: ${execution.actionId}`, { description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`, }); // Remove from executing set setExecutingActions((prev) => { const next = new Set(prev); next.delete(`${execution.pluginName}.${execution.actionId}`); return next; }); }, onActionFailed: (execution) => { toast.error(`Failed: ${execution.actionId}`, { description: execution.error || "Unknown error", }); // Remove from executing set setExecutingActions((prev) => { const next = new Set(prev); next.delete(`${execution.pluginName}.${execution.actionId}`); return next; }); }, }); // Get installed plugins for the study const { data: plugins = [], isLoading } = api.robots.plugins.getStudyPlugins.useQuery({ studyId, }); // Get actions for selected plugin - memoized to prevent infinite re-renders const selectedPluginData = useMemo( () => plugins.find((p) => p.plugin.id === selectedPlugin), [plugins, selectedPlugin], ); // Initialize parameters when action changes useEffect(() => { if (selectedAction) { const defaultParams: Record = {}; selectedAction.parameters?.forEach((param) => { if (param.default !== undefined) { defaultParams[param.name] = param.default; } else if (param.required) { // Set reasonable defaults for required params switch (param.type) { case "text": defaultParams[param.name] = ""; break; case "number": defaultParams[param.name] = param.min ?? 0; break; case "boolean": defaultParams[param.name] = false; break; case "select": defaultParams[param.name] = param.options?.[0]?.value ?? ""; break; } } }); setActionParameters(defaultParams); } else { setActionParameters({}); } }, [selectedAction]); const toggleCategory = useCallback((category: string) => { setExpandedCategories((prev) => { const next = new Set(prev); if (next.has(category)) { next.delete(category); } else { next.add(category); } return next; }); }, []); const handleExecuteAction = useCallback(async () => { if (!selectedAction || !selectedPluginData) return; const actionKey = `${selectedPluginData.plugin.name}.${selectedAction.id}`; setExecutingActions((prev) => new Set([...prev, actionKey])); try { // Get action configuration from plugin const actionDef = ( selectedPluginData.plugin.actionDefinitions as RobotAction[] )?.find((def: RobotAction) => def.id === selectedAction.id); // Try direct WebSocket execution first if (rosConnected && actionDef) { try { // Look for ROS2 configuration in the action definition const actionConfig = (actionDef as any).ros2 ? { topic: (actionDef as any).ros2.topic, messageType: (actionDef as any).ros2.messageType, payloadMapping: (actionDef as any).ros2.payloadMapping, } : undefined; await executeRosAction( selectedPluginData.plugin.name, selectedAction.id, actionParameters, actionConfig, ); toast.success(`Executed: ${selectedAction.name}`, { description: `Robot action completed via WebSocket`, }); } catch (rosError) { console.warn( "WebSocket execution failed, falling back to tRPC:", rosError, ); // Fallback to tRPC execution if (onExecuteAction) { await onExecuteAction( selectedPluginData.plugin.name, selectedAction.id, actionParameters, ); toast.success(`Executed: ${selectedAction.name}`, { description: `Robot action completed via tRPC fallback`, }); } else { throw rosError; } } } else if (onExecuteAction) { // Use tRPC execution if WebSocket not available await onExecuteAction( selectedPluginData.plugin.name, selectedAction.id, actionParameters, ); toast.success(`Executed: ${selectedAction.name}`, { description: `Robot action completed via tRPC`, }); } else { throw new Error("No execution method available"); } } catch (error) { toast.error(`Failed to execute: ${selectedAction.name}`, { description: error instanceof Error ? error.message : "Unknown error", }); } finally { setExecutingActions((prev) => { const next = new Set(prev); next.delete(actionKey); return next; }); } }, [ selectedAction, selectedPluginData, rosConnected, executeRosAction, onExecuteAction, ]); const handleParameterChange = useCallback( (paramName: string, value: unknown) => { setActionParameters((prev) => ({ ...prev, [paramName]: value, })); }, [], ); const renderParameterInput = ( param: NonNullable[0], _paramIndex: number, ) => { if (!param) return null; const value = actionParameters[param.name]; switch (param.type) { case "text": return (
{param.maxLength && param.maxLength > 100 ? (