refactor: simplify wizard UI by removing trial monitoring and robot control tabs, and streamlining monitoring panel props.

This commit is contained in:
2025-11-20 14:52:08 -05:00
parent 1108f4d25d
commit 5be4ff0372
4 changed files with 306 additions and 849 deletions

View File

@@ -491,12 +491,6 @@ export const WizardInterface = React.memo(function WizardInterface({
}
right={
<WizardMonitoringPanel
trial={trial}
trialEvents={trialEvents}
isConnected={rosConnected}
wsError={undefined}
activeTab={monitoringPanelTab}
onTabChange={setMonitoringPanelTab}
rosConnected={rosConnected}
rosConnecting={rosConnecting}
rosError={rosError ?? undefined}

View File

@@ -13,6 +13,7 @@ import {
Zap,
User,
Bot,
Eye,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
@@ -173,29 +174,25 @@ export function WizardControlPanel({
value === "actions" ||
value === "robot"
) {
onTabChange(value as "control" | "step" | "actions" | "robot");
onTabChange(value as "control" | "step" | "actions");
}
}}
className="flex min-h-0 flex-1 flex-col"
>
<div className="border-b px-2 py-1">
<TabsList className="grid w-full grid-cols-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="control" className="text-xs">
<Settings className="mr-1 h-3 w-3" />
<Play className="mr-1 h-3 w-3" />
Control
</TabsTrigger>
<TabsTrigger value="step" className="text-xs">
<Play className="mr-1 h-3 w-3" />
<Eye className="mr-1 h-3 w-3" />
Step
</TabsTrigger>
<TabsTrigger value="actions" className="text-xs">
<Zap className="mr-1 h-3 w-3" />
Actions
</TabsTrigger>
<TabsTrigger value="robot" className="text-xs">
<Bot className="mr-1 h-3 w-3" />
Robot
</TabsTrigger>
</TabsList>
</div>

View File

@@ -1,70 +1,20 @@
"use client";
import React, { useState, useRef } from "react";
import React from "react";
import {
Bot,
User,
Activity,
Wifi,
WifiOff,
AlertCircle,
CheckCircle,
Clock,
Power,
PowerOff,
Eye,
Volume2,
Move,
Hand,
AlertCircle,
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { Progress } from "~/components/ui/progress";
import { Button } from "~/components/ui/button";
interface TrialData {
id: string;
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
scheduledAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
sessionNumber: number | null;
notes: string | null;
experimentId: string;
participantId: string | null;
wizardId: string | null;
experiment: {
id: string;
name: string;
description: string | null;
studyId: string;
};
participant: {
id: string;
participantCode: string;
demographics: Record<string, unknown> | null;
};
}
interface TrialEvent {
type: string;
timestamp: Date;
data?: unknown;
message?: string;
}
interface WizardMonitoringPanelProps {
trial: TrialData;
trialEvents: TrialEvent[];
isConnected: boolean;
wsError?: string;
activeTab: "status" | "robot" | "events";
onTabChange: (tab: "status" | "robot" | "events") => void;
// ROS connection props
rosConnected: boolean;
rosConnecting: boolean;
rosError?: string;
@@ -85,13 +35,7 @@ interface WizardMonitoringPanelProps {
) => Promise<unknown>;
}
const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
trial,
trialEvents,
isConnected,
wsError,
activeTab,
onTabChange,
const WizardMonitoringPanel = function WizardMonitoringPanel({
rosConnected,
rosConnecting,
rosError,
@@ -100,271 +44,15 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
disconnectRos,
executeRosAction,
}: WizardMonitoringPanelProps) {
// ROS connection is now passed as props, no need for separate hook
// Don't close connection on unmount to prevent disconnection issues
// Connection will persist across component re-renders
// Removed auto-reconnect to prevent interference with manual connections
const formatTimestamp = React.useCallback((timestamp: Date) => {
return new Date(timestamp).toLocaleTimeString();
}, []);
const getEventIcon = (eventType: string) => {
switch (eventType.toLowerCase()) {
case "trial_started":
case "trial_resumed":
return CheckCircle;
case "trial_paused":
case "trial_stopped":
return AlertCircle;
case "step_completed":
case "action_completed":
return CheckCircle;
case "robot_action":
case "robot_status":
return Bot;
case "wizard_action":
case "wizard_intervention":
return User;
case "system_error":
case "connection_error":
return AlertCircle;
default:
return Activity;
}
};
const getEventColor = (eventType: string) => {
switch (eventType.toLowerCase()) {
case "trial_started":
case "trial_resumed":
case "step_completed":
case "action_completed":
return "text-green-600";
case "trial_paused":
case "trial_stopped":
return "text-yellow-600";
case "system_error":
case "connection_error":
case "trial_failed":
return "text-red-600";
case "robot_action":
case "robot_status":
return "text-blue-600";
case "wizard_action":
case "wizard_intervention":
return "text-purple-600";
default:
return "text-muted-foreground";
}
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b p-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Monitoring</h3>
<div className="flex items-center gap-2">
{isConnected ? (
<Wifi className="h-4 w-4 text-green-600" />
) : (
<WifiOff className="h-4 w-4 text-orange-600" />
)}
<Badge
variant={isConnected ? "default" : "secondary"}
className="text-xs"
>
{isConnected ? "Live" : "Offline"}
</Badge>
</div>
</div>
{wsError && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{wsError}</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-between border-b p-3">
<h2 className="text-sm font-semibold">Robot Control</h2>
</div>
{/* Tabbed Content */}
<Tabs
value={activeTab}
onValueChange={(value: string) => {
if (value === "status" || value === "robot" || value === "events") {
onTabChange(value);
}
}}
className="flex min-h-0 flex-1 flex-col"
>
<div className="border-b px-2 py-1">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="status" className="text-xs">
<Eye className="mr-1 h-3 w-3" />
Status
</TabsTrigger>
<TabsTrigger value="robot" className="text-xs">
<Bot className="mr-1 h-3 w-3" />
Robot
</TabsTrigger>
<TabsTrigger value="events" className="text-xs">
<Activity className="mr-1 h-3 w-3" />
Events
{trialEvents.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs">
{trialEvents.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
</div>
<div className="min-h-0 flex-1">
{/* Status Tab */}
<TabsContent value="status" className="m-0 h-full">
<ScrollArea className="h-full">
<div className="space-y-4 p-3">
{/* Connection Status */}
<div className="space-y-2">
<div className="text-sm font-medium">Connection</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
ROS Bridge
</span>
<Badge
variant={isConnected ? "default" : "secondary"}
className="text-xs"
>
{isConnected ? "Connected" : "Offline"}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Data Mode
</span>
<span className="text-xs">
{isConnected ? "Real-time" : "Polling"}
</span>
</div>
</div>
</div>
<Separator />
{/* Trial Information */}
<div className="space-y-2">
<div className="text-sm font-medium">Trial Info</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">ID</span>
<span className="font-mono text-xs">
{trial.id.slice(-8)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Session
</span>
<span className="text-xs">#{trial.sessionNumber}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Status
</span>
<Badge variant="outline" className="text-xs">
{trial.status.replace("_", " ")}
</Badge>
</div>
{trial.startedAt && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Started
</span>
<span className="text-xs">
{formatTimestamp(new Date(trial.startedAt))}
</span>
</div>
)}
</div>
</div>
<Separator />
{/* Participant Information */}
<div className="space-y-2">
<div className="text-sm font-medium">Participant</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Code
</span>
<span className="font-mono text-xs">
{trial.participant.participantCode}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Session
</span>
<span className="text-xs">#{trial.sessionNumber}</span>
</div>
{trial.participant.demographics && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Demographics
</span>
<span className="text-xs">
{Object.keys(trial.participant.demographics).length}{" "}
fields
</span>
</div>
)}
</div>
</div>
<Separator />
{/* System Information */}
<div className="space-y-2">
<div className="text-sm font-medium">System</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Experiment
</span>
<span
className="max-w-24 truncate text-xs"
title={trial.experiment.name}
>
{trial.experiment.name}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Study
</span>
<span className="font-mono text-xs">
{trial.experiment.studyId.slice(-8)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Platform
</span>
<span className="text-xs">HRIStudio</span>
</div>
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
{/* Robot Tab */}
<TabsContent value="robot" className="m-0 h-full">
<ScrollArea className="h-full">
{/* Robot Status and Controls */}
<ScrollArea className="flex-1">
<div className="space-y-4 p-3">
{/* Robot Status */}
<div className="space-y-2">
@@ -414,48 +102,6 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
)}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Battery
</span>
<div className="flex items-center gap-1">
<span className="text-xs">
{robotStatus && robotStatus.battery > 0
? `${Math.round(robotStatus.battery)}%`
: rosConnected
? "Reading..."
: "No data"}
</span>
<Progress
value={
robotStatus && robotStatus.battery > 0
? robotStatus.battery
: 0
}
className="h-1 w-8"
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Position
</span>
<span className="text-xs">
{robotStatus
? `(${robotStatus.position.x.toFixed(1)}, ${robotStatus.position.y.toFixed(1)})`
: "--"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Last Update
</span>
<span className="text-xs">
{robotStatus
? robotStatus.lastUpdate.toLocaleTimeString()
: "--"}
</span>
</div>
</div>
{/* ROS Connection Controls */}
@@ -497,256 +143,163 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
</Alert>
)}
{/* Connection Help */}
{!rosConnected && !rosConnecting && (
<Alert className="mt-2">
<div className="mt-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
<div className="space-y-1">
<div className="font-medium">Troubleshooting:</div>
<div>
1. Check ROS Bridge:{" "}
<code className="bg-muted rounded px-1 text-xs">
telnet localhost 9090
</code>
</div>
<div>2. NAO6 must be awake and connected</div>
<div>
3. Try: Click Connect Wait 2s Test Speech
</div>
</div>
Connect to ROS bridge for live robot monitoring and
control.
</AlertDescription>
</Alert>
</div>
)}
</div>
<Separator />
{/* Robot Actions */}
<div className="space-y-2">
<div className="text-sm font-medium">Active Actions</div>
<div className="space-y-1">
<div className="text-muted-foreground text-center text-xs">
No active actions
</div>
</div>
</div>
<Separator />
{/* Recent Trial Events */}
<div className="space-y-2">
<div className="text-sm font-medium">Recent Events</div>
<div className="space-y-1">
{trialEvents
.filter((e) => e.type.includes("robot"))
.slice(-2)
.map((event, index) => (
<div
key={index}
className="border-border/50 flex items-center justify-between rounded border p-2"
>
<span className="text-xs font-medium">
{event.type.replace(/_/g, " ")}
</span>
<span className="text-muted-foreground text-xs">
{formatTimestamp(event.timestamp)}
</span>
</div>
))}
{trialEvents.filter((e) => e.type.includes("robot"))
.length === 0 && (
<div className="text-muted-foreground py-2 text-center text-xs">
No robot events yet
</div>
)}
</div>
</div>
<Separator />
{/* Robot Configuration */}
<div className="space-y-2">
<div className="text-sm font-medium">Configuration</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Type
</span>
<span className="text-xs">NAO6</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
ROS Bridge
</span>
<span className="font-mono text-xs">localhost:9090</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Platform
</span>
<span className="font-mono text-xs">NAOqi</span>
</div>
{robotStatus &&
Object.keys(robotStatus.joints).length > 0 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Joints
</span>
<span className="text-xs">
{Object.keys(robotStatus.joints).length} active
</span>
</div>
)}
</div>
</div>
{/* Manual Subscription Controls */}
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Manual Controls</div>
{/* Connection Test */}
<div className="grid grid-cols-1 gap-1">
<Button
size="sm"
variant="outline"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Connection test - can you hear me?",
}).catch(console.error);
}
}}
disabled={!rosConnected}
>
<Volume2 className="mr-1 h-3 w-3" />
Test Speech
</Button>
</div>
{/* Topic Subscriptions */}
<div className="space-y-1">
<div className="text-muted-foreground text-xs font-medium">
Subscribe to Topics:
</div>
<div className="grid grid-cols-1 gap-1">
<div className="text-muted-foreground text-xs">
Subscriptions managed automatically
</div>
</div>
</div>
</div>
)}
{/* Quick Robot Actions */}
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Robot Actions</div>
{/* Movement Controls */}
<div className="grid grid-cols-3 gap-1">
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Movement</div>
<div className="grid grid-cols-3 gap-2">
{/* Row 1: Turn Left, Forward, Turn Right */}
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "walk_forward", {
speed: 0.05,
duration: 2,
}).catch(console.error);
}
}}
>
Forward
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_left", {
speed: 0.3,
duration: 2,
}).catch(console.error);
}
}}
>
Turn Left
Turn L
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_forward", {
speed: 0.5,
}).catch(console.error);
}}
>
Forward
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_right", {
speed: 0.3,
duration: 2,
}).catch(console.error);
}}
>
Turn R
</Button>
{/* Row 2: Left, Stop, Right */}
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_left", {
speed: 0.3,
}).catch(console.error);
}}
>
Left
</Button>
<Button
size="sm"
variant="destructive"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
console.error,
);
}}
>
Stop
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_right", {
speed: 0.3,
}).catch(console.error);
}}
>
Right
</Button>
{/* Row 3: Empty, Back, Empty */}
<div></div>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_backward", {
speed: 0.3,
}).catch(console.error);
}}
>
Back
</Button>
<div></div>
</div>
</div>
)}
<Separator />
{/* Quick Actions */}
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Quick Actions</div>
{/* TTS Input */}
<div className="flex gap-2">
<input
type="text"
placeholder="Type text to speak..."
className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
executeRosAction("nao6-ros2", "say_text", {
text: e.currentTarget.value.trim(),
}).catch(console.error);
e.currentTarget.value = "";
}
}}
/>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={(e) => {
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
if (input?.value.trim()) {
executeRosAction("nao6-ros2", "say_text", {
text: input.value.trim(),
}).catch(console.error);
input.value = "";
}
}}
>
Turn Right
Say
</Button>
</div>
{/* Head Controls */}
<div className="grid grid-cols-3 gap-1">
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_head", {
yaw: 0,
pitch: 0,
speed: 0.3,
}).catch(console.error);
}
}}
>
Center Head
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_head", {
yaw: 0.5,
pitch: 0,
speed: 0.3,
}).catch(console.error);
}
}}
>
Look Left
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "turn_head", {
yaw: -0.5,
pitch: 0,
speed: 0.3,
}).catch(console.error);
}
}}
>
Look Right
</Button>
</div>
{/* Animation & LED Controls */}
<div className="grid grid-cols-2 gap-1">
{/* Preset Actions */}
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
@@ -768,7 +321,7 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Experiment ready!",
text: "I am ready!",
}).catch(console.error);
}
}}
@@ -776,102 +329,12 @@ const WizardMonitoringPanel = React.memo(function WizardMonitoringPanel({
Say Ready
</Button>
</div>
{/* Emergency Controls */}
<div className="grid grid-cols-1 gap-1">
<Button
size="sm"
variant="destructive"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction(
"nao6-ros2",
"emergency_stop",
{},
).catch(console.error);
}
}}
>
🛑 Emergency Stop
</Button>
</div>
</div>
)}
{!rosConnected && !rosConnecting && (
<div className="mt-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
Connect to ROS bridge for live robot monitoring and
control
</AlertDescription>
</Alert>
</div>
)}
</div>
</ScrollArea>
</TabsContent>
{/* Events Tab */}
<TabsContent value="events" className="m-0 h-full">
<ScrollArea className="h-full">
<div className="p-3">
{trialEvents.length === 0 ? (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
No events recorded yet
</div>
) : (
<div className="space-y-2">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium">Live Events</span>
<Badge variant="secondary" className="text-xs">
{trialEvents.length}
</Badge>
</div>
{trialEvents
.slice()
.reverse()
.map((event, index) => {
const EventIcon = getEventIcon(event.type);
const eventColor = getEventColor(event.type);
return (
<div
key={`${event.timestamp.getTime()}-${index}`}
className="border-border/50 flex items-start gap-2 rounded-lg border p-2"
>
<div className={`mt-0.5 ${eventColor}`}>
<EventIcon className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium capitalize">
{event.type.replace(/_/g, " ")}
</div>
{event.message && (
<div className="text-muted-foreground mt-1 text-xs">
{event.message}
</div>
)}
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<Clock className="h-3 w-3" />
{formatTimestamp(event.timestamp)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</ScrollArea>
</TabsContent>
</div>
</Tabs>
</div>
);
});
};
export { WizardMonitoringPanel };

View File

@@ -107,12 +107,15 @@ export function useWizardRos(
if (!service) return;
const handleConnected = () => {
if (!mountedRef.current) return;
console.log("[useWizardRos] Connected to ROS bridge");
console.log("[useWizardRos] handleConnected called, mountedRef:", mountedRef.current);
// Set state immediately, before checking mounted status
setIsConnected(true);
setIsConnecting(false);
setConnectionError(null);
if (mountedRef.current) {
onConnectedRef.current?.();
}
};
const handleDisconnected = () => {