mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat: introduce conditional steps and branching logic to the experiment wizard and designer, along with new core and WoZ plugins.
This commit is contained in:
@@ -155,7 +155,7 @@ export function RobotActionsPanel({
|
||||
disconnect: disconnectRos,
|
||||
executeRobotAction: executeRosAction,
|
||||
} = useWizardRos({
|
||||
autoConnect: true,
|
||||
autoConnect: false, // Let WizardInterface handle connection
|
||||
onActionCompleted: (execution) => {
|
||||
toast.success(`Completed: ${execution.actionId}`, {
|
||||
description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`,
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Play, CheckCircle, X, Clock, AlertCircle, HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Play,
|
||||
CheckCircle,
|
||||
X,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Pause,
|
||||
SkipForward
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import Link from "next/link";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { PanelsContainer } from "~/components/experiments/designer/layout/PanelsContainer";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
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 { TrialStatusBar } from "./panels/TrialStatusBar";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useWizardRos } from "~/hooks/useWizardRos";
|
||||
import { toast } from "sonner";
|
||||
@@ -68,8 +82,18 @@ interface StepData {
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "conditional";
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
nextStepId?: string;
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions: ActionData[];
|
||||
}
|
||||
@@ -87,24 +111,31 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
// Persistent tab states to prevent resets from parent re-renders
|
||||
const [controlPanelTab, setControlPanelTab] = useState<
|
||||
"control" | "step" | "actions" | "robot"
|
||||
>("control");
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||
"current" | "timeline" | "events"
|
||||
>(trial.status === "in_progress" ? "current" : "timeline");
|
||||
// UI State
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
|
||||
const [obsTab, setObsTab] = useState<"notes" | "timeline">("notes");
|
||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||
"status" | "robot" | "events"
|
||||
>("status");
|
||||
const [completedActionsCount, setCompletedActionsCount] = useState(0);
|
||||
|
||||
// Collapse state for panels
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const [obsCollapsed, setObsCollapsed] = useState(false);
|
||||
|
||||
// Center tabs (Timeline | Actions)
|
||||
const [centerTab, setCenterTab] = useState<"timeline" | "actions">("timeline");
|
||||
|
||||
// Reset completed actions when step changes
|
||||
useEffect(() => {
|
||||
setCompletedActionsCount(0);
|
||||
}, [currentStepIndex]);
|
||||
|
||||
// Track the last response value from wizard_wait_for_response for branching
|
||||
const [lastResponse, setLastResponse] = useState<string | null>(null);
|
||||
|
||||
// Get experiment steps from API
|
||||
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
|
||||
{ experimentId: trial.experimentId },
|
||||
@@ -145,7 +176,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
case "parallel":
|
||||
return "parallel_steps" as const;
|
||||
case "conditional":
|
||||
return "conditional_branch" as const;
|
||||
return "conditional" as const;
|
||||
default:
|
||||
return "wizard_action" as const;
|
||||
}
|
||||
@@ -276,9 +307,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
name: step.name ?? `Step ${index + 1}`,
|
||||
description: step.description,
|
||||
type: mapStepType(step.type),
|
||||
// Fix: Conditions are at root level from API
|
||||
conditions: (step as any).conditions ?? (step as any).trigger?.conditions ?? undefined,
|
||||
parameters: step.parameters ?? {},
|
||||
order: step.order ?? index,
|
||||
actions: step.actions?.map((action) => ({
|
||||
actions: step.actions?.filter(a => a.type !== 'branch').map((action) => ({
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
@@ -414,11 +447,60 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
console.log("Pause trial");
|
||||
};
|
||||
|
||||
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
|
||||
const handleNextStep = (targetIndex?: number) => {
|
||||
// If explicit target provided (from branching choice), use it
|
||||
if (typeof targetIndex === 'number') {
|
||||
// Find step by index to ensure safety
|
||||
if (targetIndex >= 0 && targetIndex < steps.length) {
|
||||
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
|
||||
setCompletedActionsCount(0);
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setLastResponse(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic Branching Logic
|
||||
const currentStep = steps[currentStepIndex];
|
||||
|
||||
// Check if we have a stored response that dictates the next step
|
||||
if (currentStep?.type === 'conditional' && currentStep.conditions?.options && lastResponse) {
|
||||
const matchedOption = currentStep.conditions.options.find(opt => opt.value === lastResponse);
|
||||
if (matchedOption && matchedOption.nextStepId) {
|
||||
// Find index of the target step
|
||||
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setLastResponse(null); // Reset after consuming
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for explicit nextStepId in conditions (e.g. for end of branch)
|
||||
console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions);
|
||||
if (currentStep?.conditions?.nextStepId) {
|
||||
const nextId = String(currentStep.conditions.nextStepId);
|
||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setCompletedActionsCount(0);
|
||||
return;
|
||||
} else {
|
||||
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`);
|
||||
}
|
||||
} else {
|
||||
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly.");
|
||||
}
|
||||
|
||||
// Default: Linear progression
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex < steps.length) {
|
||||
setCurrentStepIndex(nextIndex);
|
||||
} else {
|
||||
handleCompleteTrial();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -476,8 +558,25 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
parameters?: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
// Log action execution
|
||||
console.log("Executing action:", actionId, parameters);
|
||||
|
||||
// Handle branching logic (wizard_wait_for_response)
|
||||
if (parameters?.value && parameters?.label) {
|
||||
setLastResponse(String(parameters.value));
|
||||
|
||||
// If nextStepId is provided, jump immediately
|
||||
if (parameters.nextStepId) {
|
||||
const nextId = String(parameters.nextStepId);
|
||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`);
|
||||
handleNextStep(targetIndex);
|
||||
return; // Exit after jump
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (actionId === "acknowledge") {
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
@@ -614,65 +713,102 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
[logRobotActionMutation, trial.id],
|
||||
);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 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">
|
||||
<Badge
|
||||
variant={statusConfig.variant}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
<div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
|
||||
<PageHeader
|
||||
title="Trial Execution"
|
||||
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button
|
||||
onClick={handleStartTrial}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Start Trial
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{trial.status === "in_progress" && (
|
||||
<div className="flex items-center gap-1 font-mono text-sm">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatElapsedTime(elapsedTime)}
|
||||
</div>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePauseTrial}
|
||||
className="gap-2"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
Pause
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNextStep()}
|
||||
className="gap-2"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
Next Step
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleAbortTrial}
|
||||
className="gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Abort
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleCompleteTrial}
|
||||
className="gap-2 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Complete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{steps.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Step {currentStepIndex + 1} of {totalSteps}
|
||||
</span>
|
||||
<div className="w-16">
|
||||
<Progress value={progressPercentage} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
{_userRole !== "participant" && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/studies/${trial.experiment.studyId}/trials`}>
|
||||
Exit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className="flex-none px-2 pb-2"
|
||||
/>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<div>{trial.experiment.name}</div>
|
||||
<div>{trial.participant.participantCode}</div>
|
||||
<Badge
|
||||
variant={rosConnected ? "default" : "outline"}
|
||||
className="text-xs"
|
||||
>
|
||||
{rosConnected ? "ROS Connected" : "ROS Offline"}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => startTour("wizard")}
|
||||
className="hover:bg-muted p-1 rounded-full transition-colors"
|
||||
title="Start Tour"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content with Vertical Resizable Split */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<ResizablePanel defaultSize={75} minSize={30}>
|
||||
<PanelsContainer
|
||||
left={
|
||||
{/* Main Grid - 2 rows */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2">
|
||||
{/* Top Row - 3 Column Layout */}
|
||||
<div className="flex-1 min-h-0 flex gap-2">
|
||||
{/* Left Sidebar - Control Panel (Collapsible) */}
|
||||
{!leftCollapsed && (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
||||
<span className="text-sm font-medium">Control</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setLeftCollapsed(true)}
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-controls" className="h-full">
|
||||
<WizardControlPanel
|
||||
trial={trial}
|
||||
@@ -688,38 +824,98 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
_isConnected={rosConnected}
|
||||
activeTab={controlPanelTab}
|
||||
onTabChange={setControlPanelTab}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
center={
|
||||
<div id="tour-wizard-timeline" className="h-full">
|
||||
<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}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center - Tabbed Workspace */}
|
||||
{/* Center - Execution Workspace */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
|
||||
{leftCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 mr-2"
|
||||
onClick={() => setLeftCollapsed(false)}
|
||||
title="Open Tools Panel"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Trial Execution</span>
|
||||
{currentStep && (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{currentStep.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="mr-2 text-xs text-muted-foreground font-medium">
|
||||
Step {currentStepIndex + 1} / {steps.length}
|
||||
</div>
|
||||
|
||||
{rightCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setRightCollapsed(false)}
|
||||
title="Open Robot Status"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-timeline" className="h-full">
|
||||
<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}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Robot Status (Collapsible) */}
|
||||
{!rightCollapsed && (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
||||
<span className="text-sm font-medium">Robot Status</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setRightCollapsed(true)}
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-robot-status" className="h-full">
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
@@ -732,27 +928,56 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
showDividers={true}
|
||||
className="h-full"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel defaultSize={25} minSize={10}>
|
||||
<WizardObservationPane
|
||||
onAddAnnotation={handleAddAnnotation}
|
||||
isSubmitting={addAnnotationMutation.isPending}
|
||||
trialEvents={trialEvents}
|
||||
// Observation pane is where observers usually work, so not readOnly for them?
|
||||
// But maybe we want 'readOnly' for completed trials.
|
||||
readOnly={trial.status === 'completed'}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom Row - Observations (Full Width, Collapsible) */}
|
||||
{!obsCollapsed && (
|
||||
<Tabs value={obsTab} onValueChange={(v) => setObsTab(v as "notes" | "timeline")} className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm h-48 flex-none">
|
||||
<div className="flex items-center border-b px-3 py-2 bg-muted/30 gap-3">
|
||||
<span className="text-sm font-medium">Observations</span>
|
||||
<TabsList className="h-7 bg-transparent border-0 p-0">
|
||||
<TabsTrigger value="notes" className="text-xs h-7 px-3">Notes</TabsTrigger>
|
||||
<TabsTrigger value="timeline" className="text-xs h-7 px-3">Timeline</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setObsCollapsed(true)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<WizardObservationPane
|
||||
onAddAnnotation={handleAddAnnotation}
|
||||
isSubmitting={addAnnotationMutation.isPending}
|
||||
trialEvents={trialEvents}
|
||||
readOnly={trial.status === 'completed'}
|
||||
activeTab={obsTab}
|
||||
/>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
{
|
||||
obsCollapsed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setObsCollapsed(false)}
|
||||
className="w-full flex-none"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 mr-2" />
|
||||
Show Observations
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
133
src/components/trials/wizard/panels/TrialStatusBar.tsx
Normal file
133
src/components/trials/wizard/panels/TrialStatusBar.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Play,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
export interface TrialStatusBarProps {
|
||||
currentStepIndex: number;
|
||||
totalSteps: number;
|
||||
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
rosConnected: boolean;
|
||||
eventsCount: number;
|
||||
completedActionsCount: number;
|
||||
totalActionsCount: number;
|
||||
onAddNote?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TrialStatusBar({
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
trialStatus,
|
||||
rosConnected,
|
||||
eventsCount,
|
||||
completedActionsCount,
|
||||
totalActionsCount,
|
||||
onAddNote,
|
||||
className,
|
||||
}: TrialStatusBarProps) {
|
||||
const progressPercentage = useMemo(
|
||||
() => (totalSteps > 0 ? ((currentStepIndex + 1) / totalSteps) * 100 : 0),
|
||||
[currentStepIndex, totalSteps],
|
||||
);
|
||||
|
||||
const actionProgress = useMemo(
|
||||
() =>
|
||||
totalActionsCount > 0
|
||||
? (completedActionsCount / totalActionsCount) * 100
|
||||
: 0,
|
||||
[completedActionsCount, totalActionsCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
|
||||
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Step Progress */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<GitBranch className="h-3.5 w-3.5 opacity-70" />
|
||||
Step {currentStepIndex + 1}/{totalSteps}
|
||||
</span>
|
||||
<div className="w-20">
|
||||
<Progress value={progressPercentage} className="h-1.5" />
|
||||
</div>
|
||||
<span className="text-muted-foreground/70">{Math.round(progressPercentage)}%</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
|
||||
{/* Action Progress */}
|
||||
{totalActionsCount > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5 opacity-70" />
|
||||
{completedActionsCount}/{totalActionsCount} actions
|
||||
</span>
|
||||
<div className="w-16">
|
||||
<Progress value={actionProgress} className="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Trial Stats */}
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 opacity-70" />
|
||||
{eventsCount} events
|
||||
</span>
|
||||
{trialStatus === "in_progress" && (
|
||||
<Badge variant="default" className="h-5 gap-1 bg-emerald-500 px-1.5 text-[10px] font-normal">
|
||||
<Play className="h-2.5 w-2.5" />
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
{trialStatus === "completed" && (
|
||||
<Badge variant="secondary" className="h-5 gap-1 px-1.5 text-[10px] font-normal">
|
||||
<CheckCircle2 className="h-2.5 w-2.5" />
|
||||
Completed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{onAddNote && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={onAddNote}
|
||||
title="Add Quick Note"
|
||||
>
|
||||
<StickyNote className="mr-1.5 h-3.5 w-3.5" />
|
||||
Note
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrialStatusBar;
|
||||
@@ -207,9 +207,9 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden bg-black p-4 flex items-center justify-center relative">
|
||||
<div className="flex-1 overflow-hidden bg-muted/50 p-4 flex items-center justify-center relative">
|
||||
{isCameraEnabled ? (
|
||||
<div className="w-full relative rounded-lg overflow-hidden border border-slate-800">
|
||||
<div className="w-full relative rounded-lg overflow-hidden border border-border shadow-sm bg-black">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Webcam
|
||||
ref={webcamRef}
|
||||
@@ -249,11 +249,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-slate-500">
|
||||
<CameraOff className="mx-auto mb-2 h-12 w-12 opacity-20" />
|
||||
<p className="text-sm">Camera is disabled</p>
|
||||
<div className="text-center text-muted-foreground/50">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<CameraOff className="h-6 w-6 opacity-50" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Camera is disabled</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={handleEnableCamera}
|
||||
|
||||
@@ -22,7 +22,6 @@ 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";
|
||||
import { RobotActionsPanel } from "../RobotActionsPanel";
|
||||
|
||||
@@ -34,8 +33,17 @@ interface StepData {
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "conditional"; // Updated to match DB enum
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
@@ -80,7 +88,7 @@ interface WizardControlPanelProps {
|
||||
currentStepIndex: number;
|
||||
onStartTrial: () => void;
|
||||
onPauseTrial: () => void;
|
||||
onNextStep: () => void;
|
||||
onNextStep: (targetIndex?: number) => void;
|
||||
onCompleteTrial: () => void;
|
||||
onAbortTrial: () => void;
|
||||
onExecuteAction: (
|
||||
@@ -94,14 +102,13 @@ interface WizardControlPanelProps {
|
||||
) => Promise<void>;
|
||||
studyId?: string;
|
||||
_isConnected: boolean;
|
||||
activeTab: "control" | "step" | "actions" | "robot";
|
||||
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
||||
|
||||
isStarting?: boolean;
|
||||
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function WizardControlPanel({
|
||||
export const WizardControlPanel = React.memo(function WizardControlPanel({
|
||||
trial,
|
||||
currentStep,
|
||||
steps,
|
||||
@@ -115,8 +122,6 @@ export function WizardControlPanel({
|
||||
onExecuteRobotAction,
|
||||
studyId,
|
||||
_isConnected,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
isStarting = false,
|
||||
onSetAutonomousLife,
|
||||
readOnly = false,
|
||||
@@ -141,331 +146,111 @@ export function WizardControlPanel({
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Tabbed Content */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => {
|
||||
if (
|
||||
value === "control" ||
|
||||
value === "step" ||
|
||||
value === "actions" ||
|
||||
value === "robot"
|
||||
) {
|
||||
onTabChange(value as "control" | "step" | "actions");
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-2 py-1">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="control" className="text-xs">
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
Control
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="step" className="text-xs">
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
Step
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="actions" className="text-xs">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
Actions
|
||||
</TabsTrigger>
|
||||
</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" && (
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="space-y-3">
|
||||
{/* Decision Point UI removed as per user request (handled in Execution Panel) */}
|
||||
|
||||
{trial.status === "in_progress" ? (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Start Trial clicked");
|
||||
onStartTrial();
|
||||
}}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isStarting || readOnly}
|
||||
className="w-full justify-start"
|
||||
onClick={() => onExecuteAction("acknowledge")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{isStarting ? "Starting..." : "Start Trial"}
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Acknowledge
|
||||
</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={readOnly}
|
||||
>
|
||||
<Pause className="mr-1 h-3 w-3" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNextStep}
|
||||
disabled={(currentStepIndex >= steps.length - 1) || readOnly}
|
||||
size="sm"
|
||||
>
|
||||
<SkipForward className="mr-1 h-3 w-3" />
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<AlertCircle className="mr-2 h-3 w-3" />
|
||||
Flag Intervention
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button
|
||||
onClick={onCompleteTrial}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Complete Trial
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onAbortTrial}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={readOnly}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<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">
|
||||
Connection
|
||||
</span>
|
||||
{_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 || readOnly}
|
||||
className="scale-75"
|
||||
/>
|
||||
</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={() => onExecuteAction("note", { content: "Wizard note" })}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<User className="mr-2 h-3 w-3" />
|
||||
Add Note
|
||||
</Button>
|
||||
|
||||
{currentStep?.type === "wizard_action" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Acknowledge clicked");
|
||||
onExecuteAction("acknowledge");
|
||||
}}
|
||||
onClick={() => onExecuteAction("step_complete")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Acknowledge
|
||||
Mark Step Complete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground p-2 text-center border border-dashed rounded-md bg-muted/20">
|
||||
Controls available during trial
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Intervene clicked");
|
||||
onExecuteAction("intervene");
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<AlertCircle className="mr-2 h-3 w-3" />
|
||||
Intervene
|
||||
</Button>
|
||||
<Separator />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Add Note clicked");
|
||||
onExecuteAction("note", { content: "Wizard note" });
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<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={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Mark Complete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{/* Robot Controls (Merged from System & Robot Tab) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">Connection</span>
|
||||
{_isConnected ? (
|
||||
<Badge variant="default" className="bg-green-600 text-xs">Connected</Badge>
|
||||
) : (
|
||||
<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>
|
||||
<Badge variant="outline" className="text-muted-foreground border-muted-foreground/30 text-xs">Offline</Badge>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Robot Actions Tab */}
|
||||
<TabsContent
|
||||
value="robot"
|
||||
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
{studyId && onExecuteRobotAction ? (
|
||||
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
|
||||
<RobotActionsPanel
|
||||
studyId={studyId}
|
||||
trialId={trial.id}
|
||||
onExecuteAction={onExecuteRobotAction}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Robot actions are not available. Study ID or action
|
||||
handler is missing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
|
||||
<Switch
|
||||
id="autonomous-life"
|
||||
checked={!!autonomousLife}
|
||||
onCheckedChange={handleAutonomousLifeChange}
|
||||
disabled={!_isConnected || readOnly}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Robot Actions Panel Integration */}
|
||||
{studyId && onExecuteRobotAction ? (
|
||||
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
|
||||
<RobotActionsPanel
|
||||
studyId={studyId}
|
||||
trialId={trial.id}
|
||||
onExecuteAction={onExecuteRobotAction}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground text-center py-2">Robot actions unavailable</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Play,
|
||||
Clock,
|
||||
SkipForward,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
User,
|
||||
Activity,
|
||||
Zap,
|
||||
ArrowRight,
|
||||
AlertTriangle,
|
||||
Zap,
|
||||
Loader2,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -26,8 +25,17 @@ interface StepData {
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "conditional";
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
@@ -129,30 +137,31 @@ export function WizardExecutionPanel({
|
||||
|
||||
const activeActionIndex = completedActionsCount;
|
||||
|
||||
// Auto-scroll to active action
|
||||
const activeActionRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeActionRef.current) {
|
||||
activeActionRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [activeActionIndex, currentStepIndex]);
|
||||
|
||||
// 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 className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md space-y-4 text-center">
|
||||
<Clock className="text-muted-foreground mx-auto h-12 w-12 opacity-20" />
|
||||
<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
|
||||
<h4 className="text-lg font-medium">Ready to Begin</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{steps.length} steps prepared. Use controls to start.
|
||||
</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>
|
||||
@@ -197,160 +206,188 @@ export function WizardExecutionPanel({
|
||||
|
||||
// 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>
|
||||
|
||||
{/* Simplified Content - Sequential Focus */}
|
||||
<div className="relative 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 mt-1">{currentStep.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="pr-4">
|
||||
{currentStep ? (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-2xl mx-auto">
|
||||
{/* Header Info */}
|
||||
<div className="space-y-1 pb-4 border-b">
|
||||
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
|
||||
{currentStep.description && (
|
||||
<div className="text-muted-foreground">{currentStep.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
{/* Action Sequence */}
|
||||
{currentStep.actions && currentStep.actions.length > 0 && (
|
||||
<div className="relative ml-3 space-y-0 pt-2">
|
||||
{currentStep.actions.map((action, idx) => {
|
||||
const isCompleted = idx < activeActionIndex;
|
||||
const isActive = idx === activeActionIndex;
|
||||
const isLast = idx === currentStep.actions!.length - 1;
|
||||
|
||||
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"
|
||||
}`}
|
||||
className="relative pl-8 pb-10 last:pb-0"
|
||||
ref={isActive ? activeActionRef : undefined}
|
||||
>
|
||||
<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>
|
||||
{/* Connecting Line */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className={`absolute left-[11px] top-8 bottom-0 w-[2px] ${isCompleted ? "bg-primary/20" : "bg-border/40"}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* Marker */}
|
||||
<div
|
||||
className={`absolute left-0 top-1 h-6 w-6 rounded-full border-2 flex items-center justify-center z-10 bg-background transition-all duration-300 ${isCompleted
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: isActive
|
||||
? "border-primary ring-4 ring-primary/10 scale-110"
|
||||
: "border-muted-foreground/30 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold">{idx + 1}</span>
|
||||
)}
|
||||
</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();
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
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();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<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();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
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
|
||||
{/* Content Card */}
|
||||
<div
|
||||
className={`rounded-lg border transition-all duration-300 ${isActive
|
||||
? "bg-card border-primary/50 shadow-md p-5 translate-x-1"
|
||||
: "bg-muted/5 border-transparent p-3 opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div
|
||||
className={`text-base font-medium leading-none ${isCompleted ? "line-through text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{action.name}
|
||||
</div>
|
||||
</div>
|
||||
{action.pluginId && (
|
||||
<>
|
||||
|
||||
{action.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Action Controls */}
|
||||
{isActive && (
|
||||
<div className="pt-3 flex items-center gap-3">
|
||||
{action.pluginId && !["hristudio-core", "hristudio-woz"].includes(action.pluginId) ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shadow-sm min-w-[100px]"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onExecuteRobotAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
? action.type.split(".").pop()!
|
||||
: action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
Execute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSkipAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
? action.type.split(".").pop()!
|
||||
: action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
Mark Done
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wizard Wait For Response / Branching UI */}
|
||||
{isActive && action.type === 'wizard_wait_for_response' && action.parameters?.options && Array.isArray(action.parameters.options) && (
|
||||
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{(action.parameters.options as any[]).map((opt, optIdx) => {
|
||||
// Handle both string options and object options
|
||||
const label = typeof opt === 'string' ? opt : opt.label;
|
||||
const value = typeof opt === 'string' ? opt : opt.value;
|
||||
const nextStepId = typeof opt === 'object' ? opt.nextStepId : undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={optIdx}
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-3 px-4 text-left border-primary/20 hover:border-primary hover:bg-primary/5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onExecuteAction(
|
||||
action.id,
|
||||
{
|
||||
value,
|
||||
label,
|
||||
nextStepId
|
||||
}
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="font-medium">{String(label)}</span>
|
||||
{typeof opt !== 'string' && value && <span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded-sm">{String(value)}</span>}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed State Actions */}
|
||||
{isCompleted && action.pluginId && (
|
||||
<div className="pt-1 flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
title="Retry Action"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Execute again without advancing count
|
||||
onExecuteRobotAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
||||
@@ -360,81 +397,47 @@ export function WizardExecutionPanel({
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
Retry
|
||||
</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"
|
||||
});
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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"
|
||||
}`}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
{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-1 gap-3">
|
||||
{/* Manual Advance Button */}
|
||||
{activeActionIndex >= (currentStep.actions?.length || 0) && (
|
||||
<div className="mt-6 flex justify-center pb-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
disabled={readOnly}
|
||||
size="lg"
|
||||
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
|
||||
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-green-600 hover:bg-green-700"
|
||||
}`}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Flag Issue / Intervention
|
||||
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
No active step
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground space-y-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
|
||||
<div className="text-sm">Waiting for trial to start...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/* Scroll Hint Fade */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-background to-transparent z-10" />
|
||||
</div>
|
||||
</div >
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
{/* Camera View - Always Visible */}
|
||||
<div className="shrink-0 bg-black rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
|
||||
<div className="shrink-0 bg-muted/30 rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
|
||||
<WebcamPanel readOnly={readOnly} />
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
{rosConnected ? (
|
||||
<Power className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<PowerOff className="h-3 w-3 text-gray-400" />
|
||||
<Badge variant="outline" className="text-gray-500 border-gray-300 text-xs text-muted-foreground w-auto px-1.5 py-0">Offline</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ interface WizardObservationPaneProps {
|
||||
) => Promise<void>;
|
||||
isSubmitting?: boolean;
|
||||
readOnly?: boolean;
|
||||
activeTab?: "notes" | "timeline";
|
||||
}
|
||||
|
||||
export function WizardObservationPane({
|
||||
@@ -38,6 +39,7 @@ export function WizardObservationPane({
|
||||
isSubmitting = false,
|
||||
trialEvents = [],
|
||||
readOnly = false,
|
||||
activeTab = "notes",
|
||||
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [category, setCategory] = useState("observation");
|
||||
@@ -68,95 +70,82 @@ export function WizardObservationPane({
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<div className={`flex-1 flex flex-col p-4 m-0 ${activeTab !== "notes" ? "hidden" : ""}`}>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Textarea
|
||||
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
|
||||
className="flex-1 resize-none font-mono text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<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={readOnly ? "Session is read-only" : "Type your observation here..."}
|
||||
className="flex-1 resize-none font-mono text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
|
||||
<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 items-center gap-2">
|
||||
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
|
||||
<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 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={readOnly ? "" : "Add tags..."}
|
||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
value={currentTag}
|
||||
onChange={(e) => setCurrentTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
onBlur={addTag}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !note.trim() || readOnly}
|
||||
className="h-8"
|
||||
>
|
||||
<Send className="mr-2 h-3 w-3" />
|
||||
Add Note
|
||||
</Button>
|
||||
<div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8">
|
||||
<Tag className={`h-3 w-3 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={readOnly ? "" : "Add tags..."}
|
||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
value={currentTag}
|
||||
onChange={(e) => setCurrentTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
onBlur={addTag}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !note.trim() || readOnly}
|
||||
className="h-8"
|
||||
>
|
||||
<Send className="mr-2 h-3 w-3" />
|
||||
Add Note
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="timeline" className="flex-1 m-0 min-h-0 p-4 data-[state=inactive]:hidden">
|
||||
<HorizontalTimeline events={trialEvents} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 m-0 min-h-0 p-4 ${activeTab !== "timeline" ? "hidden" : ""}`}>
|
||||
<HorizontalTimeline events={trialEvents} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user