feat: Introduce dedicated participant, experiment, and trial detail/edit pages, enable MinIO, and refactor dashboard navigation.

This commit is contained in:
2025-12-11 20:04:52 -05:00
parent 5be4ff0372
commit d83c02759a
45 changed files with 4123 additions and 1455 deletions

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -9,6 +10,12 @@ import { PanelsContainer } from "~/components/experiments/designer/layout/Panels
import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
import { WizardObservationPane } from "./panels/WizardObservationPane";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos";
import { toast } from "sonner";
@@ -42,6 +49,16 @@ interface WizardInterfaceProps {
userRole: string;
}
interface ActionData {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}
interface StepData {
id: string;
name: string;
@@ -53,6 +70,7 @@ interface StepData {
| "conditional_branch";
parameters: Record<string, unknown>;
order: number;
actions: ActionData[];
}
export const WizardInterface = React.memo(function WizardInterface({
@@ -65,6 +83,7 @@ export const WizardInterface = React.memo(function WizardInterface({
initialTrial.startedAt ? new Date(initialTrial.startedAt) : null,
);
const [elapsedTime, setElapsedTime] = useState(0);
const router = useRouter();
// Persistent tab states to prevent resets from parent re-renders
const [controlPanelTab, setControlPanelTab] = useState<
@@ -73,9 +92,16 @@ export const WizardInterface = React.memo(function WizardInterface({
const [executionPanelTab, setExecutionPanelTab] = useState<
"current" | "timeline" | "events"
>(trial.status === "in_progress" ? "current" : "timeline");
const [isExecutingAction, setIsExecutingAction] = useState(false);
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
"status" | "robot" | "events"
>("status");
const [completedActionsCount, setCompletedActionsCount] = useState(0);
// Reset completed actions when step changes
useEffect(() => {
setCompletedActionsCount(0);
}, [currentStepIndex]);
// Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
@@ -100,6 +126,13 @@ export const WizardInterface = React.memo(function WizardInterface({
},
});
// Log robot action mutation (for client-side execution)
const logRobotActionMutation = api.trials.logRobotAction.useMutation({
onError: (error) => {
console.error("Failed to log robot action:", error);
},
});
// Map database step types to component step types
const mapStepType = (dbType: string) => {
switch (dbType) {
@@ -136,6 +169,7 @@ export const WizardInterface = React.memo(function WizardInterface({
connect: connectRos,
disconnect: disconnectRos,
executeRobotAction: executeRosAction,
setAutonomousLife,
} = useWizardRos({
autoConnect: true,
onActionCompleted,
@@ -152,6 +186,15 @@ export const WizardInterface = React.memo(function WizardInterface({
},
);
// Poll for trial events
const { data: fetchedEvents } = api.trials.getEvents.useQuery(
{ trialId: trial.id, limit: 100 },
{
refetchInterval: 3000,
staleTime: 1000,
}
);
// Update local trial state from polling
useEffect(() => {
if (pollingData) {
@@ -168,7 +211,15 @@ export const WizardInterface = React.memo(function WizardInterface({
}
}, [pollingData]);
// Auto-start trial on mount if scheduled
useEffect(() => {
if (trial.status === "scheduled") {
handleStartTrial();
}
}, []); // Run once on mount
// Trial events from robot actions
const trialEvents = useMemo<
Array<{
type: string;
@@ -176,7 +227,38 @@ export const WizardInterface = React.memo(function WizardInterface({
data?: unknown;
message?: string;
}>
>(() => [], []);
>(() => {
return (fetchedEvents ?? []).map(event => {
let message: string | undefined;
const eventData = event.data as any;
// Extract or generate message based on event type
if (event.eventType.startsWith('annotation_')) {
message = eventData?.description || eventData?.label || 'Annotation added';
} else if (event.eventType.startsWith('robot_action_')) {
const actionName = event.eventType.replace('robot_action_', '').replace(/_/g, ' ');
message = `Robot action: ${actionName}`;
} else if (event.eventType === 'trial_started') {
message = 'Trial started';
} else if (event.eventType === 'trial_completed') {
message = 'Trial completed';
} else if (event.eventType === 'step_changed') {
message = `Step changed to: ${eventData?.stepName || 'next step'}`;
} else if (event.eventType.startsWith('wizard_')) {
message = eventData?.notes || eventData?.message || event.eventType.replace('wizard_', '').replace(/_/g, ' ');
} else {
// Generic fallback
message = eventData?.notes || eventData?.message || eventData?.description || event.eventType.replace(/_/g, ' ');
}
return {
type: event.eventType,
timestamp: new Date(event.timestamp),
data: event.data,
message,
};
}).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
}, [fetchedEvents]);
// Transform experiment steps to component format
const steps: StepData[] =
@@ -187,6 +269,15 @@ export const WizardInterface = React.memo(function WizardInterface({
type: mapStepType(step.type),
parameters: step.parameters ?? {},
order: step.order ?? index,
actions: step.actions?.map((action) => ({
id: action.id,
name: action.name,
description: action.description,
type: action.type,
parameters: action.parameters ?? {},
order: action.order,
pluginId: action.pluginId,
})) ?? [],
})) ?? [];
const currentStep = steps[currentStepIndex] ?? null;
@@ -261,6 +352,8 @@ export const WizardInterface = React.memo(function WizardInterface({
status: data.status,
completedAt: data.completedAt,
});
toast.success("Trial completed! Redirecting to analysis...");
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
}
},
});
@@ -314,6 +407,7 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleNextStep = () => {
if (currentStepIndex < steps.length - 1) {
setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues
setCurrentStepIndex(currentStepIndex + 1);
// Note: Step transitions can be enhanced later with database logging
}
@@ -335,15 +429,72 @@ export const WizardInterface = React.memo(function WizardInterface({
}
};
// Mutations for annotations
const addAnnotationMutation = api.trials.addAnnotation.useMutation({
onSuccess: () => {
toast.success("Note added");
},
onError: (error) => {
toast.error("Failed to add note", { description: error.message });
},
});
const handleAddAnnotation = async (
description: string,
category?: string,
tags?: string[],
) => {
await addAnnotationMutation.mutateAsync({
trialId: trial.id,
description,
category,
tags,
});
};
// Mutation for events (Acknowledge)
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => toast.success("Event logged"),
});
// Mutation for interventions
const addInterventionMutation = api.trials.addIntervention.useMutation({
onSuccess: () => toast.success("Intervention logged"),
});
const handleExecuteAction = async (
actionId: string,
parameters?: Record<string, unknown>,
) => {
try {
console.log("Executing action:", actionId, parameters);
if (actionId === "acknowledge") {
await logEventMutation.mutateAsync({
trialId: trial.id,
type: "wizard_acknowledge",
data: parameters,
});
handleNextStep();
} else if (actionId === "intervene") {
await addInterventionMutation.mutateAsync({
trialId: trial.id,
type: "manual_intervention",
description: "Wizard manual intervention triggered",
data: parameters,
});
} else if (actionId === "note") {
await addAnnotationMutation.mutateAsync({
trialId: trial.id,
description: String(parameters?.content || "Quick note"),
category: String(parameters?.category || "quick_note")
});
}
// Note: Action execution can be enhanced later with tRPC mutations
} catch (error) {
console.error("Failed to execute action:", error);
toast.error("Failed to execute action");
}
};
@@ -352,22 +503,34 @@ export const WizardInterface = React.memo(function WizardInterface({
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => {
try {
setIsExecutingAction(true);
// Try direct WebSocket execution first for better performance
if (rosConnected) {
try {
await executeRosAction(pluginName, actionId, parameters);
const result = await executeRosAction(pluginName, actionId, parameters);
const duration =
result.endTime && result.startTime
? result.endTime.getTime() - result.startTime.getTime()
: 0;
// Log to trial events for data capture
await executeRobotActionMutation.mutateAsync({
await logRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
duration,
result: { status: result.status },
});
toast.success(`Robot action executed: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
} catch (rosError) {
console.warn(
"WebSocket execution failed, falling back to tRPC:",
@@ -383,6 +546,9 @@ export const WizardInterface = React.memo(function WizardInterface({
});
toast.success(`Robot action executed via fallback: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
}
} else {
// Use tRPC execution if WebSocket not connected
@@ -394,17 +560,51 @@ export const WizardInterface = React.memo(function WizardInterface({
});
toast.success(`Robot action executed: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
}
} catch (error) {
console.error("Failed to execute robot action:", error);
toast.error(`Failed to execute robot action: ${actionId}`, {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsExecutingAction(false);
}
},
[rosConnected, executeRosAction, executeRobotActionMutation, trial.id],
);
const handleSkipAction = useCallback(
async (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => {
try {
await logRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
duration: 0,
result: { skipped: true },
});
toast.info(`Action skipped: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
} catch (error) {
console.error("Failed to skip action:", error);
toast.error("Failed to skip action");
}
},
[logRobotActionMutation, trial.id],
);
return (
<div className="flex h-full flex-col">
{/* Compact Status Bar */}
@@ -451,58 +651,78 @@ export const WizardInterface = React.memo(function WizardInterface({
</div>
</div>
{/* No connection status alert - ROS connection shown in monitoring panel */}
{/* Main Content - Three Panel Layout */}
{/* Main Content with Vertical Resizable Split */}
<div className="min-h-0 flex-1">
<PanelsContainer
left={
<WizardControlPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
onStartTrial={handleStartTrial}
onPauseTrial={handlePauseTrial}
onNextStep={handleNextStep}
onCompleteTrial={handleCompleteTrial}
onAbortTrial={handleAbortTrial}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
_isConnected={rosConnected}
activeTab={controlPanelTab}
onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending}
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={75} minSize={30}>
<PanelsContainer
left={
<WizardControlPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
onStartTrial={handleStartTrial}
onPauseTrial={handlePauseTrial}
onNextStep={handleNextStep}
onCompleteTrial={handleCompleteTrial}
onAbortTrial={handleAbortTrial}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
_isConnected={rosConnected}
activeTab={controlPanelTab}
onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending}
onSetAutonomousLife={setAutonomousLife}
/>
}
center={
<WizardExecutionPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
trialEvents={trialEvents}
onStepSelect={(index: number) => setCurrentStepIndex(index)}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
activeTab={executionPanelTab}
onTabChange={setExecutionPanelTab}
onSkipAction={handleSkipAction}
isExecuting={isExecutingAction}
onNextStep={handleNextStep}
completedActionsCount={completedActionsCount}
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onCompleteTrial={handleCompleteTrial}
/>
}
right={
<WizardMonitoringPanel
rosConnected={rosConnected}
rosConnecting={rosConnecting}
rosError={rosError ?? undefined}
robotStatus={robotStatus}
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
/>
}
showDividers={true}
className="h-full"
/>
}
center={
<WizardExecutionPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={25} minSize={10}>
<WizardObservationPane
onAddAnnotation={handleAddAnnotation}
isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents}
onStepSelect={(index: number) => setCurrentStepIndex(index)}
onExecuteAction={handleExecuteAction}
activeTab={executionPanelTab}
onTabChange={setExecutionPanelTab}
/>
}
right={
<WizardMonitoringPanel
rosConnected={rosConnected}
rosConnecting={rosConnecting}
rosError={rosError ?? undefined}
robotStatus={robotStatus}
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
/>
}
showDividers={true}
className="h-full"
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
);

View File

@@ -19,6 +19,8 @@ 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 { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area";
@@ -35,6 +37,15 @@ interface StepData {
| "conditional_branch";
parameters: Record<string, unknown>;
order: number;
actions?: {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}[];
}
interface TrialData {
@@ -86,6 +97,7 @@ interface WizardControlPanelProps {
activeTab: "control" | "step" | "actions" | "robot";
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
isStarting?: boolean;
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
}
export function WizardControlPanel({
@@ -105,65 +117,28 @@ export function WizardControlPanel({
activeTab,
onTabChange,
isStarting = false,
onSetAutonomousLife,
}: WizardControlPanelProps) {
const progress =
steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
const [autonomousLife, setAutonomousLife] = React.useState(true);
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 handleAutonomousLifeChange = async (checked: boolean) => {
setAutonomousLife(checked); // Optimistic update
if (onSetAutonomousLife) {
try {
const result = await onSetAutonomousLife(checked);
if (result === false) {
throw new Error("Service unavailable");
}
} catch (error) {
console.error("Failed to set autonomous life:", error);
setAutonomousLife(!checked); // Revert on failure
// Optional: Toast error?
}
}
};
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}
@@ -275,17 +250,36 @@ export function WizardControlPanel({
</Alert>
)}
{/* Connection Status */}
<Separator />
<div className="space-y-2">
<div className="text-xs font-medium">Connection</div>
<div className="space-y-4">
<div className="text-xs font-medium">Robot Status</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
Status
Connection
</span>
<Badge variant="default" className="text-xs">
Polling
</Badge>
{_isConnected ? (
<Badge variant="default" className="bg-green-600 text-xs">
Connected
</Badge>
) : (
<Badge variant="outline" className="text-yellow-600 border-yellow-600 text-xs">
Polling...
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
</div>
<Switch
id="autonomous-life"
checked={autonomousLife}
onCheckedChange={handleAutonomousLifeChange}
disabled={!_isConnected}
className="scale-75"
/>
</div>
</div>
</div>

View File

@@ -12,6 +12,10 @@ import {
Zap,
Eye,
List,
Loader2,
ArrowRight,
AlertTriangle,
RotateCcw,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
@@ -24,12 +28,21 @@ interface StepData {
name: string;
description: string | null;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
parameters: Record<string, unknown>;
order: number;
actions?: {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}[];
}
interface TrialData {
@@ -75,8 +88,25 @@ interface WizardExecutionPanelProps {
actionId: string,
parameters?: Record<string, unknown>,
) => void;
activeTab: "current" | "timeline" | "events";
onTabChange: (tab: "current" | "timeline" | "events") => void;
onExecuteRobotAction: (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => Promise<void>;
activeTab: "current" | "timeline" | "events"; // Deprecated/Ignored
onTabChange: (tab: "current" | "timeline" | "events") => void; // Deprecated/Ignored
onSkipAction: (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => Promise<void>;
isExecuting?: boolean;
onNextStep?: () => void;
onCompleteTrial?: () => void;
completedActionsCount: number;
onActionCompleted: () => void;
}
export function WizardExecutionPanel({
@@ -87,9 +117,21 @@ export function WizardExecutionPanel({
trialEvents,
onStepSelect,
onExecuteAction,
onExecuteRobotAction,
activeTab,
onTabChange,
onSkipAction,
isExecuting = false,
onNextStep,
onCompleteTrial,
completedActionsCount,
onActionCompleted,
}: 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":
@@ -169,7 +211,7 @@ export function WizardExecutionPanel({
</h3>
<p className="text-muted-foreground text-xs">
{trial.completedAt &&
`Ended at ${new Date(trial.completedAt).toLocaleTimeString()}`}
`Ended at ${new Date(trial.completedAt).toLocaleTimeString()} `}
</p>
</div>
@@ -209,281 +251,228 @@ export function WizardExecutionPanel({
)}
</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>
{/* Simplified Content - Sequential Focus */}
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full">
{currentStep ? (
<div className="flex flex-col gap-6 p-6">
{/* Header Info (Simplified) */}
<div className="space-y-4">
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
{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 className="text-muted-foreground text-sm mt-1">{currentStep.description}</div>
)}
</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>
{/* Action Sequence */}
{currentStep.actions && currentStep.actions.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
Execution Sequence
</h3>
</div>
<div className="grid gap-3">
{currentStep.actions.map((action, idx) => {
const isCompleted = idx < activeActionIndex;
const isActive = idx === activeActionIndex;
const isPending = idx > activeActionIndex;
return (
<div
key={action.id}
className={`group relative flex items-center gap-4 rounded-xl border p-5 transition-all ${isActive ? "bg-card border-primary ring-1 ring-primary shadow-md" :
isCompleted ? "bg-muted/30 border-transparent opacity-70" :
"bg-card border-border opacity-50"
}`}
>
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border text-sm font-medium ${isCompleted ? "bg-transparent text-green-600 border-green-600" :
isActive ? "bg-transparent text-primary border-primary font-bold shadow-sm" :
"bg-transparent text-muted-foreground border-transparent"
}`}>
{isCompleted ? <CheckCircle className="h-5 w-5" /> : idx + 1}
</div>
<div className="flex-1 min-w-0">
<div className={`font - medium truncate ${isCompleted ? "line-through text-muted-foreground" : ""} `}>{action.name}</div>
{action.description && (
<div className="text-xs text-muted-foreground line-clamp-1">
{action.description}
</div>
)}
</div>
{action.pluginId && isActive && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
className="h-9 px-3 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log("Skip clicked");
// Fire and forget
onSkipAction(
action.pluginId!,
action.type.includes(".")
? action.type.split(".").pop()!
: action.type,
action.parameters || {},
{ autoAdvance: false }
);
onActionCompleted();
}}
>
Skip
</Button>
<Button
size="default"
className="h-10 px-4 shadow-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log("Execute clicked");
onExecuteRobotAction(
action.pluginId!,
action.type.includes(".")
? action.type.split(".").pop()!
: action.type,
action.parameters || {},
{ autoAdvance: false },
);
onActionCompleted();
}}
>
<Play className="mr-2 h-4 w-4" />
Execute
</Button>
</div>
)}
{/* Fallback for actions with no plugin ID (e.g. manual steps) */}
{!action.pluginId && isActive && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={(e) => {
e.preventDefault();
onActionCompleted();
}}
>
Mark Done
</Button>
</div>
)}
{/* Completed State Indicator */}
{isCompleted && (
<div className="flex items-center gap-2 px-3">
<div className="text-xs font-medium text-green-600">
Done
</div>
{action.pluginId && (
<>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
title="Retry Action"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Execute again without advancing count
onExecuteRobotAction(
action.pluginId!,
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
action.parameters || {},
{ autoAdvance: false },
);
}}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-100"
title="Mark Issue"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onExecuteAction("note", {
content: `Reported issue with action: ${action.name}`,
category: "system_issue"
});
}}
>
<AlertTriangle className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
)}
</div>
)
})}
</div>
{/* Manual Advance Button */}
{activeActionIndex >= (currentStep.actions?.length || 0) && (
<div className="mt-6 flex justify-end">
<Button
size="lg"
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
className={`w-full text-white shadow-md transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
? "bg-blue-600 hover:bg-blue-700"
: "bg-green-600 hover:bg-green-700"
}`}
>
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</div>
)}
</div>
)}
{/* Manual Wizard Controls (If applicable) */}
{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">
<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"
onClick={() => onExecuteAction("intervene")}
>
<Zap className="mr-2 h-4 w-4" />
Intervene
</Button>
</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>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
No active step
</div>
)}
</ScrollArea>
</div>
</div >
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import React, { useState } from "react";
import { Send, Hash, Tag, Clock, Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Textarea } from "~/components/ui/textarea";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Badge } from "~/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { HorizontalTimeline } from "~/components/trials/timeline/HorizontalTimeline";
interface TrialEvent {
type: string;
timestamp: Date;
data?: unknown;
message?: string;
}
interface WizardObservationPaneProps {
onAddAnnotation: (
description: string,
category?: string,
tags?: string[],
) => Promise<void>;
isSubmitting?: boolean;
}
export function WizardObservationPane({
onAddAnnotation,
isSubmitting = false,
trialEvents = [],
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
const [note, setNote] = useState("");
const [category, setCategory] = useState("observation");
const [tags, setTags] = useState<string[]>([]);
const [currentTag, setCurrentTag] = useState("");
const handleSubmit = async () => {
if (!note.trim()) return;
await onAddAnnotation(note, category, tags);
setNote("");
setTags([]);
setCurrentTag("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
};
const addTag = () => {
const trimmed = currentTag.trim();
if (trimmed && !tags.includes(trimmed)) {
setTags([...tags, trimmed]);
setCurrentTag("");
}
};
return (
<div className="flex h-full flex-col border-t bg-background">
<Tabs defaultValue="notes" className="flex h-full flex-col">
<div className="border-b px-4 bg-muted/30">
<TabsList className="h-9 -mb-px bg-transparent p-0">
<TabsTrigger value="notes" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
Notes & Observations
</TabsTrigger>
<TabsTrigger value="timeline" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
Timeline
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="notes" className="flex-1 flex flex-col p-4 m-0 data-[state=inactive]:hidden">
<div className="flex flex-1 flex-col gap-2">
<Textarea
placeholder="Type your observation here..."
className="flex-1 resize-none font-mono text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div className="flex items-center gap-2">
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="observation">Observation</SelectItem>
<SelectItem value="participant_behavior">Behavior</SelectItem>
<SelectItem value="system_issue">System Issue</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8">
<Tag className="h-3 w-3 text-muted-foreground" />
<input
type="text"
placeholder="Add tags..."
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
onBlur={addTag}
/>
</div>
<Button
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !note.trim()}
className="h-8"
>
<Send className="mr-2 h-3 w-3" />
Add Note
</Button>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="px-1 py-0 text-[10px] cursor-pointer hover:bg-destructive/10 hover:text-destructive"
onClick={() => setTags(tags.filter((t) => t !== tag))}
>
#{tag}
</Badge>
))}
</div>
)}
</div>
</TabsContent>
<TabsContent value="timeline" className="flex-1 m-0 min-h-0 p-4 data-[state=inactive]:hidden">
<HorizontalTimeline events={trialEvents} />
</TabsContent>
</Tabs>
</div>
);
}