feat: Enhance trial event display with improved formatting and icons, refine trial wizard panels, and update dashboard page layouts.

This commit is contained in:
2026-02-20 00:37:33 -05:00
parent 72971a4b49
commit 60d4fae72c
20 changed files with 1202 additions and 688 deletions

View File

@@ -10,6 +10,7 @@ import {
Play,
Target,
Users,
SkipForward
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -21,15 +22,17 @@ interface TrialProgressProps {
id: string;
name: string;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
description?: string;
duration?: number;
parameters?: Record<string, unknown>;
}>;
currentStepIndex: number;
completedSteps: Set<number>;
skippedSteps: Set<number>;
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
}
@@ -71,6 +74,8 @@ const stepTypeConfig = {
export function TrialProgress({
steps,
currentStepIndex,
completedSteps,
skippedSteps,
trialStatus,
}: TrialProgressProps) {
if (!steps || steps.length === 0) {
@@ -93,7 +98,7 @@ export function TrialProgress({
? 0
: ((currentStepIndex + 1) / steps.length) * 100;
const completedSteps =
const completedCount =
trialStatus === "completed"
? steps.length
: trialStatus === "aborted" || trialStatus === "failed"
@@ -102,12 +107,19 @@ export function TrialProgress({
const getStepStatus = (index: number) => {
if (trialStatus === "aborted" || trialStatus === "failed") return "aborted";
if (trialStatus === "completed" || index < currentStepIndex)
return "completed";
if (trialStatus === "completed") return "completed";
if (skippedSteps.has(index)) return "skipped";
if (completedSteps.has(index)) return "completed";
if (index === currentStepIndex && trialStatus === "in_progress")
return "active";
if (index === currentStepIndex && trialStatus === "scheduled")
return "pending";
// Default fallback if jumping around without explicitly adding to sets
if (index < currentStepIndex && !skippedSteps.has(index)) return "completed";
return "upcoming";
};
@@ -145,6 +157,14 @@ export function TrialProgress({
borderColor: "border-red-300",
textColor: "text-red-800",
};
case "skipped":
return {
icon: Circle,
iconColor: "text-slate-400 opacity-50",
bgColor: "bg-slate-50 opacity-50",
borderColor: "border-slate-200 border-dashed",
textColor: "text-slate-500",
};
default: // upcoming
return {
icon: Circle,
@@ -171,7 +191,7 @@ export function TrialProgress({
</CardTitle>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="text-xs">
{completedSteps}/{steps.length} steps
{completedCount}/{steps.length} steps
</Badge>
{totalDuration > 0 && (
<Badge variant="outline" className="text-xs">
@@ -191,13 +211,12 @@ export function TrialProgress({
</div>
<Progress
value={progress}
className={`h-2 ${
trialStatus === "completed"
? "bg-green-100"
: trialStatus === "aborted" || trialStatus === "failed"
? "bg-red-100"
: "bg-blue-100"
}`}
className={`h-2 ${trialStatus === "completed"
? "bg-green-100"
: trialStatus === "aborted" || trialStatus === "failed"
? "bg-red-100"
: "bg-blue-100"
}`}
/>
<div className="flex justify-between text-xs text-slate-500">
<span>Start</span>
@@ -236,51 +255,47 @@ export function TrialProgress({
{/* Connection Line */}
{index < steps.length - 1 && (
<div
className={`absolute top-12 left-6 h-6 w-0.5 ${
getStepStatus(index + 1) === "completed" ||
className={`absolute top-12 left-6 h-6 w-0.5 ${getStepStatus(index + 1) === "completed" ||
(getStepStatus(index + 1) === "active" &&
status === "completed")
? "bg-green-300"
: "bg-slate-300"
}`}
? "bg-green-300"
: "bg-slate-300"
}`}
/>
)}
{/* Step Card */}
<div
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${
status === "active"
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
: status === "completed"
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${status === "active"
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
: status === "completed"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: status === "aborted"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: status === "aborted"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: "border-slate-200 bg-slate-50"
}`}
: "border-slate-200 bg-slate-50"
}`}
>
{/* Step Number & Status */}
<div className="flex-shrink-0 space-y-1">
<div
className={`flex h-8 w-12 items-center justify-center rounded-lg ${
status === "active"
? statusConfig.bgColor
: status === "completed"
? "bg-green-100"
: status === "aborted"
? "bg-red-100"
: "bg-slate-100"
}`}
className={`flex h-8 w-12 items-center justify-center rounded-lg ${status === "active"
? statusConfig.bgColor
: status === "completed"
? "bg-green-100"
: status === "aborted"
? "bg-red-100"
: "bg-slate-100"
}`}
>
<span
className={`text-sm font-medium ${
status === "active"
? statusConfig.textColor
: status === "completed"
? "text-green-700"
: status === "aborted"
? "text-red-700"
: "text-slate-600"
}`}
className={`text-sm font-medium ${status === "active"
? statusConfig.textColor
: status === "completed"
? "text-green-700"
: status === "aborted"
? "text-red-700"
: "text-slate-600"
}`}
>
{index + 1}
</span>
@@ -297,15 +312,14 @@ export function TrialProgress({
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<h5
className={`truncate font-medium ${
status === "active"
? "text-slate-900"
: status === "completed"
? "text-green-900"
: status === "aborted"
? "text-red-900"
: "text-slate-700"
}`}
className={`truncate font-medium ${status === "active"
? "text-slate-900"
: status === "completed"
? "text-green-900"
: status === "aborted"
? "text-red-900"
: "text-slate-700"
}`}
>
{step.name}
</h5>
@@ -352,6 +366,12 @@ export function TrialProgress({
<span>Completed</span>
</div>
)}
{status === "skipped" && (
<div className="mt-2 flex items-center space-x-1 text-sm text-slate-500 opacity-80">
<SkipForward className="h-3 w-3" />
<span>Skipped</span>
</div>
)}
</div>
</div>
</div>
@@ -365,7 +385,7 @@ export function TrialProgress({
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-green-600">
{completedSteps}
{completedCount}
</div>
<div className="text-xs text-slate-600">Completed</div>
</div>
@@ -378,7 +398,7 @@ export function TrialProgress({
<div>
<div className="text-2xl font-bold text-slate-600">
{steps.length -
completedSteps -
completedCount -
(trialStatus === "in_progress" ? 1 : 0)}
</div>
<div className="text-xs text-slate-600">Remaining</div>

View File

@@ -25,10 +25,10 @@ import Link from "next/link";
import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert";
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 { WebcamPanel } from "./panels/WebcamPanel";
import { TrialStatusBar } from "./panels/TrialStatusBar";
import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos";
@@ -121,20 +121,22 @@ export const WizardInterface = React.memo(function WizardInterface({
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 completed steps
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [skippedSteps, setSkippedSteps] = useState<Set<number>>(new Set());
// Track the last response value from wizard_wait_for_response for branching
const [lastResponse, setLastResponse] = useState<string | null>(null);
const [isPaused, setIsPaused] = useState(false);
const utils = api.useUtils();
// Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
@@ -492,16 +494,27 @@ export const WizardInterface = React.memo(function WizardInterface({
const handlePauseTrial = async () => {
try {
await pauseTrialMutation.mutateAsync({ id: trial.id });
logEventMutation.mutate({
trialId: trial.id,
type: "trial_paused",
data: { timestamp: new Date() }
});
setIsPaused(true);
toast.info("Trial paused");
} catch (error) {
console.error("Failed to pause trial:", error);
}
};
const handleResumeTrial = async () => {
try {
logEventMutation.mutate({
trialId: trial.id,
type: "trial_resumed",
data: { timestamp: new Date() }
});
setIsPaused(false);
toast.success("Trial resumed");
} catch (error) {
console.error("Failed to resume trial:", error);
}
};
const handleNextStep = (targetIndex?: number) => {
// If explicit target provided (from branching choice), use it
if (typeof targetIndex === 'number') {
@@ -577,6 +590,24 @@ export const WizardInterface = React.memo(function WizardInterface({
}
});
// Mark steps as skipped
setSkippedSteps(prev => {
const next = new Set(prev);
for (let i = currentStepIndex + 1; i < targetIndex; i++) {
if (!completedSteps.has(i)) {
next.add(i);
}
}
return next;
});
// Mark current as complete
setCompletedSteps(prev => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
setCurrentStepIndex(targetIndex);
setCompletedActionsCount(0);
return;
@@ -590,6 +621,13 @@ export const WizardInterface = React.memo(function WizardInterface({
// Default: Linear progression
const nextIndex = currentStepIndex + 1;
if (nextIndex < steps.length) {
// Mark current step as complete
setCompletedSteps(prev => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
// Log step change
logEventMutation.mutate({
trialId: trial.id,
@@ -600,6 +638,7 @@ export const WizardInterface = React.memo(function WizardInterface({
fromStepId: currentStep?.id,
toStepId: steps[nextIndex]?.id,
stepName: steps[nextIndex]?.name,
method: "auto"
}
});
@@ -609,14 +648,51 @@ export const WizardInterface = React.memo(function WizardInterface({
}
};
const handleStepSelect = (index: number) => {
if (index === currentStepIndex) return;
// Log manual jump
logEventMutation.mutate({
trialId: trial.id,
type: "intervention_step_jump",
data: {
fromIndex: currentStepIndex,
toIndex: index,
fromStepId: currentStep?.id,
toStepId: steps[index]?.id,
stepName: steps[index]?.name,
method: "manual"
}
});
// Mark current as complete if leaving it?
// Maybe better to only mark on "Next" or explicit complete.
// If I jump away, I might not be done.
// I'll leave 'completedSteps' update to explicit actions or completion.
setCurrentStepIndex(index);
};
const handleCompleteTrial = async () => {
try {
// Mark final step as complete
setCompletedSteps(prev => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
await completeTrialMutation.mutateAsync({ id: trial.id });
// Invalidate queries so the analysis page sees the completed state immediately
await utils.trials.get.invalidate({ id: trial.id });
await utils.trials.getEvents.invalidate({ trialId: trial.id });
// Trigger archive in background
archiveTrialMutation.mutate({ id: trial.id });
// Immediately navigate to analysis
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
} catch (error) {
console.error("Failed to complete trial:", error);
}
@@ -707,12 +783,60 @@ export const WizardInterface = React.memo(function WizardInterface({
category: String(parameters?.category || "quick_note")
});
} else {
// Generic action logging
// Generic action logging - now with more details
// Find the action definition to get its name
let actionName = actionId;
let actionType = "unknown";
// Helper to search recursively
const findAction = (actions: ActionData[], id: string): ActionData | undefined => {
for (const action of actions) {
if (action.id === id) return action;
if (action.parameters?.children) {
const found = findAction(action.parameters.children as ActionData[], id);
if (found) return found;
}
}
return undefined;
};
// Search in current step first
let foundAction: ActionData | undefined;
if (steps[currentStepIndex]?.actions) {
foundAction = findAction(steps[currentStepIndex]!.actions!, actionId);
}
// If not found, search all steps (less efficient but safer)
if (!foundAction) {
for (const step of steps) {
if (step.actions) {
foundAction = findAction(step.actions, actionId);
if (foundAction) break;
}
}
}
if (foundAction) {
actionName = foundAction.name;
actionType = foundAction.type;
} else {
// Fallback for Wizard Actions (often have label/value in parameters)
if (parameters?.label && typeof parameters.label === 'string') {
actionName = parameters.label;
actionType = "wizard_button";
} else if (parameters?.value && typeof parameters.value === 'string') {
actionName = parameters.value;
actionType = "wizard_input";
}
}
await logEventMutation.mutateAsync({
trialId: trial.id,
type: "action_executed",
data: {
actionId,
actionName,
actionType,
parameters
}
});
@@ -734,6 +858,22 @@ export const WizardInterface = React.memo(function WizardInterface({
) => {
try {
setIsExecutingAction(true);
// Core actions execute directly via tRPC (no ROS needed)
if (pluginName === "hristudio-core" || pluginName === "hristudio-woz") {
await executeRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
});
if (options?.autoAdvance) {
handleNextStep();
}
setIsExecutingAction(false);
return;
}
// Try direct WebSocket execution first for better performance
if (rosConnected) {
try {
@@ -778,18 +918,12 @@ export const WizardInterface = React.memo(function WizardInterface({
}
}
} else {
// Use tRPC execution if WebSocket not connected
await executeRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
});
toast.success(`Robot action executed: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
// Not connected - show error and don't try to execute
const errorMsg = "Robot not connected. Cannot execute action.";
toast.error(errorMsg);
console.warn(errorMsg);
// Throw to stop execution flow
throw new Error(errorMsg);
}
} catch (error) {
console.error("Failed to execute robot action:", error);
@@ -825,7 +959,7 @@ export const WizardInterface = React.memo(function WizardInterface({
// Generic skip logging
await logEventMutation.mutateAsync({
trialId: trial.id,
type: "action_skipped",
type: "intervention_action_skipped",
data: {
actionId,
parameters
@@ -842,9 +976,17 @@ export const WizardInterface = React.memo(function WizardInterface({
toast.error("Failed to skip action");
}
},
[logRobotActionMutation, trial.id],
[logRobotActionMutation, trial.id, logEventMutation, handleNextStep],
);
const handleLogEvent = useCallback((type: string, data?: any) => {
logEventMutation.mutate({
trialId: trial.id,
type,
data
});
}, [logEventMutation, trial.id]);
return (
@@ -869,13 +1011,13 @@ export const WizardInterface = React.memo(function WizardInterface({
{trial.status === "in_progress" && (
<>
<Button
variant="outline"
variant={isPaused ? "default" : "outline"}
size="sm"
onClick={handlePauseTrial}
onClick={isPaused ? handleResumeTrial : handlePauseTrial}
className="gap-2"
>
<Pause className="h-4 w-4" />
Pause
{isPaused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
{isPaused ? "Resume" : "Pause"}
</Button>
<Button
@@ -922,190 +1064,128 @@ export const WizardInterface = React.memo(function WizardInterface({
className="flex-none px-2 pb-2"
/>
{/* 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-hidden min-h-0 bg-muted/10">
<div id="tour-wizard-controls-wrapper" className="h-full">
<WizardControlPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
onStartTrial={handleStartTrial}
onPauseTrial={handlePauseTrial}
onNextStep={handleNextStep}
onCompleteTrial={handleCompleteTrial}
onAbortTrial={handleAbortTrial}
onExecuteAction={handleExecuteAction}
isStarting={startTrialMutation.isPending}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</div>
</div>
</div>
)}
{/* Main Grid - Single Row */}
<div className="flex-1 min-h-0 flex gap-2 px-2 pb-2">
{/* 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>
{/* 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]">
<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 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'}
rosConnected={rosConnected}
/>
</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 Control & 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}
rosConnecting={rosConnecting}
rosError={rosError ?? undefined}
robotStatus={robotStatus}
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
onSetAutonomousLife={setAutonomousLife}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
trialId={trial.id}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</div>
</div>
</div>
)}
</div>
<div className="flex-1" />
{/* Bottom Row - Observations (Full Width, Collapsible) */}
{!obsCollapsed && (
<div 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>
<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={() => setObsCollapsed(true)}
onClick={() => setRightCollapsed(false)}
title="Open Status & Tools"
>
<ChevronDown className="h-4 w-4" />
<PanelRightOpen 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}
)}
</div>
<div className="flex-1 overflow-auto bg-muted/10 pb-0">
<div id="tour-wizard-timeline" className="h-full">
<WizardExecutionPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
completedStepIndices={completedSteps}
trialEvents={trialEvents}
readOnly={trial.status === 'completed'}
isPaused={isPaused}
onStepSelect={handleStepSelect}
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'}
rosConnected={rosConnected}
onLogEvent={handleLogEvent}
/>
</div>
</div>
)}
{
obsCollapsed && (
</div>
{/* Right Sidebar - Tools Tabs (Collapsible) */}
<div className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-[350px] lg:w-[400px]",
rightCollapsed && "hidden"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30 shrink-0">
<span className="text-sm font-medium">Tools</span>
<Button
variant="outline"
size="sm"
onClick={() => setObsCollapsed(false)}
className="w-full flex-none"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setRightCollapsed(true)}
>
<ChevronUp className="h-4 w-4 mr-2" />
Show Observations
<PanelRightClose className="h-4 w-4" />
</Button>
)
}
</div >
</div >
</div>
<div className="flex-1 overflow-hidden bg-background">
<Tabs defaultValue="camera_obs" className="flex flex-col h-full w-full">
<TabsList className="w-full justify-start rounded-none border-b bg-muted/30 px-3 py-1 shrink-0 h-10">
<TabsTrigger value="camera_obs" className="text-xs flex-1">Camera & Obs</TabsTrigger>
<TabsTrigger value="robot" className="text-xs flex-1">Robot Control</TabsTrigger>
</TabsList>
<TabsContent value="camera_obs" className="flex-1 flex flex-col m-0 p-0 h-full overflow-hidden min-h-0">
<div className="flex-none bg-muted/30 border-b h-48 sm:h-56 relative group shrink-0">
<WebcamPanel readOnly={trial.status === 'completed'} trialId={trial.id} trialStatus={trial.status} />
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<WizardObservationPane
onAddAnnotation={handleAddAnnotation}
onFlagIntervention={() => handleExecuteAction("intervene")}
isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents}
readOnly={trial.status === 'completed'}
/>
</div>
</TabsContent>
<TabsContent value="robot" className="flex-1 m-0 h-full overflow-hidden">
<WizardMonitoringPanel
rosConnected={rosConnected}
rosConnecting={rosConnecting}
rosError={rosError ?? undefined}
robotStatus={robotStatus}
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
onSetAutonomousLife={setAutonomousLife}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
trialId={trial.id}
trialStatus={trial.status}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</div>
);
});

View File

@@ -16,7 +16,7 @@ import { AspectRatio } from "~/components/ui/aspect-ratio";
import { toast } from "sonner";
import { api } from "~/trpc/react";
export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOnly?: boolean; trialId?: string; trialStatus?: string }) {
const [deviceId, setDeviceId] = useState<string | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
@@ -31,6 +31,10 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
// TRPC mutation for presigned URL
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
// Mutation to save recording metadata to DB
const saveRecordingMutation = api.storage.saveRecording.useMutation();
const logEventMutation = api.trials.logEvent.useMutation();
const handleDevices = useCallback(
(mediaDevices: MediaDeviceInfo[]) => {
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
@@ -38,7 +42,10 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
[setDevices],
);
const [isMounted, setIsMounted] = useState(false);
React.useEffect(() => {
setIsMounted(true);
navigator.mediaDevices.enumerateDevices().then(handleDevices);
}, [handleDevices]);
@@ -54,6 +61,30 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
setIsCameraEnabled(false);
};
// Auto-record based on trial status
React.useEffect(() => {
if (!trialStatus || readOnly) return;
if (trialStatus === "in_progress") {
if (!isCameraEnabled) {
console.log("Auto-enabling camera for trial start");
setIsCameraEnabled(true);
} else if (!isRecording && webcamRef.current?.stream) {
handleStartRecording();
}
} else if (trialStatus === "completed" && isRecording) {
handleStopRecording();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trialStatus, isCameraEnabled, isRecording, readOnly]);
const handleUserMedia = () => {
if (trialStatus === "in_progress" && !isRecording && !readOnly) {
console.log("Stream ready, auto-starting camera recording");
handleStartRecording();
}
};
const handleStartRecording = () => {
if (!webcamRef.current?.stream) return;
@@ -78,6 +109,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
recorder.start();
mediaRecorderRef.current = recorder;
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_started",
data: { action: "recording_started" }
});
}
toast.success("Recording started");
} catch (e) {
console.error("Failed to start recorder:", e);
@@ -90,6 +128,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_stopped",
data: { action: "recording_stopped" }
});
}
}
};
@@ -114,7 +159,30 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
});
if (!response.ok) {
throw new Error("Upload failed");
const errorText = await response.text();
throw new Error(`Upload failed: ${errorText} | Status: ${response.status}`);
}
// 3. Save metadata to DB
if (trialId) {
console.log("Attempting to link recording to trial:", trialId);
try {
await saveRecordingMutation.mutateAsync({
trialId,
storagePath: filename,
mediaType: "video",
format: "webm",
fileSize: blob.size,
});
console.log("Recording successfully linked to trial:", trialId);
toast.success("Recording saved to trial log");
} catch (mutationError) {
console.error("Failed to link recording to trial:", mutationError);
toast.error("Video uploaded but failed to link to trial");
}
} else {
console.warn("No trialId provided, recording uploaded but not linked. Props:", { trialId });
toast.warning("Trial ID missing - recording not linked");
}
toast.success("Recording uploaded successfully");
@@ -137,7 +205,7 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
{!readOnly && (
<div className="flex items-center gap-2">
{devices.length > 0 && (
{devices.length > 0 && isMounted && (
<Select
value={deviceId ?? undefined}
onValueChange={setDeviceId}
@@ -217,6 +285,7 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
width="100%"
height="100%"
videoConstraints={{ deviceId: deviceId ?? undefined }}
onUserMedia={handleUserMedia}
onUserMediaError={(err) => setError(String(err))}
className="object-contain w-full h-full"
/>

View File

@@ -47,6 +47,7 @@ interface WizardActionItemProps {
isExecuting?: boolean;
depth?: number;
isRobotConnected?: boolean;
onLogEvent?: (type: string, data?: any) => void;
}
export function WizardActionItem({
@@ -62,6 +63,7 @@ export function WizardActionItem({
isExecuting,
depth = 0,
isRobotConnected = false,
onLogEvent,
}: WizardActionItemProps): React.JSX.Element {
// Local state for container children completion
const [completedChildren, setCompletedChildren] = useState<Set<number>>(new Set());
@@ -289,13 +291,14 @@ export function WizardActionItem({
isExecuting={isExecuting}
depth={depth + 1}
isRobotConnected={isRobotConnected}
onLogEvent={onLogEvent}
/>
))}
</div>
) : null) as any}
{/* Active Action Controls */}
{isActive && !readOnly && (
{(isActive || (isCompleted && !readOnly)) && (
<div className="pt-3 flex flex-wrap items-center gap-3">
{/* Parallel Container Controls */}
{isContainer && action.type.includes("parallel") ? (
@@ -326,20 +329,22 @@ export function WizardActionItem({
title={isButtonDisabled && !isExecuting ? "Robot disconnected" : undefined}
>
<Play className="mr-2 h-3.5 w-3.5" />
Run All
</Button>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
onCompleted();
}}
disabled={isExecuting}
>
<CheckCircle className="mr-2 h-3.5 w-3.5" />
Mark Group Complete
{isCompleted ? "Rerun All" : "Run All"}
</Button>
{!isCompleted && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
onCompleted();
}}
disabled={isExecuting}
>
<CheckCircle className="mr-2 h-3.5 w-3.5" />
Mark Group Complete
</Button>
)}
</>
) : (
/* Standard Single Action Controls */
@@ -367,7 +372,7 @@ export function WizardActionItem({
action.parameters || {},
{ autoAdvance: false }
);
onCompleted();
if (!isCompleted) onCompleted();
} catch (error) {
console.error("Action execution error:", error);
} finally {
@@ -386,39 +391,50 @@ export function WizardActionItem({
) : (
<>
<Play className="mr-2 h-3.5 w-3.5" />
Run
{isCompleted ? "Rerun" : "Run"}
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
onCompleted();
}}
disabled={isExecuting}
>
<CheckCircle className="mr-2 h-3.5 w-3.5" />
Mark Complete
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
if (onSkip) {
onSkip(action.pluginId!, action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.parameters || {}, { autoAdvance: false });
}
onCompleted();
}}
>
Skip
</Button>
{!isCompleted && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
// Log manual completion
if (onLogEvent) {
onLogEvent("action_marked_complete", {
actionId: action.id,
formatted: "Action manually marked complete"
});
}
onCompleted();
}}
disabled={isExecuting}
>
<CheckCircle className="mr-2 h-3.5 w-3.5" />
Mark Complete
</Button>
)}
{!isCompleted && (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
if (onSkip) {
onSkip(action.pluginId!, action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.parameters || {}, { autoAdvance: false });
}
onCompleted();
}}
>
Skip
</Button>
)}
</>
) : (
// Manual/Wizard Actions (Leaf nodes)
!isContainer && action.type !== "wizard_wait_for_response" && (
!isContainer && action.type !== "wizard_wait_for_response" && !isCompleted && (
<Button
size="sm"
onClick={(e) => {
@@ -437,7 +453,7 @@ export function WizardActionItem({
)}
{/* Branching / Choice UI */}
{isActive &&
{(isActive || (isCompleted && !readOnly)) &&
(action.type === "wizard_wait_for_response" || isBranch) &&
action.parameters?.options &&
Array.isArray(action.parameters.options) && (

View File

@@ -113,7 +113,11 @@ interface WizardExecutionPanelProps {
completedActionsCount: number;
onActionCompleted: () => void;
readOnly?: boolean;
isPaused?: boolean;
rosConnected?: boolean;
completedStepIndices?: Set<number>;
skippedStepIndices?: Set<number>;
onLogEvent?: (type: string, data?: any) => void;
}
export function WizardExecutionPanel({
@@ -134,12 +138,17 @@ export function WizardExecutionPanel({
completedActionsCount,
onActionCompleted,
readOnly = false,
isPaused = false,
rosConnected,
completedStepIndices = new Set(),
skippedStepIndices = new Set(),
onLogEvent,
}: 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 isStepCompleted = completedStepIndices.has(currentStepIndex);
const activeActionIndex = isStepCompleted ? 9999 : completedActionsCount;
// Auto-scroll to active action
const activeActionRef = React.useRef<HTMLDivElement>(null);
@@ -210,13 +219,29 @@ export function WizardExecutionPanel({
// Active trial state
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex h-full flex-col overflow-hidden relative">
{/* Paused Overlay */}
{isPaused && (
<div className="absolute inset-0 z-50 bg-background/60 backdrop-blur-[2px] flex items-center justify-center">
<div className="bg-background border shadow-lg rounded-xl p-8 flex flex-col items-center max-w-sm text-center space-y-4">
<AlertCircle className="h-12 w-12 text-muted-foreground" />
<div>
<h2 className="text-xl font-bold tracking-tight">Trial Paused</h2>
<p className="text-sm text-muted-foreground mt-1">
The trial execution has been paused. Resume from the control bar to continue interacting.
</p>
</div>
</div>
</div>
)}
{/* Horizontal Step Progress Bar */}
<div className="flex-none border-b bg-muted/30 p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-2">
{steps.map((step, idx) => {
const isCurrent = idx === currentStepIndex;
const isCompleted = idx < currentStepIndex;
const isSkipped = skippedStepIndices.has(idx);
const isCompleted = completedStepIndices.has(idx) || (!isSkipped && idx < currentStepIndex);
const isUpcoming = idx > currentStepIndex;
return (
@@ -233,7 +258,9 @@ export function WizardExecutionPanel({
? "border-primary bg-primary/10 shadow-sm"
: isCompleted
? "border-primary/30 bg-primary/5 hover:bg-primary/10"
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
: isSkipped
? "border-muted-foreground/30 bg-muted/20 border-dashed"
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
}
${readOnly ? "cursor-default" : "cursor-pointer"}
`}
@@ -244,9 +271,11 @@ export function WizardExecutionPanel({
flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold
${isCompleted
? "bg-primary text-primary-foreground"
: isCurrent
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
: "bg-muted text-muted-foreground"
: isSkipped
? "bg-transparent border border-muted-foreground/40 text-muted-foreground"
: isCurrent
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
: "bg-muted text-muted-foreground"
}
`}
>
@@ -348,6 +377,7 @@ export function WizardExecutionPanel({
readOnly={readOnly}
isExecuting={isExecuting}
isRobotConnected={rosConnected}
onLogEvent={onLogEvent}
/>
</div>
);

View File

@@ -14,7 +14,6 @@ import { Alert, AlertDescription } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { WebcamPanel } from "./WebcamPanel";
import { RobotActionsPanel } from "../RobotActionsPanel";
interface WizardMonitoringPanelProps {
@@ -44,6 +43,7 @@ interface WizardMonitoringPanelProps {
) => Promise<void>;
studyId?: string;
trialId?: string;
trialStatus?: string;
readOnly?: boolean;
}
@@ -59,6 +59,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
onExecuteRobotAction,
studyId,
trialId,
trialStatus,
readOnly = false,
}: WizardMonitoringPanelProps) {
const [autonomousLife, setAutonomousLife] = React.useState(true);
@@ -78,12 +79,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
}
}, [onSetAutonomousLife]);
return (
<div className="flex h-full flex-col gap-2 p-2">
{/* Camera View - Always Visible */}
<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>
<div className="flex h-full flex-col p-2">
{/* Robot Controls - Scrollable */}
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b bg-muted/30 flex items-center gap-2">

View File

@@ -29,6 +29,7 @@ interface WizardObservationPaneProps {
category?: string,
tags?: string[],
) => Promise<void>;
onFlagIntervention?: () => Promise<void> | void;
isSubmitting?: boolean;
readOnly?: boolean;
@@ -36,6 +37,7 @@ interface WizardObservationPaneProps {
export function WizardObservationPane({
onAddAnnotation,
onFlagIntervention,
isSubmitting = false,
trialEvents = [],
readOnly = false,
@@ -118,11 +120,23 @@ export function WizardObservationPane({
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !note.trim() || readOnly}
className="h-8"
className="h-8 shrink-0"
>
<Send className="mr-2 h-3 w-3" />
Add Note
</Button>
{onFlagIntervention && (
<Button
size="sm"
variant="outline"
onClick={() => onFlagIntervention()}
disabled={readOnly}
className="h-8 shrink-0 border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
>
<AlertTriangle className="mr-2 h-3 w-3" />
Intervention
</Button>
)}
</div>
{tags.length > 0 && (