Add ROS2 bridge

This commit is contained in:
2025-10-16 16:08:49 -04:00
parent 9431bb549b
commit 816b2b9e31
27 changed files with 6360 additions and 507 deletions

View File

@@ -1,20 +1,17 @@
"use client";
import React, { useState, useEffect } from "react";
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { PanelsContainer } from "~/components/experiments/designer/layout/PanelsContainer";
import { TrialControlPanel } from "./panels/TrialControlPanel";
import { ExecutionPanel } from "./panels/ExecutionPanel";
import { MonitoringPanel } from "./panels/MonitoringPanel";
import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
import { api } from "~/trpc/react";
import { useTrialWebSocket } from "~/hooks/useWebSocket";
// import { useTrialWebSocket } from "~/hooks/useWebSocket"; // Removed WebSocket dependency
import { toast } from "sonner";
interface WizardInterfaceProps {
trial: {
@@ -69,6 +66,17 @@ export function WizardInterface({
);
const [elapsedTime, setElapsedTime] = useState(0);
// Persistent tab states to prevent resets from parent re-renders
const [controlPanelTab, setControlPanelTab] = useState<
"control" | "step" | "actions"
>("control");
const [executionPanelTab, setExecutionPanelTab] = useState<
"current" | "timeline" | "events"
>(trial.status === "in_progress" ? "current" : "timeline");
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
"status" | "robot" | "events"
>("status");
// Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
{ experimentId: trial.experimentId },
@@ -94,28 +102,25 @@ export function WizardInterface({
}
};
// Real-time WebSocket connection
const {
isConnected: wsConnected,
isConnecting: wsConnecting,
connectionError: wsError,
trialEvents,
executeTrialAction,
transitionStep,
} = useTrialWebSocket(trial.id);
// Fallback polling for trial updates when WebSocket is not available
// Use polling for real-time updates (no WebSocket dependency)
const { data: pollingData } = api.trials.get.useQuery(
{ id: trial.id },
{
enabled: !wsConnected && !wsConnecting,
refetchInterval: wsConnected ? false : 5000,
refetchInterval: 2000, // Poll every 2 seconds
},
);
// Mock trial events for now (can be populated from database later)
const trialEvents: Array<{
type: string;
timestamp: Date;
data?: unknown;
message?: string;
}> = [];
// Update trial data from polling
React.useEffect(() => {
if (pollingData && !wsConnected) {
if (pollingData) {
setTrial({
...pollingData,
metadata: pollingData.metadata as Record<string, unknown> | null,
@@ -128,7 +133,7 @@ export function WizardInterface({
},
});
}
}, [pollingData, wsConnected]);
}, [pollingData]);
// Transform experiment steps to component format
const steps: StepData[] =
@@ -225,10 +230,37 @@ export function WizardInterface({
// Action handlers
const handleStartTrial = async () => {
console.log(
"[WizardInterface] Starting trial:",
trial.id,
"Current status:",
trial.status,
);
// Check if trial can be started
if (trial.status !== "scheduled") {
toast.error("Trial can only be started from scheduled status");
return;
}
try {
await startTrialMutation.mutateAsync({ id: trial.id });
const result = await startTrialMutation.mutateAsync({ id: trial.id });
console.log("[WizardInterface] Trial started successfully", result);
// Update local state immediately
setTrial((prev) => ({
...prev,
status: "in_progress",
startedAt: new Date(),
}));
setTrialStartTime(new Date());
toast.success("Trial started successfully");
} catch (error) {
console.error("Failed to start trial:", error);
toast.error(
`Failed to start trial: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
};
@@ -240,11 +272,7 @@ export function WizardInterface({
const handleNextStep = () => {
if (currentStepIndex < steps.length - 1) {
setCurrentStepIndex(currentStepIndex + 1);
transitionStep?.({
to_step: currentStepIndex + 1,
from_step: currentStepIndex,
step_name: steps[currentStepIndex + 1]?.name,
});
// Note: Step transitions can be enhanced later with database logging
}
};
@@ -269,7 +297,8 @@ export function WizardInterface({
parameters?: Record<string, unknown>,
) => {
try {
executeTrialAction?.(actionId, parameters ?? {});
console.log("Executing action:", actionId, parameters);
// Note: Action execution can be enhanced later with tRPC mutations
} catch (error) {
console.error("Failed to execute action:", error);
}
@@ -277,7 +306,7 @@ export function WizardInterface({
return (
<div className="flex h-full flex-col">
{/* Status Bar */}
{/* Compact Status Bar */}
<div className="bg-background border-b px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
@@ -308,28 +337,29 @@ export function WizardInterface({
)}
</div>
<div className="text-muted-foreground text-sm">
{trial.experiment.name} {trial.participant.participantCode}
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<div>{trial.experiment.name}</div>
<div>{trial.participant.participantCode}</div>
<Badge variant="outline" className="text-xs">
Polling
</Badge>
</div>
</div>
</div>
{/* WebSocket Connection Status */}
{wsError && (
<Alert className="mx-4 mt-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
WebSocket connection failed. Using fallback polling. Some features
may be limited.
</AlertDescription>
</Alert>
)}
{/* Connection Status */}
<Alert className="mx-4 mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Using polling mode for trial updates (refreshes every 2 seconds).
</AlertDescription>
</Alert>
{/* Main Content - Three Panel Layout */}
<div className="min-h-0 flex-1">
<PanelsContainer
left={
<TrialControlPanel
<WizardControlPanel
trial={trial}
currentStep={currentStep}
steps={steps}
@@ -340,64 +370,33 @@ export function WizardInterface({
onCompleteTrial={handleCompleteTrial}
onAbortTrial={handleAbortTrial}
onExecuteAction={handleExecuteAction}
isConnected={wsConnected}
_isConnected={true}
activeTab={controlPanelTab}
onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending}
/>
}
center={
<ExecutionPanel
<WizardExecutionPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
trialEvents={trialEvents.map((event) => ({
type: event.type ?? "unknown",
timestamp:
"data" in event &&
event.data &&
typeof event.data === "object" &&
"timestamp" in event.data &&
typeof event.data.timestamp === "number"
? new Date(event.data.timestamp)
: new Date(),
data: "data" in event ? event.data : undefined,
message:
"data" in event &&
event.data &&
typeof event.data === "object" &&
"message" in event.data &&
typeof event.data.message === "string"
? event.data.message
: undefined,
}))}
onStepSelect={(index) => setCurrentStepIndex(index)}
trialEvents={trialEvents}
onStepSelect={(index: number) => setCurrentStepIndex(index)}
onExecuteAction={handleExecuteAction}
activeTab={executionPanelTab}
onTabChange={setExecutionPanelTab}
/>
}
right={
<MonitoringPanel
<WizardMonitoringPanel
trial={trial}
trialEvents={trialEvents.map((event) => ({
type: event.type ?? "unknown",
timestamp:
"data" in event &&
event.data &&
typeof event.data === "object" &&
"timestamp" in event.data &&
typeof event.data.timestamp === "number"
? new Date(event.data.timestamp)
: new Date(),
data: "data" in event ? event.data : undefined,
message:
"data" in event &&
event.data &&
typeof event.data === "object" &&
"message" in event.data &&
typeof event.data.message === "string"
? event.data.message
: undefined,
}))}
isConnected={wsConnected}
wsError={wsError ?? undefined}
trialEvents={trialEvents}
isConnected={true}
wsError={undefined}
activeTab={monitoringPanelTab}
onTabChange={setMonitoringPanelTab}
/>
}
showDividers={true}

View File

@@ -0,0 +1,364 @@
"use client";
import React from "react";
import {
Play,
Clock,
CheckCircle,
AlertCircle,
Bot,
User,
Activity,
Zap,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { ScrollArea } from "~/components/ui/scroll-area";
interface StepData {
id: string;
name: string;
description: string | null;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
parameters: Record<string, unknown>;
order: number;
}
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 ExecutionPanelProps {
trial: TrialData;
currentStep: StepData | null;
steps: StepData[];
currentStepIndex: number;
trialEvents: TrialEvent[];
onStepSelect: (index: number) => void;
onExecuteAction: (
actionId: string,
parameters?: Record<string, unknown>,
) => void;
}
export function ExecutionPanel({
trial,
currentStep,
steps,
currentStepIndex,
trialEvents,
onStepSelect,
onExecuteAction,
}: ExecutionPanelProps) {
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";
}
};
if (trial.status === "scheduled") {
return (
<div className="flex h-full items-center justify-center p-8">
<Card className="w-full max-w-md">
<CardContent className="pt-6 text-center">
<Clock className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">Trial Ready to Start</h3>
<p className="text-muted-foreground mb-4">
This trial is scheduled and ready to begin. Use the controls in
the left panel to start execution.
</p>
<div className="text-muted-foreground space-y-1 text-sm">
<div>Experiment: {trial.experiment.name}</div>
<div>Participant: {trial.participant.participantCode}</div>
<div>Session: #{trial.sessionNumber}</div>
{steps.length > 0 && <div>{steps.length} steps to execute</div>}
</div>
</CardContent>
</Card>
</div>
);
}
if (
trial.status === "completed" ||
trial.status === "aborted" ||
trial.status === "failed"
) {
return (
<div className="flex h-full items-center justify-center p-8">
<Card className="w-full max-w-md">
<CardContent className="pt-6 text-center">
<CheckCircle className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">
Trial {trial.status === "completed" ? "Completed" : "Ended"}
</h3>
<p className="text-muted-foreground mb-4">
The trial execution has finished. You can review the results and
captured data.
</p>
{trial.completedAt && (
<div className="text-muted-foreground text-sm">
Ended at {new Date(trial.completedAt).toLocaleString()}
</div>
)}
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex h-full flex-col p-6">
{/* Current Step Header */}
{currentStep && (
<Card className="mb-6">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-3">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-full">
{React.createElement(getStepIcon(currentStep.type), {
className: "h-5 w-5 text-primary",
})}
</div>
<div className="flex-1">
<div className="font-semibold">{currentStep.name}</div>
<div className="text-muted-foreground text-sm">
Step {currentStepIndex + 1} of {steps.length}
</div>
</div>
<Badge variant="secondary" className="ml-auto">
{currentStep.type.replace("_", " ")}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{currentStep.description && (
<p className="text-muted-foreground mb-4">
{currentStep.description}
</p>
)}
{currentStep.type === "wizard_action" && (
<div className="space-y-3">
<div className="text-sm font-medium">Available Actions:</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => onExecuteAction("acknowledge")}
>
<CheckCircle className="mr-2 h-4 w-4" />
Acknowledge
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onExecuteAction("intervene")}
>
<Zap className="mr-2 h-4 w-4" />
Intervene
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
onExecuteAction("note", { content: "Wizard observation" })
}
>
Note
</Button>
</div>
</div>
)}
{currentStep.type === "robot_action" && (
<div className="rounded-lg bg-blue-50 p-3 text-sm">
<div className="flex items-center gap-2 font-medium text-blue-900">
<Bot className="h-4 w-4" />
Robot Action in Progress
</div>
<div className="mt-1 text-blue-700">
The robot is executing this step. Monitor progress in the
right panel.
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Steps Timeline */}
<Card className="flex-1">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Experiment Timeline
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-full">
<div className="space-y-3">
{steps.map((step, index) => {
const status = getStepStatus(index);
const StepIcon = getStepIcon(step.type);
const isActive = index === currentStepIndex;
return (
<div
key={step.id}
className={`hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-lg p-3 transition-colors ${
isActive ? "bg-primary/5 border-primary/20 border" : ""
}`}
onClick={() => onStepSelect(index)}
>
{/* Step Number and Status */}
<div className="flex flex-col items-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
status === "completed"
? "bg-green-100 text-green-700"
: status === "active"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{status === "completed" ? (
<CheckCircle className="h-4 w-4" />
) : (
index + 1
)}
</div>
{index < steps.length - 1 && (
<div
className={`mt-2 h-6 w-0.5 ${
status === "completed"
? "bg-green-200"
: "bg-border"
}`}
/>
)}
</div>
{/* Step Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<StepIcon className="text-muted-foreground h-4 w-4" />
<div className="font-medium">{step.name}</div>
<Badge
variant={getStepVariant(status)}
className="ml-auto text-xs"
>
{step.type.replace("_", " ")}
</Badge>
</div>
{step.description && (
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
{step.description}
</p>
)}
{isActive && trial.status === "in_progress" && (
<div className="mt-2 flex items-center gap-2">
<div className="bg-primary h-2 w-2 animate-pulse rounded-full" />
<span className="text-primary text-xs">
Currently executing
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Recent Events */}
{trialEvents.length > 0 && (
<Card className="mt-4">
<CardHeader className="pb-3">
<CardTitle className="text-sm">Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-24">
<div className="space-y-2">
{trialEvents.slice(-5).map((event, index) => (
<div
key={index}
className="flex items-center justify-between text-xs"
>
<span className="font-medium">{event.type}</span>
<span className="text-muted-foreground">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,334 @@
"use client";
import React from "react";
import {
Bot,
User,
Activity,
Settings,
Wifi,
WifiOff,
AlertCircle,
CheckCircle,
Zap,
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Alert, AlertDescription } from "~/components/ui/alert";
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 MonitoringPanelProps {
trial: TrialData;
trialEvents: TrialEvent[];
isConnected: boolean;
wsError?: string;
}
export function MonitoringPanel({
trial,
trialEvents,
isConnected,
wsError,
}: MonitoringPanelProps) {
const formatTimestamp = (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 space-y-4 p-4">
{/* Connection Status */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Settings className="h-4 w-4" />
Connection Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<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" />
)}
<span className="text-sm">WebSocket</span>
</div>
<Badge
variant={isConnected ? "default" : "secondary"}
className="text-xs"
>
{isConnected ? "Connected" : "Offline"}
</Badge>
</div>
{wsError && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{wsError}</AlertDescription>
</Alert>
)}
<Separator />
<div className="text-muted-foreground space-y-2 text-xs">
<div className="flex justify-between">
<span>Trial ID</span>
<span className="font-mono">{trial.id.slice(-8)}</span>
</div>
<div className="flex justify-between">
<span>Session</span>
<span>#{trial.sessionNumber}</span>
</div>
{trial.startedAt && (
<div className="flex justify-between">
<span>Started</span>
<span>{formatTimestamp(new Date(trial.startedAt))}</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Robot Status */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Bot className="h-4 w-4" />
Robot Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Status</span>
<Badge variant="outline" className="text-xs">
{isConnected ? "Ready" : "Unknown"}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Battery</span>
<span className="text-muted-foreground text-sm">--</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Position</span>
<span className="text-muted-foreground text-sm">--</span>
</div>
<Separator />
<div className="bg-muted/50 text-muted-foreground rounded-lg p-2 text-center text-xs">
Robot monitoring requires WebSocket connection
</div>
</CardContent>
</Card>
{/* Participant Info */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<User className="h-4 w-4" />
Participant
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Code</span>
<span className="font-mono">
{trial.participant.participantCode}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Session</span>
<span>#{trial.sessionNumber}</span>
</div>
{trial.participant.demographics && (
<div className="flex justify-between">
<span className="text-muted-foreground">Demographics</span>
<span className="text-xs">
{Object.keys(trial.participant.demographics).length} fields
</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Live Events */}
<Card className="min-h-0 flex-1">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Activity className="h-4 w-4" />
Live Events
{trialEvents.length > 0 && (
<Badge variant="secondary" className="ml-auto text-xs">
{trialEvents.length}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="h-full min-h-0 pb-2">
<ScrollArea className="h-full">
{trialEvents.length === 0 ? (
<div className="text-muted-foreground flex h-32 items-center justify-center text-sm">
No events yet
</div>
) : (
<div className="space-y-3">
{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 text-xs"
>
<div className={`mt-0.5 ${eventColor}`}>
<EventIcon className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium capitalize">
{event.type.replace(/_/g, " ")}
</div>
{event.message && (
<div className="text-muted-foreground mt-1">
{event.message}
</div>
)}
<div className="text-muted-foreground mt-1">
{formatTimestamp(event.timestamp)}
</div>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
{/* System Info */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Zap className="h-4 w-4" />
System
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground space-y-2 text-xs">
<div className="flex justify-between">
<span>Experiment</span>
<span
className="ml-2 max-w-24 truncate"
title={trial.experiment.name}
>
{trial.experiment.name}
</span>
</div>
<div className="flex justify-between">
<span>Study ID</span>
<span className="font-mono">
{trial.experiment.studyId.slice(-8)}
</span>
</div>
<div className="flex justify-between">
<span>Platform</span>
<span>HRIStudio</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,296 @@
"use client";
import React from "react";
import {
Play,
Pause,
SkipForward,
CheckCircle,
X,
Clock,
AlertCircle,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface StepData {
id: string;
name: string;
description: string | null;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
parameters: Record<string, unknown>;
order: number;
}
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 TrialControlPanelProps {
trial: TrialData;
currentStep: StepData | null;
steps: StepData[];
currentStepIndex: number;
onStartTrial: () => void;
onPauseTrial: () => void;
onNextStep: () => void;
onCompleteTrial: () => void;
onAbortTrial: () => void;
onExecuteAction: (
actionId: string,
parameters?: Record<string, unknown>,
) => void;
isConnected: boolean;
}
export function TrialControlPanel({
trial,
currentStep,
steps,
currentStepIndex,
onStartTrial,
onPauseTrial,
onNextStep,
onCompleteTrial,
onAbortTrial,
onExecuteAction,
isConnected,
}: TrialControlPanelProps) {
const progress =
steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
const getStatusConfig = (status: string) => {
switch (status) {
case "scheduled":
return { variant: "outline" as const, icon: Clock };
case "in_progress":
return { variant: "default" as const, icon: Play };
case "completed":
return { variant: "secondary" as const, icon: CheckCircle };
case "aborted":
case "failed":
return { variant: "destructive" as const, icon: X };
default:
return { variant: "outline" as const, icon: Clock };
}
};
const statusConfig = getStatusConfig(trial.status);
const StatusIcon = statusConfig.icon;
return (
<div className="flex h-full flex-col space-y-4 p-4">
{/* Trial Status Card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-sm">
<span>Trial Status</span>
<Badge
variant={statusConfig.variant}
className="flex items-center gap-1"
>
<StatusIcon className="h-3 w-3" />
{trial.status.replace("_", " ")}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Session</span>
<span>#{trial.sessionNumber}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Participant</span>
<span className="font-mono">
{trial.participant.participantCode}
</span>
</div>
{trial.status === "in_progress" && (
<>
<Separator />
<div className="flex justify-between">
<span className="text-muted-foreground">Progress</span>
<span>
{currentStepIndex + 1} of {steps.length}
</span>
</div>
<Progress value={progress} className="h-2" />
</>
)}
</div>
{/* Connection Status */}
<div className="flex items-center justify-between pt-2">
<span className="text-muted-foreground text-sm">Connection</span>
<Badge
variant={isConnected ? "default" : "outline"}
className="text-xs"
>
{isConnected ? "Live" : "Polling"}
</Badge>
</div>
</CardContent>
</Card>
{/* Trial Controls */}
<Card className="flex-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm">Controls</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{trial.status === "scheduled" && (
<Button onClick={onStartTrial} className="w-full" size="sm">
<Play className="mr-2 h-4 w-4" />
Start Trial
</Button>
)}
{trial.status === "in_progress" && (
<>
<div className="grid grid-cols-2 gap-2">
<Button
onClick={onPauseTrial}
variant="outline"
size="sm"
disabled={!isConnected}
>
<Pause className="mr-1 h-3 w-3" />
Pause
</Button>
<Button
onClick={onNextStep}
disabled={currentStepIndex >= steps.length - 1}
size="sm"
>
<SkipForward className="mr-1 h-3 w-3" />
Next
</Button>
</div>
<Separator />
<div className="space-y-2">
<Button
onClick={onCompleteTrial}
variant="outline"
className="w-full"
size="sm"
>
<CheckCircle className="mr-2 h-4 w-4" />
Complete Trial
</Button>
<Button
onClick={onAbortTrial}
variant="destructive"
className="w-full"
size="sm"
>
<X className="mr-2 h-4 w-4" />
Abort Trial
</Button>
</div>
</>
)}
{(trial.status === "completed" || trial.status === "aborted") && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Trial has ended. All controls are disabled.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Current Step Info */}
{currentStep && trial.status === "in_progress" && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">Current Step</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="text-sm font-medium">{currentStep.name}</div>
{currentStep.description && (
<p className="text-muted-foreground line-clamp-3 text-xs">
{currentStep.description}
</p>
)}
<div className="flex items-center justify-between pt-1">
<Badge variant="outline" className="text-xs">
{currentStep.type.replace("_", " ")}
</Badge>
<span className="text-muted-foreground text-xs">
Step {currentStepIndex + 1}
</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Quick Actions */}
{trial.status === "in_progress" &&
currentStep?.type === "wizard_action" && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">Quick Actions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => onExecuteAction("acknowledge")}
>
<CheckCircle className="mr-2 h-3 w-3" />
Acknowledge
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => onExecuteAction("intervene")}
>
<AlertCircle className="mr-2 h-3 w-3" />
Intervene
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,429 @@
"use client";
import React from "react";
import {
Play,
Pause,
SkipForward,
CheckCircle,
X,
Clock,
AlertCircle,
Settings,
Zap,
User,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { Separator } from "~/components/ui/separator";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area";
interface StepData {
id: string;
name: string;
description: string | null;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
parameters: Record<string, unknown>;
order: number;
}
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 WizardControlPanelProps {
trial: TrialData;
currentStep: StepData | null;
steps: StepData[];
currentStepIndex: number;
onStartTrial: () => void;
onPauseTrial: () => void;
onNextStep: () => void;
onCompleteTrial: () => void;
onAbortTrial: () => void;
onExecuteAction: (
actionId: string,
parameters?: Record<string, unknown>,
) => void;
_isConnected: boolean;
activeTab: "control" | "step" | "actions";
onTabChange: (tab: "control" | "step" | "actions") => void;
isStarting?: boolean;
}
export function WizardControlPanel({
trial,
currentStep,
steps,
currentStepIndex,
onStartTrial,
onPauseTrial,
onNextStep,
onCompleteTrial,
onAbortTrial,
onExecuteAction,
_isConnected,
activeTab,
onTabChange,
isStarting = false,
}: WizardControlPanelProps) {
const progress =
steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
const getStatusConfig = (status: string) => {
switch (status) {
case "scheduled":
return { variant: "outline" as const, icon: Clock };
case "in_progress":
return { variant: "default" as const, icon: Play };
case "completed":
return { variant: "secondary" as const, icon: CheckCircle };
case "aborted":
case "failed":
return { variant: "destructive" as const, icon: X };
default:
return { variant: "outline" as const, icon: Clock };
}
};
const statusConfig = getStatusConfig(trial.status);
const StatusIcon = statusConfig.icon;
return (
<div className="flex h-full flex-col">
{/* Trial Info Header */}
<div className="border-b p-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Badge
variant={statusConfig.variant}
className="flex items-center gap-1"
>
<StatusIcon className="h-3 w-3" />
{trial.status.replace("_", " ")}
</Badge>
<span className="text-muted-foreground text-xs">
Session #{trial.sessionNumber}
</span>
</div>
<div className="text-sm font-medium">
{trial.participant.participantCode}
</div>
{trial.status === "in_progress" && steps.length > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Progress</span>
<span>
{currentStepIndex + 1} of {steps.length}
</span>
</div>
<Progress value={progress} className="h-1.5" />
</div>
)}
</div>
</div>
{/* Tabbed Content */}
<Tabs
value={activeTab}
onValueChange={(value: string) => {
if (value === "control" || value === "step" || value === "actions") {
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="control" className="text-xs">
<Settings className="mr-1 h-3 w-3" />
Control
</TabsTrigger>
<TabsTrigger value="step" className="text-xs">
<Play 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>
</TabsList>
</div>
<div className="min-h-0 flex-1">
{/* Trial Control Tab */}
<TabsContent
value="control"
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
>
<ScrollArea className="h-full">
<div className="space-y-3 p-3">
{trial.status === "scheduled" && (
<Button
onClick={() => {
console.log("[WizardControlPanel] Start Trial clicked");
onStartTrial();
}}
className="w-full"
size="sm"
disabled={isStarting}
>
<Play className="mr-2 h-4 w-4" />
{isStarting ? "Starting..." : "Start Trial"}
</Button>
)}
{trial.status === "in_progress" && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<Button
onClick={onPauseTrial}
variant="outline"
size="sm"
disabled={false}
>
<Pause className="mr-1 h-3 w-3" />
Pause
</Button>
<Button
onClick={onNextStep}
disabled={currentStepIndex >= steps.length - 1}
size="sm"
>
<SkipForward className="mr-1 h-3 w-3" />
Next
</Button>
</div>
<Separator />
<Button
onClick={onCompleteTrial}
variant="outline"
className="w-full"
size="sm"
>
<CheckCircle className="mr-2 h-4 w-4" />
Complete Trial
</Button>
<Button
onClick={onAbortTrial}
variant="destructive"
className="w-full"
size="sm"
>
<X className="mr-2 h-4 w-4" />
Abort Trial
</Button>
</div>
)}
{(trial.status === "completed" ||
trial.status === "aborted") && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Trial has ended. All controls are disabled.
</AlertDescription>
</Alert>
)}
{/* Connection Status */}
<Separator />
<div className="space-y-2">
<div className="text-xs font-medium">Connection</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Status
</span>
<Badge variant="default" className="text-xs">
Polling
</Badge>
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
{/* Current Step Tab */}
<TabsContent
value="step"
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
>
<ScrollArea className="h-full">
<div className="p-3">
{currentStep && trial.status === "in_progress" ? (
<div className="space-y-3">
<div className="space-y-2">
<div className="text-sm font-medium">
{currentStep.name}
</div>
<Badge variant="outline" className="text-xs">
{currentStep.type.replace("_", " ")}
</Badge>
</div>
{currentStep.description && (
<div className="text-muted-foreground text-xs">
{currentStep.description}
</div>
)}
<Separator />
<div className="space-y-2">
<div className="text-xs font-medium">Step Progress</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Current</span>
<span>Step {currentStepIndex + 1}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Remaining</span>
<span>{steps.length - currentStepIndex - 1} steps</span>
</div>
</div>
{currentStep.type === "robot_action" && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
Robot is executing this step. Monitor progress in the
monitoring panel.
</AlertDescription>
</Alert>
)}
</div>
) : (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-xs">
{trial.status === "scheduled"
? "Start trial to see current step"
: trial.status === "in_progress"
? "No current step"
: "Trial has ended"}
</div>
)}
</div>
</ScrollArea>
</TabsContent>
{/* Quick Actions Tab */}
<TabsContent
value="actions"
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
>
<ScrollArea className="h-full">
<div className="space-y-2 p-3">
{trial.status === "in_progress" ? (
<>
<div className="mb-2 text-xs font-medium">
Quick Actions
</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => {
console.log("[WizardControlPanel] Acknowledge clicked");
onExecuteAction("acknowledge");
}}
disabled={false}
>
<CheckCircle className="mr-2 h-3 w-3" />
Acknowledge
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => {
console.log("[WizardControlPanel] Intervene clicked");
onExecuteAction("intervene");
}}
disabled={false}
>
<AlertCircle className="mr-2 h-3 w-3" />
Intervene
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => {
console.log("[WizardControlPanel] Add Note clicked");
onExecuteAction("note", { content: "Wizard note" });
}}
disabled={false}
>
<User className="mr-2 h-3 w-3" />
Add Note
</Button>
<Separator />
{currentStep?.type === "wizard_action" && (
<div className="space-y-2">
<div className="text-xs font-medium">Step Actions</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => onExecuteAction("step_complete")}
disabled={false}
>
<CheckCircle className="mr-2 h-3 w-3" />
Mark Complete
</Button>
</div>
)}
</>
) : (
<div className="flex h-32 items-center justify-center">
<div className="text-muted-foreground text-center text-xs">
{trial.status === "scheduled"
? "Start trial to access actions"
: "Actions unavailable - trial not active"}
</div>
</div>
)}
</div>
</ScrollArea>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,489 @@
"use client";
import React from "react";
import {
Play,
Clock,
CheckCircle,
AlertCircle,
Bot,
User,
Activity,
Zap,
Eye,
List,
} 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;
name: string;
description: string | null;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
parameters: Record<string, unknown>;
order: number;
}
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 WizardExecutionPanelProps {
trial: TrialData;
currentStep: StepData | null;
steps: StepData[];
currentStepIndex: number;
trialEvents: TrialEvent[];
onStepSelect: (index: number) => void;
onExecuteAction: (
actionId: string,
parameters?: Record<string, unknown>,
) => void;
activeTab: "current" | "timeline" | "events";
onTabChange: (tab: "current" | "timeline" | "events") => void;
}
export function WizardExecutionPanel({
trial,
currentStep,
steps,
currentStepIndex,
trialEvents,
onStepSelect,
onExecuteAction,
activeTab,
onTabChange,
}: WizardExecutionPanelProps) {
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 (
<div className="flex h-full flex-col">
<div className="border-b p-3">
<h3 className="text-sm font-medium">Trial Ready</h3>
<p className="text-muted-foreground text-xs">
{steps.length} steps prepared for execution
</p>
</div>
<div className="flex h-full flex-1 items-center justify-center p-6">
<div className="w-full max-w-md space-y-3 text-center">
<Clock className="text-muted-foreground mx-auto h-8 w-8" />
<div>
<h4 className="text-sm font-medium">Ready to Begin</h4>
<p className="text-muted-foreground text-xs">
Use the control panel to start this trial
</p>
</div>
<div className="text-muted-foreground space-y-1 text-xs">
<div>Experiment: {trial.experiment.name}</div>
<div>Participant: {trial.participant.participantCode}</div>
</div>
</div>
</div>
</div>
);
}
// Post-trial state
if (
trial.status === "completed" ||
trial.status === "aborted" ||
trial.status === "failed"
) {
return (
<div className="flex h-full flex-col">
<div className="border-b p-3">
<h3 className="text-sm font-medium">
Trial {trial.status === "completed" ? "Completed" : "Ended"}
</h3>
<p className="text-muted-foreground text-xs">
{trial.completedAt &&
`Ended at ${new Date(trial.completedAt).toLocaleTimeString()}`}
</p>
</div>
<div className="flex h-full flex-1 items-center justify-center p-6">
<div className="w-full max-w-md space-y-3 text-center">
<CheckCircle className="text-muted-foreground mx-auto h-8 w-8" />
<div>
<h4 className="text-sm font-medium">Execution Complete</h4>
<p className="text-muted-foreground text-xs">
Review results and captured data
</p>
</div>
<div className="text-muted-foreground text-xs">
{trialEvents.length} events recorded
</div>
</div>
</div>
</div>
);
}
// Active trial state
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">Trial Execution</h3>
<Badge variant="secondary" className="text-xs">
{currentStepIndex + 1} / {steps.length}
</Badge>
</div>
{currentStep && (
<p className="text-muted-foreground mt-1 text-xs">
{currentStep.name}
</p>
)}
</div>
{/* Tabbed Content */}
<Tabs
value={activeTab}
onValueChange={(value: string) => {
if (
value === "current" ||
value === "timeline" ||
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="current" className="text-xs">
<Eye className="mr-1 h-3 w-3" />
Current
</TabsTrigger>
<TabsTrigger value="timeline" className="text-xs">
<List className="mr-1 h-3 w-3" />
Timeline
</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">
{/* Current Step Tab */}
<TabsContent value="current" className="m-0 h-full">
<div className="h-full">
{currentStep ? (
<div className="flex h-full flex-col p-4">
{/* Current Step Display */}
<div className="flex-1 space-y-4 text-left">
<div className="flex items-start gap-3">
<div className="bg-primary/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full">
{React.createElement(getStepIcon(currentStep.type), {
className: "h-5 w-5 text-primary",
})}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-medium">
{currentStep.name}
</h4>
<Badge variant="outline" className="mt-1 text-xs">
{currentStep.type.replace("_", " ")}
</Badge>
</div>
</div>
{currentStep.description && (
<div className="text-muted-foreground text-sm">
{currentStep.description}
</div>
)}
{/* Step-specific content */}
{currentStep.type === "wizard_action" && (
<div className="space-y-3">
<div className="text-sm font-medium">
Available Actions
</div>
<div className="space-y-2">
<Button
size="sm"
variant="outline"
className="w-full justify-start"
onClick={() => onExecuteAction("acknowledge")}
>
<CheckCircle className="mr-2 h-4 w-4" />
Acknowledge Step
</Button>
<Button
size="sm"
variant="outline"
className="w-full justify-start"
onClick={() => onExecuteAction("intervene")}
>
<Zap className="mr-2 h-4 w-4" />
Manual Intervention
</Button>
<Button
size="sm"
variant="outline"
className="w-full justify-start"
onClick={() =>
onExecuteAction("note", {
content: "Step observation",
})
}
>
<User className="mr-2 h-4 w-4" />
Add Observation
</Button>
</div>
</div>
)}
{currentStep.type === "robot_action" && (
<Alert>
<Bot className="h-4 w-4" />
<AlertDescription className="text-sm">
<div className="font-medium">
Robot Action in Progress
</div>
<div className="mt-1 text-xs">
The robot is executing this step. Monitor status in
the monitoring panel.
</div>
</AlertDescription>
</Alert>
)}
{currentStep.type === "parallel_steps" && (
<Alert>
<Activity className="h-4 w-4" />
<AlertDescription className="text-sm">
<div className="font-medium">Parallel Execution</div>
<div className="mt-1 text-xs">
Multiple actions are running simultaneously.
</div>
</AlertDescription>
</Alert>
)}
</div>
</div>
) : (
<div className="flex h-full items-center justify-center p-6">
<div className="w-full max-w-md text-center">
<div className="text-muted-foreground text-sm">
No current step available
</div>
</div>
</div>
)}
</div>
</TabsContent>
{/* Timeline Tab */}
<TabsContent value="timeline" className="m-0 h-full">
<ScrollArea className="h-full">
<div className="space-y-2 p-3">
{steps.map((step, index) => {
const status = getStepStatus(index);
const StepIcon = getStepIcon(step.type);
const isActive = index === currentStepIndex;
return (
<div
key={step.id}
className={`hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-lg p-2 transition-colors ${
isActive ? "bg-primary/5 border-primary/20 border" : ""
}`}
onClick={() => onStepSelect(index)}
>
{/* Step Number and Status */}
<div className="flex flex-col items-center">
<div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
status === "completed"
? "bg-green-100 text-green-700"
: status === "active"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{status === "completed" ? (
<CheckCircle className="h-3 w-3" />
) : (
index + 1
)}
</div>
{index < steps.length - 1 && (
<div
className={`mt-1 h-4 w-0.5 ${
status === "completed"
? "bg-green-200"
: "bg-border"
}`}
/>
)}
</div>
{/* Step Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<StepIcon className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<div className="truncate text-sm font-medium">
{step.name}
</div>
<Badge
variant={getStepVariant(status)}
className="ml-auto flex-shrink-0 text-xs"
>
{step.type.replace("_", " ")}
</Badge>
</div>
{step.description && (
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">
{step.description}
</p>
)}
{isActive && trial.status === "in_progress" && (
<div className="mt-1 flex items-center gap-1">
<div className="bg-primary h-1.5 w-1.5 animate-pulse rounded-full" />
<span className="text-primary text-xs">
Executing
</span>
</div>
)}
</div>
</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="flex h-32 items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
No events recorded yet
</div>
</div>
) : (
<div className="space-y-2">
{trialEvents
.slice()
.reverse()
.map((event, index) => (
<div
key={`${event.timestamp.getTime()}-${index}`}
className="border-border/50 flex items-start gap-2 rounded-lg border p-2"
>
<div className="bg-muted flex h-6 w-6 flex-shrink-0 items-center justify-center rounded">
<Activity 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 text-xs">
{event.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,672 @@
"use client";
import React from "react";
import {
Bot,
User,
Activity,
Wifi,
WifiOff,
AlertCircle,
CheckCircle,
Clock,
Power,
PowerOff,
Eye,
} 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";
// import { useRosBridge } from "~/hooks/useRosBridge"; // Removed ROS dependency
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;
}
export function WizardMonitoringPanel({
trial,
trialEvents,
isConnected,
wsError,
activeTab,
onTabChange,
}: WizardMonitoringPanelProps) {
// Mock robot status for development (ROS bridge removed for now)
const mockRobotStatus = {
connected: false,
battery: 85,
position: { x: 0, y: 0, theta: 0 },
joints: {},
sensors: {},
lastUpdate: new Date(),
};
const rosConnected = false;
const rosConnecting = false;
const rosError = null;
const robotStatus = mockRobotStatus;
// const connectRos = () => console.log("ROS connection not implemented yet");
const disconnectRos = () =>
console.log("ROS disconnection not implemented yet");
const executeRobotAction = (
action: string,
parameters?: Record<string, unknown>,
) => console.log("Robot action:", action, parameters);
const formatTimestamp = (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>
{/* 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">
WebSocket
</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">
<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>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
ROS Bridge
</span>
<Badge
variant={rosConnected ? "default" : "outline"}
className="text-xs"
>
{rosConnecting
? "Connecting..."
: rosConnected
? "Connected"
: "Offline"}
</Badge>
</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
? `${Math.round(robotStatus.battery * 100)}%`
: "--"}
</span>
<Progress
value={robotStatus ? robotStatus.battery * 100 : 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 */}
<div className="pt-2">
{!rosConnected ? (
<Button
size="sm"
variant="outline"
className="w-full text-xs"
onClick={() =>
console.log("Connect robot (not implemented)")
}
disabled={true}
>
<Bot className="mr-1 h-3 w-3" />
Connect Robot (Coming Soon)
</Button>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs"
onClick={disconnectRos}
>
<PowerOff className="mr-1 h-3 w-3" />
Disconnect Robot
</Button>
)}
</div>
{rosError && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
ROS Error: {rosError}
</AlertDescription>
</Alert>
)}
</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>
{/* Quick Robot Actions */}
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Quick Actions</div>
<div className="grid grid-cols-2 gap-1">
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("say_text", {
text: "Hello from wizard!",
})
}
>
Say Hello
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("play_animation", {
animation: "Hello",
})
}
>
Wave
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("set_led_color", {
color: "blue",
intensity: 1.0,
})
}
>
Blue LEDs
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() =>
executeRobotAction("turn_head", {
yaw: 0,
pitch: 0,
speed: 0.3,
})
}
>
Center Head
</Button>
</div>
</div>
)}
{!rosConnected && !rosConnecting && (
<Alert className="mt-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
Connect to ROS bridge for live robot monitoring and
control
</AlertDescription>
</Alert>
)}
</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>
);
}