Break work

This commit is contained in:
2026-01-20 09:38:07 -05:00
parent d83c02759a
commit 4fbd3be324
36 changed files with 3117 additions and 2770 deletions

View File

@@ -1,366 +0,0 @@
"use client";
import {
Activity,
AlertTriangle,
Battery,
BatteryLow,
Bot,
CheckCircle,
Clock,
RefreshCw,
Signal,
SignalHigh,
SignalLow,
SignalMedium,
WifiOff,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
interface RobotStatusProps {
trialId: string;
}
interface RobotStatus {
id: string;
name: string;
connectionStatus: "connected" | "disconnected" | "connecting" | "error";
batteryLevel?: number;
signalStrength?: number;
currentMode: string;
lastHeartbeat?: Date;
errorMessage?: string;
capabilities: string[];
communicationProtocol: string;
isMoving: boolean;
position?: {
x: number;
y: number;
z?: number;
orientation?: number;
};
sensors?: Record<string, string>;
}
export function RobotStatus({ trialId: _trialId }: RobotStatusProps) {
const [robotStatus, setRobotStatus] = useState<RobotStatus | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [refreshing, setRefreshing] = useState(false);
// Mock robot status - in real implementation, this would come from API/WebSocket
useEffect(() => {
// Simulate robot status updates
const mockStatus: RobotStatus = {
id: "robot_001",
name: "TurtleBot3 Burger",
connectionStatus: "connected",
batteryLevel: 85,
signalStrength: 75,
currentMode: "autonomous_navigation",
lastHeartbeat: new Date(),
capabilities: ["navigation", "manipulation", "speech", "vision"],
communicationProtocol: "ROS2",
isMoving: false,
position: {
x: 1.2,
y: 0.8,
orientation: 45,
},
sensors: {
lidar: "operational",
camera: "operational",
imu: "operational",
odometry: "operational",
},
};
setRobotStatus(mockStatus);
// Simulate periodic updates
const interval = setInterval(() => {
setRobotStatus((prev) => {
if (!prev) return prev;
return {
...prev,
batteryLevel: Math.max(
0,
(prev.batteryLevel ?? 0) - Math.random() * 0.5,
),
signalStrength: Math.max(
0,
Math.min(
100,
(prev.signalStrength ?? 0) + (Math.random() - 0.5) * 10,
),
),
lastHeartbeat: new Date(),
position: prev.position
? {
...prev.position,
x: prev.position.x + (Math.random() - 0.5) * 0.1,
y: prev.position.y + (Math.random() - 0.5) * 0.1,
}
: undefined,
};
});
setLastUpdate(new Date());
}, 3000);
return () => clearInterval(interval);
}, []);
const getConnectionStatusConfig = (status: string) => {
switch (status) {
case "connected":
return {
icon: CheckCircle,
color: "text-green-600",
bgColor: "bg-green-100",
label: "Connected",
};
case "connecting":
return {
icon: RefreshCw,
color: "text-blue-600",
bgColor: "bg-blue-100",
label: "Connecting",
};
case "disconnected":
return {
icon: WifiOff,
color: "text-gray-600",
bgColor: "bg-gray-100",
label: "Disconnected",
};
case "error":
return {
icon: AlertTriangle,
color: "text-red-600",
bgColor: "bg-red-100",
label: "Error",
};
default:
return {
icon: WifiOff,
color: "text-gray-600",
bgColor: "bg-gray-100",
label: "Unknown",
};
}
};
const getSignalIcon = (strength: number) => {
if (strength >= 75) return SignalHigh;
if (strength >= 50) return SignalMedium;
if (strength >= 25) return SignalLow;
return Signal;
};
const getBatteryIcon = (level: number) => {
return level <= 20 ? BatteryLow : Battery;
};
const handleRefreshStatus = async () => {
setRefreshing(true);
// Simulate API call
setTimeout(() => {
setRefreshing(false);
setLastUpdate(new Date());
}, 1000);
};
if (!robotStatus) {
return (
<div className="space-y-4">
<div className="rounded-lg border p-4 text-center">
<div className="text-slate-500">
<Bot className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="text-sm">No robot connected</p>
</div>
</div>
</div>
);
}
const statusConfig = getConnectionStatusConfig(robotStatus.connectionStatus);
const StatusIcon = statusConfig.icon;
const SignalIcon = getSignalIcon(robotStatus.signalStrength ?? 0);
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel ?? 0);
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="sm"
onClick={handleRefreshStatus}
disabled={refreshing}
>
<RefreshCw
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
</div>
{/* Main Status Card */}
<div className="rounded-lg border p-4">
<div className="space-y-3">
{/* Robot Info */}
<div className="flex items-center justify-between">
<div className="font-medium text-slate-900">{robotStatus.name}</div>
<Badge
className={`${statusConfig.bgColor} ${statusConfig.color}`}
variant="secondary"
>
<StatusIcon className="mr-1 h-3 w-3" />
{statusConfig.label}
</Badge>
</div>
{/* Connection Details */}
<div className="text-sm text-slate-600">
Protocol: {robotStatus.communicationProtocol}
</div>
{/* Status Indicators */}
<div className="grid grid-cols-2 gap-3">
{/* Battery */}
{robotStatus.batteryLevel !== undefined && (
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-slate-600">
<BatteryIcon className="h-3 w-3" />
<span>Battery</span>
</div>
<div className="flex items-center space-x-2">
<Progress
value={robotStatus.batteryLevel}
className="h-1.5 flex-1"
/>
<span className="w-8 text-xs font-medium">
{Math.round(robotStatus.batteryLevel)}%
</span>
</div>
</div>
)}
{/* Signal Strength */}
{robotStatus.signalStrength !== undefined && (
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-slate-600">
<SignalIcon className="h-3 w-3" />
<span>Signal</span>
</div>
<div className="flex items-center space-x-2">
<Progress
value={robotStatus.signalStrength}
className="h-1.5 flex-1"
/>
<span className="w-8 text-xs font-medium">
{Math.round(robotStatus.signalStrength)}%
</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* Current Mode */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Activity className="h-3 w-3 text-slate-600" />
<span className="text-sm text-slate-600">Mode:</span>
</div>
<Badge variant="outline" className="text-xs">
{robotStatus.currentMode
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())}
</Badge>
</div>
{robotStatus.isMoving && (
<div className="mt-2 flex items-center space-x-1 text-xs">
<div className="h-1.5 w-1.5 animate-pulse rounded-full"></div>
<span>Robot is moving</span>
</div>
)}
</div>
{/* Position Info */}
{robotStatus.position && (
<div className="rounded-lg border p-4">
<div className="mb-3 text-sm font-medium text-slate-700">
Position
</div>
<div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-slate-600">X:</span>
<span className="font-mono">
{robotStatus.position.x.toFixed(2)}m
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Y:</span>
<span className="font-mono">
{robotStatus.position.y.toFixed(2)}m
</span>
</div>
{robotStatus.position.orientation !== undefined && (
<div className="col-span-2 flex justify-between">
<span className="text-slate-600">Orientation:</span>
<span className="font-mono">
{Math.round(robotStatus.position.orientation)}°
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Sensors Status */}
{robotStatus.sensors && (
<div className="rounded-lg border p-4">
<div className="mb-3 text-sm font-medium text-slate-700">Sensors</div>
<div>
<div className="space-y-1">
{Object.entries(robotStatus.sensors).map(([sensor, status]) => (
<div
key={sensor}
className="flex items-center justify-between text-xs"
>
<span className="text-slate-600 capitalize">{sensor}:</span>
<Badge variant="outline" className="text-xs">
{status}
</Badge>
</div>
))}
</div>
</div>
</div>
)}
{/* Error Alert */}
{robotStatus.errorMessage && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
{robotStatus.errorMessage}
</AlertDescription>
</Alert>
)}
{/* Last Update */}
<div className="flex items-center space-x-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
<span>Last update: {lastUpdate.toLocaleTimeString()}</span>
</div>
</div>
);
}

View File

@@ -195,21 +195,28 @@ export const WizardInterface = React.memo(function WizardInterface({
}
);
// Update local trial state from polling
// Update local trial state from polling only if changed
useEffect(() => {
if (pollingData) {
setTrial((prev) => ({
...prev,
status: pollingData.status,
startedAt: pollingData.startedAt
? new Date(pollingData.startedAt)
: prev.startedAt,
completedAt: pollingData.completedAt
? new Date(pollingData.completedAt)
: prev.completedAt,
}));
if (pollingData && JSON.stringify(pollingData) !== JSON.stringify(trial)) {
// Only update if specific fields we care about have changed to avoid
// unnecessary re-renders that might cause UI flashing
if (pollingData.status !== trial.status ||
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
setTrial((prev) => ({
...prev,
status: pollingData.status,
startedAt: pollingData.startedAt
? new Date(pollingData.startedAt)
: prev.startedAt,
completedAt: pollingData.completedAt
? new Date(pollingData.completedAt)
: prev.completedAt,
}));
}
}
}, [pollingData]);
}, [pollingData, trial]);
// Auto-start trial on mount if scheduled
useEffect(() => {
@@ -675,6 +682,7 @@ export const WizardInterface = React.memo(function WizardInterface({
onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending}
onSetAutonomousLife={setAutonomousLife}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
}
center={
@@ -695,6 +703,7 @@ export const WizardInterface = React.memo(function WizardInterface({
completedActionsCount={completedActionsCount}
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
}
right={
@@ -706,6 +715,7 @@ export const WizardInterface = React.memo(function WizardInterface({
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
}
showDividers={true}
@@ -720,6 +730,9 @@ export const WizardInterface = React.memo(function WizardInterface({
onAddAnnotation={handleAddAnnotation}
isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents}
// Observation pane is where observers usually work, so not readOnly for them?
// But maybe we want 'readOnly' for completed trials.
readOnly={trial.status === 'completed'}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -0,0 +1,268 @@
"use client";
import React, { useCallback, useRef, useState } from "react";
import Webcam from "react-webcam";
import { Camera, CameraOff, Video, StopCircle, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { AspectRatio } from "~/components/ui/aspect-ratio";
import { toast } from "sonner";
import { api } from "~/trpc/react";
export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
const [deviceId, setDeviceId] = useState<string | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const webcamRef = useRef<Webcam>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
// TRPC mutation for presigned URL
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
const handleDevices = useCallback(
(mediaDevices: MediaDeviceInfo[]) => {
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
},
[setDevices],
);
React.useEffect(() => {
navigator.mediaDevices.enumerateDevices().then(handleDevices);
}, [handleDevices]);
const handleEnableCamera = () => {
setIsCameraEnabled(true);
setError(null);
};
const handleDisableCamera = () => {
if (isRecording) {
handleStopRecording();
}
setIsCameraEnabled(false);
};
const handleStartRecording = () => {
if (!webcamRef.current?.stream) return;
setIsRecording(true);
chunksRef.current = [];
try {
const recorder = new MediaRecorder(webcamRef.current.stream, {
mimeType: "video/webm"
});
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: "video/webm" });
await handleUpload(blob);
};
recorder.start();
mediaRecorderRef.current = recorder;
toast.success("Recording started");
} catch (e) {
console.error("Failed to start recorder:", e);
toast.error("Failed to start recording");
setIsRecording(false);
}
};
const handleStopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
};
const handleUpload = async (blob: Blob) => {
setUploading(true);
const filename = `recording-${Date.now()}.webm`;
try {
// 1. Get Presigned URL
const { url } = await getUploadUrlMutation.mutateAsync({
filename,
contentType: "video/webm",
});
// 2. Upload to S3
const response = await fetch(url, {
method: "PUT",
body: blob,
headers: {
"Content-Type": "video/webm",
},
});
if (!response.ok) {
throw new Error("Upload failed");
}
toast.success("Recording uploaded successfully");
console.log("Uploaded recording:", filename);
} catch (e) {
console.error("Upload error:", e);
toast.error("Failed to upload recording");
} finally {
setUploading(false);
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b p-3">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Camera className="h-4 w-4" />
Webcam Feed
</h2>
{!readOnly && (
<div className="flex items-center gap-2">
{devices.length > 0 && (
<Select
value={deviceId ?? undefined}
onValueChange={setDeviceId}
disabled={!isCameraEnabled || isRecording}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="Select Camera" />
</SelectTrigger>
<SelectContent>
{devices.map((device, key) => (
<SelectItem key={key} value={device.deviceId} className="text-xs">
{device.label || `Camera ${key + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{isCameraEnabled && (
!isRecording ? (
<Button
variant="destructive"
size="sm"
className="h-7 px-2 text-xs animate-in fade-in"
onClick={handleStartRecording}
disabled={uploading}
>
<Video className="mr-1 h-3 w-3" />
Record
</Button>
) : (
<Button
variant="secondary"
size="sm"
className="h-7 px-2 text-xs border-red-500 border text-red-500 hover:bg-red-50"
onClick={handleStopRecording}
>
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
Stop Rec
</Button>
)
)}
{isCameraEnabled ? (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={handleDisableCamera}
disabled={isRecording}
>
<CameraOff className="mr-1 h-3 w-3" />
Off
</Button>
) : (
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleEnableCamera}
>
<Camera className="mr-1 h-3 w-3" />
Start Camera
</Button>
)}
</div>
)}
</div>
<div className="flex-1 overflow-hidden bg-black p-4 flex items-center justify-center relative">
{isCameraEnabled ? (
<div className="w-full relative rounded-lg overflow-hidden border border-slate-800">
<AspectRatio ratio={16 / 9}>
<Webcam
ref={webcamRef}
audio={false}
width="100%"
height="100%"
videoConstraints={{ deviceId: deviceId ?? undefined }}
onUserMediaError={(err) => setError(String(err))}
className="object-contain w-full h-full"
/>
</AspectRatio>
{/* Recording Overlay */}
{isRecording && (
<div className="absolute top-2 right-2 flex items-center gap-2 bg-black/50 px-2 py-1 rounded-full backdrop-blur-sm">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] font-medium text-white">REC</span>
</div>
)}
{/* Uploading Overlay */}
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="flex flex-col items-center gap-2 text-white">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-xs font-medium">Uploading...</span>
</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
<Alert variant="destructive" className="max-w-xs">
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)}
</div>
) : (
<div className="text-center text-slate-500">
<CameraOff className="mx-auto mb-2 h-12 w-12 opacity-20" />
<p className="text-sm">Camera is disabled</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={handleEnableCamera}
>
Enable Camera
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -98,6 +98,7 @@ interface WizardControlPanelProps {
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
isStarting?: boolean;
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
readOnly?: boolean;
}
export function WizardControlPanel({
@@ -118,6 +119,7 @@ export function WizardControlPanel({
onTabChange,
isStarting = false,
onSetAutonomousLife,
readOnly = false,
}: WizardControlPanelProps) {
const [autonomousLife, setAutonomousLife] = React.useState(true);
@@ -187,7 +189,7 @@ export function WizardControlPanel({
}}
className="w-full"
size="sm"
disabled={isStarting}
disabled={isStarting || readOnly}
>
<Play className="mr-2 h-4 w-4" />
{isStarting ? "Starting..." : "Start Trial"}
@@ -201,14 +203,14 @@ export function WizardControlPanel({
onClick={onPauseTrial}
variant="outline"
size="sm"
disabled={false}
disabled={readOnly}
>
<Pause className="mr-1 h-3 w-3" />
Pause
</Button>
<Button
onClick={onNextStep}
disabled={currentStepIndex >= steps.length - 1}
disabled={(currentStepIndex >= steps.length - 1) || readOnly}
size="sm"
>
<SkipForward className="mr-1 h-3 w-3" />
@@ -223,6 +225,7 @@ export function WizardControlPanel({
variant="outline"
className="w-full"
size="sm"
disabled={readOnly}
>
<CheckCircle className="mr-2 h-4 w-4" />
Complete Trial
@@ -233,6 +236,7 @@ export function WizardControlPanel({
variant="destructive"
className="w-full"
size="sm"
disabled={readOnly}
>
<X className="mr-2 h-4 w-4" />
Abort Trial
@@ -277,7 +281,7 @@ export function WizardControlPanel({
id="autonomous-life"
checked={autonomousLife}
onCheckedChange={handleAutonomousLifeChange}
disabled={!_isConnected}
disabled={!_isConnected || readOnly}
className="scale-75"
/>
</div>
@@ -368,7 +372,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Acknowledge clicked");
onExecuteAction("acknowledge");
}}
disabled={false}
disabled={readOnly}
>
<CheckCircle className="mr-2 h-3 w-3" />
Acknowledge
@@ -382,7 +386,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Intervene clicked");
onExecuteAction("intervene");
}}
disabled={false}
disabled={readOnly}
>
<AlertCircle className="mr-2 h-3 w-3" />
Intervene
@@ -396,7 +400,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Add Note clicked");
onExecuteAction("note", { content: "Wizard note" });
}}
disabled={false}
disabled={readOnly}
>
<User className="mr-2 h-3 w-3" />
Add Note
@@ -412,7 +416,7 @@ export function WizardControlPanel({
size="sm"
className="w-full justify-start"
onClick={() => onExecuteAction("step_complete")}
disabled={false}
disabled={readOnly}
>
<CheckCircle className="mr-2 h-3 w-3" />
Mark Complete
@@ -441,11 +445,13 @@ export function WizardControlPanel({
<ScrollArea className="h-full">
<div className="p-3">
{studyId && onExecuteRobotAction ? (
<RobotActionsPanel
studyId={studyId}
trialId={trial.id}
onExecuteAction={onExecuteRobotAction}
/>
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
<RobotActionsPanel
studyId={studyId}
trialId={trial.id}
onExecuteAction={onExecuteRobotAction}
/>
</div>
) : (
<Alert>
<AlertCircle className="h-4 w-4" />

View File

@@ -10,18 +10,13 @@ import {
User,
Activity,
Zap,
Eye,
List,
Loader2,
ArrowRight,
AlertTriangle,
RotateCcw,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface StepData {
id: string;
@@ -107,6 +102,7 @@ interface WizardExecutionPanelProps {
onCompleteTrial?: () => void;
completedActionsCount: number;
onActionCompleted: () => void;
readOnly?: boolean;
}
export function WizardExecutionPanel({
@@ -126,47 +122,13 @@ export function WizardExecutionPanel({
onCompleteTrial,
completedActionsCount,
onActionCompleted,
readOnly = false,
}: WizardExecutionPanelProps) {
// Local state removed in favor of parent state to prevent reset on re-render
// const [completedCount, setCompletedCount] = React.useState(0);
const activeActionIndex = completedActionsCount;
const getStepIcon = (type: string) => {
switch (type) {
case "wizard_action":
return User;
case "robot_action":
return Bot;
case "parallel_steps":
return Activity;
case "conditional_branch":
return AlertCircle;
default:
return Play;
}
};
const getStepStatus = (stepIndex: number) => {
if (stepIndex < currentStepIndex) return "completed";
if (stepIndex === currentStepIndex && trial.status === "in_progress")
return "active";
return "pending";
};
const getStepVariant = (status: string) => {
switch (status) {
case "completed":
return "default";
case "active":
return "secondary";
case "pending":
return "outline";
default:
return "outline";
}
};
// Pre-trial state
if (trial.status === "scheduled") {
return (
@@ -252,7 +214,7 @@ export function WizardExecutionPanel({
</div>
{/* Simplified Content - Sequential Focus */}
<div className="flex-1 overflow-hidden">
<div className="relative flex-1 overflow-hidden">
<ScrollArea className="h-full">
{currentStep ? (
<div className="flex flex-col gap-6 p-6">
@@ -281,7 +243,6 @@ export function WizardExecutionPanel({
{currentStep.actions.map((action, idx) => {
const isCompleted = idx < activeActionIndex;
const isActive = idx === activeActionIndex;
const isPending = idx > activeActionIndex;
return (
<div
@@ -328,6 +289,7 @@ export function WizardExecutionPanel({
);
onActionCompleted();
}}
disabled={readOnly}
>
Skip
</Button>
@@ -348,6 +310,7 @@ export function WizardExecutionPanel({
);
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
<Play className="mr-2 h-4 w-4" />
Execute
@@ -364,6 +327,7 @@ export function WizardExecutionPanel({
e.preventDefault();
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
Mark Done
</Button>
@@ -394,6 +358,7 @@ export function WizardExecutionPanel({
{ autoAdvance: false },
);
}}
disabled={readOnly || isExecuting}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
@@ -410,6 +375,7 @@ export function WizardExecutionPanel({
category: "system_issue"
});
}}
disabled={readOnly}
>
<AlertTriangle className="h-3.5 w-3.5" />
</Button>
@@ -432,6 +398,7 @@ export function WizardExecutionPanel({
? "bg-blue-600 hover:bg-blue-700"
: "bg-green-600 hover:bg-green-700"
}`}
disabled={readOnly || isExecuting}
>
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
<ArrowRight className="ml-2 h-5 w-5" />
@@ -445,22 +412,15 @@ export function WizardExecutionPanel({
{currentStep.type === "wizard_action" && (
<div className="rounded-xl border border-dashed p-6 space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3">
<Button
variant="outline"
className="h-12 justify-start"
onClick={() => onExecuteAction("acknowledge")}
>
<CheckCircle className="mr-2 h-4 w-4" />
Acknowledge
</Button>
<Button
variant="outline"
className="h-12 justify-start"
className="h-12 justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
onClick={() => onExecuteAction("intervene")}
disabled={readOnly}
>
<Zap className="mr-2 h-4 w-4" />
Intervene
Flag Issue / Intervention
</Button>
</div>
</div>
@@ -472,6 +432,8 @@ export function WizardExecutionPanel({
</div>
)}
</ScrollArea>
{/* Scroll Hint Fade */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-background to-transparent z-10" />
</div>
</div >
);

View File

@@ -11,8 +11,8 @@ 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 { Progress } from "~/components/ui/progress";
import { Button } from "~/components/ui/button";
import { WebcamPanel } from "./WebcamPanel";
interface WizardMonitoringPanelProps {
rosConnected: boolean;
@@ -33,6 +33,7 @@ interface WizardMonitoringPanelProps {
actionId: string,
parameters: Record<string, unknown>,
) => Promise<unknown>;
readOnly?: boolean;
}
const WizardMonitoringPanel = function WizardMonitoringPanel({
@@ -43,296 +44,315 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
connectRos,
disconnectRos,
executeRosAction,
readOnly = false,
}: WizardMonitoringPanelProps) {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b p-3">
<h2 className="text-sm font-semibold">Robot Control</h2>
<div className="flex h-full flex-col gap-2 p-2">
{/* Camera View - Always Visible */}
<div className="shrink-0 bg-black rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
<WebcamPanel readOnly={readOnly} />
</div>
{/* Robot Status and Controls */}
<ScrollArea className="flex-1">
<div className="space-y-4 p-3">
{/* Robot Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Robot Status</div>
<div className="flex items-center gap-1">
{rosConnected ? (
<Power className="h-3 w-3 text-green-600" />
) : (
<PowerOff className="h-3 w-3 text-gray-400" />
)}
</div>
</div>
{/* Robot Controls - Scrollable */}
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b bg-muted/30 flex items-center gap-2">
<Bot className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Robot Control</span>
</div>
<ScrollArea className="flex-1">
<div className="space-y-4 p-3">
{/* Robot Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
ROS Bridge
</span>
<div className="text-sm font-medium">Robot Status</div>
<div className="flex items-center gap-1">
<Badge
variant={
rosConnected
? "default"
: rosError
? "destructive"
: "outline"
}
className="text-xs"
>
{rosConnecting
? "Connecting..."
: rosConnected
? "Ready"
: rosError
? "Failed"
: "Offline"}
</Badge>
{rosConnected && (
<span className="animate-pulse text-xs text-green-600">
</span>
)}
{rosConnecting && (
<span className="animate-spin text-xs text-blue-600">
</span>
{rosConnected ? (
<Power className="h-3 w-3 text-green-600" />
) : (
<PowerOff className="h-3 w-3 text-gray-400" />
)}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
ROS Bridge
</span>
<div className="flex items-center gap-1">
<Badge
variant={
rosConnected
? "default"
: rosError
? "destructive"
: "outline"
}
className="text-xs"
>
{rosConnecting
? "Connecting..."
: rosConnected
? "Ready"
: rosError
? "Failed"
: "Offline"}
</Badge>
{rosConnected && (
<span className="animate-pulse text-xs text-green-600">
</span>
)}
{rosConnecting && (
<span className="animate-spin text-xs text-blue-600">
</span>
)}
</div>
</div>
</div>
{/* ROS Connection Controls */}
<div className="pt-2">
{!rosConnected ? (
<Button
size="sm"
variant="outline"
className="w-full text-xs"
onClick={() => connectRos()}
disabled={rosConnecting || rosConnected}
>
<Bot className="mr-1 h-3 w-3" />
{rosConnecting
? "Connecting..."
: rosConnected
? "Connected ✓"
: "Connect to NAO6"}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs"
onClick={() => disconnectRos()}
>
<PowerOff className="mr-1 h-3 w-3" />
Disconnect
</Button>
{/* ROS Connection Controls */}
<div className="pt-2">
{!rosConnected ? (
<Button
size="sm"
variant="outline"
className="w-full text-xs"
onClick={() => connectRos()}
disabled={rosConnecting || rosConnected || readOnly}
>
<Bot className="mr-1 h-3 w-3" />
{rosConnecting
? "Connecting..."
: rosConnected
? "Connected ✓"
: "Connect to NAO6"}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs"
onClick={() => disconnectRos()}
disabled={readOnly}
>
<PowerOff className="mr-1 h-3 w-3" />
Disconnect
</Button>
)}
</div>
{rosError && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
{rosError}
</AlertDescription>
</Alert>
)}
{!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>
{rosError && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
{rosError}
</AlertDescription>
</Alert>
<Separator />
{/* Movement Controls */}
{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={() => {
executeRosAction("nao6-ros2", "turn_left", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Turn L
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_forward", {
speed: 0.5,
}).catch(console.error);
}}
disabled={readOnly}
>
Forward
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "turn_right", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
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);
}}
disabled={readOnly}
>
Left
</Button>
<Button
size="sm"
variant="destructive"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
console.error,
);
}}
disabled={readOnly}
>
Stop
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_right", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
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);
}}
disabled={readOnly}
>
Back
</Button>
<div></div>
</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>
<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 disabled:opacity-50"
disabled={readOnly}
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim() && !readOnly) {
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 = "";
}
}}
disabled={readOnly}
>
Say
</Button>
</div>
{/* Preset Actions */}
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Hello! I am NAO!",
}).catch(console.error);
}
}}
disabled={readOnly}
>
Say Hello
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "I am ready!",
}).catch(console.error);
}
}}
disabled={readOnly}
>
Say Ready
</Button>
</div>
</div>
)}
</div>
<Separator />
{/* Movement Controls */}
{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={() => {
executeRosAction("nao6-ros2", "turn_left", {
speed: 0.3,
}).catch(console.error);
}}
>
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={() => {
executeRosAction("nao6-ros2", "turn_right", {
speed: 0.3,
}).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 = "";
}
}}
>
Say
</Button>
</div>
{/* Preset Actions */}
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Hello! I am NAO!",
}).catch(console.error);
}
}}
>
Say Hello
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "I am ready!",
}).catch(console.error);
}
}}
>
Say Ready
</Button>
</div>
</div>
)}
</div>
</ScrollArea>
</ScrollArea>
</div>
</div>
);
};