feat(analytics): refine timeline visualization and add print support

This commit is contained in:
2026-02-17 21:17:11 -05:00
parent 568d408587
commit 72971a4b49
82 changed files with 6670 additions and 2448 deletions

View File

@@ -0,0 +1,273 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Switch } from "~/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { Loader2, Settings2 } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface RobotSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
studyId: string;
pluginId: string;
settingsSchema: SettingsSchema | null;
}
interface SettingsSchema {
type: "object";
title?: string;
description?: string;
properties: Record<string, PropertySchema>;
}
interface PropertySchema {
type: "object" | "string" | "number" | "integer" | "boolean";
title?: string;
description?: string;
properties?: Record<string, PropertySchema>;
enum?: string[];
enumNames?: string[];
minimum?: number;
maximum?: number;
default?: unknown;
pattern?: string;
}
export function RobotSettingsModal({
open,
onOpenChange,
studyId,
pluginId,
settingsSchema,
}: RobotSettingsModalProps) {
const [settings, setSettings] = useState<Record<string, unknown>>({});
const [isSaving, setIsSaving] = useState(false);
// Fetch current settings
const { data: currentSettings, isLoading } = api.studies.getPluginConfiguration.useQuery(
{ studyId, pluginId },
{ enabled: open }
);
// Update settings mutation
const updateSettings = api.studies.updatePluginConfiguration.useMutation({
onSuccess: () => {
toast.success("Robot settings updated successfully");
onOpenChange(false);
},
onError: (error: { message: string }) => {
toast.error(`Failed to update settings: ${error.message}`);
},
});
// Initialize settings from current configuration
// eslint-disable-next-line react-hooks/exhaustive-deps
useState(() => {
if (currentSettings) {
setSettings(currentSettings as Record<string, unknown>);
}
});
const handleSave = async () => {
setIsSaving(true);
try {
await updateSettings.mutateAsync({
studyId,
pluginId,
configuration: settings,
});
} finally {
setIsSaving(false);
}
};
const renderField = (key: string, schema: PropertySchema, parentPath: string = "") => {
const fullPath = parentPath ? `${parentPath}.${key}` : key;
const value = getNestedValue(settings, fullPath);
const defaultValue = schema.default;
const updateValue = (newValue: unknown) => {
setSettings((prev) => setNestedValue({ ...prev }, fullPath, newValue));
};
// Object type - render nested fields
if (schema.type === "object" && schema.properties) {
return (
<div key={fullPath} className="space-y-4">
<div className="space-y-1">
<h4 className="text-sm font-semibold">{schema.title || key}</h4>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
</div>
<div className="ml-4 space-y-3">
{Object.entries(schema.properties).map(([subKey, subSchema]) =>
renderField(subKey, subSchema, fullPath)
)}
</div>
</div>
);
}
// Boolean type - render switch
if (schema.type === "boolean") {
return (
<div key={fullPath} className="flex items-center justify-between space-x-2">
<div className="space-y-0.5 flex-1">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
</div>
<Switch
id={fullPath}
checked={(value ?? defaultValue) as boolean}
onCheckedChange={updateValue}
/>
</div>
);
}
// Enum type - render select
if (schema.enum) {
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
<Select
value={(value ?? defaultValue) as string}
onValueChange={updateValue}
>
<SelectTrigger id={fullPath}>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{schema.enum.map((option, idx) => (
<SelectItem key={option} value={option}>
{schema.enumNames?.[idx] || option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// Number/Integer type - render number input
if (schema.type === "number" || schema.type === "integer") {
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
<Input
id={fullPath}
type="number"
min={schema.minimum}
max={schema.maximum}
step={schema.type === "integer" ? 1 : 0.1}
value={(value ?? defaultValue) as number}
onChange={(e) => {
const newValue = schema.type === "integer"
? parseInt(e.target.value, 10)
: parseFloat(e.target.value);
updateValue(isNaN(newValue) ? defaultValue : newValue);
}}
/>
</div>
);
}
// String type - render text input
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
<Input
id={fullPath}
type="text"
pattern={schema.pattern}
value={(value ?? defaultValue) as string}
onChange={(e) => updateValue(e.target.value)}
/>
</div>
);
};
if (!settingsSchema) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
{settingsSchema.title || "Robot Settings"}
</DialogTitle>
{settingsSchema.description && (
<DialogDescription>{settingsSchema.description}</DialogDescription>
)}
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-6 py-4">
{Object.entries(settingsSchema.properties).map(([key, schema], idx) => (
<div key={key}>
{renderField(key, schema)}
{idx < Object.keys(settingsSchema.properties).length - 1 && (
<Separator className="mt-6" />
)}
</div>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving || isLoading}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Helper functions for nested object access
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
return path.split(".").reduce((current, key) => {
return current && typeof current === "object" ? (current as Record<string, unknown>)[key] : undefined;
}, obj as unknown);
}
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const keys = path.split(".");
const lastKey = keys.pop()!;
const target = keys.reduce((current, key) => {
if (!current[key] || typeof current[key] !== "object") {
current[key] = {};
}
return current[key] as Record<string, unknown>;
}, obj);
target[lastKey] = value;
return obj;
}

View File

@@ -113,7 +113,7 @@ export const WizardInterface = React.memo(function WizardInterface({
// 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"
@@ -202,13 +202,23 @@ export const WizardInterface = React.memo(function WizardInterface({
connect: connectRos,
disconnect: disconnectRos,
executeRobotAction: executeRosAction,
setAutonomousLife,
setAutonomousLife: setAutonomousLifeRaw,
} = useWizardRos({
autoConnect: true,
onActionCompleted,
onActionFailed,
});
// Wrap setAutonomousLife in a stable callback to prevent infinite re-renders
// The raw function from useWizardRos is recreated when isConnected changes,
// which would cause WizardControlPanel (wrapped in React.memo) to re-render infinitely
const setAutonomousLife = useCallback(
async (enabled: boolean) => {
return setAutonomousLifeRaw(enabled);
},
[setAutonomousLifeRaw]
);
// Use polling for trial status updates (no trial WebSocket server exists)
const { data: pollingData } = api.trials.get.useQuery(
{ id: trial.id },
@@ -237,19 +247,28 @@ export const WizardInterface = React.memo(function WizardInterface({
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
setTrial((prev) => ({
...prev,
status: pollingData.status,
startedAt: pollingData.startedAt
? new Date(pollingData.startedAt)
: prev.startedAt,
completedAt: pollingData.completedAt
? new Date(pollingData.completedAt)
: prev.completedAt,
}));
setTrial((prev) => {
// Double check inside setter to be safe
if (prev.status === pollingData.status &&
prev.startedAt?.getTime() === pollingData.startedAt?.getTime() &&
prev.completedAt?.getTime() === pollingData.completedAt?.getTime()) {
return prev;
}
return {
...prev,
status: pollingData.status,
startedAt: pollingData.startedAt
? new Date(pollingData.startedAt)
: prev.startedAt,
completedAt: pollingData.completedAt
? new Date(pollingData.completedAt)
: prev.completedAt,
};
});
}
}
}, [pollingData, trial]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pollingData]);
// Auto-start trial on mount if scheduled
useEffect(() => {
@@ -259,7 +278,6 @@ export const WizardInterface = React.memo(function WizardInterface({
}, []); // Run once on mount
// Trial events from robot actions
const trialEvents = useMemo<
Array<{
type: string;
@@ -301,7 +319,7 @@ export const WizardInterface = React.memo(function WizardInterface({
}, [fetchedEvents]);
// Transform experiment steps to component format
const steps: StepData[] =
const steps: StepData[] = useMemo(() =>
experimentSteps?.map((step, index) => ({
id: step.id,
name: step.name ?? `Step ${index + 1}`,
@@ -320,7 +338,8 @@ export const WizardInterface = React.memo(function WizardInterface({
order: action.order,
pluginId: action.pluginId,
})) ?? [],
})) ?? [];
})) ?? [], [experimentSteps]);
const currentStep = steps[currentStepIndex] ?? null;
const totalSteps = steps.length;
@@ -451,6 +470,8 @@ export const WizardInterface = React.memo(function WizardInterface({
const result = await startTrialMutation.mutateAsync({ id: trial.id });
console.log("[WizardInterface] Trial started successfully", result);
// Update local state immediately
setTrial((prev) => ({
...prev,
@@ -471,6 +492,11 @@ 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() }
});
} catch (error) {
console.error("Failed to pause trial:", error);
}
@@ -482,6 +508,20 @@ export const WizardInterface = React.memo(function WizardInterface({
// Find step by index to ensure safety
if (targetIndex >= 0 && targetIndex < steps.length) {
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
// Log manual jump
logEventMutation.mutate({
trialId: trial.id,
type: "step_jumped",
data: {
fromIndex: currentStepIndex,
toIndex: targetIndex,
fromStepId: steps[currentStepIndex]?.id,
toStepId: steps[targetIndex]?.id,
reason: "manual_choice"
}
});
setCompletedActionsCount(0);
setCurrentStepIndex(targetIndex);
setLastResponse(null);
@@ -500,6 +540,18 @@ export const WizardInterface = React.memo(function WizardInterface({
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
if (targetIndex !== -1) {
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
logEventMutation.mutate({
trialId: trial.id,
type: "step_branched",
data: {
fromIndex: currentStepIndex,
toIndex: targetIndex,
condition: matchedOption.label,
value: lastResponse
}
});
setCurrentStepIndex(targetIndex);
setLastResponse(null); // Reset after consuming
return;
@@ -514,6 +566,17 @@ export const WizardInterface = React.memo(function WizardInterface({
const targetIndex = steps.findIndex(s => s.id === nextId);
if (targetIndex !== -1) {
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
logEventMutation.mutate({
trialId: trial.id,
type: "step_jumped",
data: {
fromIndex: currentStepIndex,
toIndex: targetIndex,
reason: "condition_next_step"
}
});
setCurrentStepIndex(targetIndex);
setCompletedActionsCount(0);
return;
@@ -549,6 +612,9 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleCompleteTrial = async () => {
try {
await completeTrialMutation.mutateAsync({ id: trial.id });
// Trigger archive in background
archiveTrialMutation.mutate({ id: trial.id });
} catch (error) {
@@ -559,6 +625,8 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleAbortTrial = async () => {
try {
await abortTrialMutation.mutateAsync({ id: trial.id });
} catch (error) {
console.error("Failed to abort trial:", error);
}
@@ -638,6 +706,16 @@ export const WizardInterface = React.memo(function WizardInterface({
description: String(parameters?.content || "Quick note"),
category: String(parameters?.category || "quick_note")
});
} else {
// Generic action logging
await logEventMutation.mutateAsync({
trialId: trial.id,
type: "action_executed",
data: {
actionId,
parameters
}
});
}
// Note: Action execution can be enhanced later with tRPC mutations
@@ -733,14 +811,27 @@ export const WizardInterface = React.memo(function WizardInterface({
options?: { autoAdvance?: boolean },
) => {
try {
await logRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
duration: 0,
result: { skipped: true },
});
// If it's a robot action (indicated by pluginName), use the robot logger
if (pluginName) {
await logRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
duration: 0,
result: { skipped: true },
});
} else {
// Generic skip logging
await logEventMutation.mutateAsync({
trialId: trial.id,
type: "action_skipped",
data: {
actionId,
parameters
}
});
}
toast.info(`Action skipped: ${actionId}`);
if (options?.autoAdvance) {
@@ -849,8 +940,8 @@ export const WizardInterface = React.memo(function WizardInterface({
<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">
<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}
@@ -862,11 +953,7 @@ export const WizardInterface = React.memo(function WizardInterface({
onCompleteTrial={handleCompleteTrial}
onAbortTrial={handleAbortTrial}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
_isConnected={rosConnected}
isStarting={startTrialMutation.isPending}
onSetAutonomousLife={setAutonomousLife}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</div>
@@ -937,6 +1024,7 @@ export const WizardInterface = React.memo(function WizardInterface({
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
rosConnected={rosConnected}
/>
</div>
</div>
@@ -946,7 +1034,7 @@ export const WizardInterface = React.memo(function WizardInterface({
{!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>
<span className="text-sm font-medium">Robot Control & Status</span>
<Button
variant="ghost"
size="icon"
@@ -966,6 +1054,10 @@ export const WizardInterface = React.memo(function WizardInterface({
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
onSetAutonomousLife={setAutonomousLife}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
trialId={trial.id}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</div>
@@ -976,13 +1068,9 @@ export const WizardInterface = React.memo(function WizardInterface({
{/* 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 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"
@@ -999,10 +1087,9 @@ export const WizardInterface = React.memo(function WizardInterface({
isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents}
readOnly={trial.status === 'completed'}
activeTab={obsTab}
/>
</div>
</Tabs>
</div>
)}
{
obsCollapsed && (

View File

@@ -0,0 +1,498 @@
import React, { useState, useCallback } from "react";
import {
Play,
CheckCircle,
RotateCcw,
Clock,
Repeat,
Split,
Layers,
ChevronRight,
Loader2,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { Badge } from "~/components/ui/badge";
export interface ActionData {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}
interface WizardActionItemProps {
action: ActionData;
index: number;
isActive: boolean;
isCompleted: boolean;
onExecute: (actionId: string, parameters?: Record<string, unknown>) => void;
onExecuteRobot: (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean }
) => Promise<void>;
onSkip: (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean }
) => Promise<void>;
onCompleted: () => void;
readOnly?: boolean;
isExecuting?: boolean;
depth?: number;
isRobotConnected?: boolean;
}
export function WizardActionItem({
action,
index,
isActive,
isCompleted,
onExecute,
onExecuteRobot,
onSkip,
onCompleted,
readOnly,
isExecuting,
depth = 0,
isRobotConnected = false,
}: WizardActionItemProps): React.JSX.Element {
// Local state for container children completion
const [completedChildren, setCompletedChildren] = useState<Set<number>>(new Set());
// Local state for loop iterations
const [currentIteration, setCurrentIteration] = useState(1);
// Local state to track execution of this specific item
const [isRunningLocal, setIsRunningLocal] = useState(false);
// Local state for wait countdown
const [countdown, setCountdown] = useState<number | null>(null);
const isContainer =
action.type === "hristudio-core.sequence" ||
action.type === "hristudio-core.parallel" ||
action.type === "hristudio-core.loop" ||
action.type === "sequence" ||
action.type === "parallel" ||
action.type === "loop";
// Branch support
const isBranch = action.type === "hristudio-core.branch" || action.type === "branch";
const isWait = action.type === "hristudio-core.wait" || action.type === "wait";
// Helper to get children
const children = (action.parameters.children as ActionData[]) || [];
const iterations = (action.parameters.iterations as number) || 1;
// Recursive helper to check for robot actions
const hasRobotActions = useCallback((item: ActionData): boolean => {
if (item.type === "robot_action" || !!item.pluginId) return true;
if (item.parameters?.children && Array.isArray(item.parameters.children)) {
return (item.parameters.children as ActionData[]).some(hasRobotActions);
}
return false;
}, []);
const containsRobotActions = hasRobotActions(action);
// Countdown effect
React.useEffect(() => {
let interval: NodeJS.Timeout;
if (isRunningLocal && countdown !== null && countdown > 0) {
interval = setInterval(() => {
setCountdown((prev) => (prev !== null && prev > 0 ? prev - 1 : 0));
}, 1000);
}
return () => clearInterval(interval);
}, [isRunningLocal, countdown]);
// Derived state for disabled button
const isButtonDisabled =
isExecuting ||
isRunningLocal ||
(!isWait && !isRobotConnected && (action.type === 'robot_action' || !!action.pluginId || (isContainer && containsRobotActions)));
// Handler for child completion
const handleChildCompleted = useCallback((childIndex: number) => {
setCompletedChildren(prev => {
const next = new Set(prev);
next.add(childIndex);
return next;
});
}, []);
// Handler for next loop iteration
const handleNextIteration = useCallback(() => {
if (currentIteration < iterations) {
setCompletedChildren(new Set());
setCurrentIteration(prev => prev + 1);
} else {
// Loop finished - allow manual completion of the loop action
}
}, [currentIteration, iterations]);
// Check if current iteration is complete (all children done)
const isIterationComplete = children.length > 0 && children.every((_, idx) => completedChildren.has(idx));
const isLoopComplete = isIterationComplete && currentIteration >= iterations;
return (
<div
className={cn(
"relative pb-2 last:pb-0 transition-all duration-300",
depth > 0 && "ml-4 mt-2 border-l pl-4 border-l-border/30"
)}
>
{/* Visual Connection Line for Root items is handled by parent list,
but for nested items we handle it via border-l above */}
<div
className={cn(
"rounded-lg border transition-all duration-300",
isActive
? "bg-card border-primary/50 shadow-md p-4"
: "bg-muted/5 border-transparent p-3 opacity-80 hover:opacity-100",
isContainer && "bg-muted/10 border-border/50"
)}
>
<div className="space-y-2">
{/* Header Row */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
{/* Icon based on type */}
{isContainer && action.type.includes("loop") && <Repeat className="h-4 w-4 text-blue-500 dark:text-blue-400" />}
{isContainer && action.type.includes("parallel") && <Layers className="h-4 w-4 text-purple-500 dark:text-purple-400" />}
{isBranch && <Split className="h-4 w-4 text-orange-500 dark:text-orange-400" />}
{isWait && <Clock className="h-4 w-4 text-amber-500 dark:text-amber-400" />}
<div
className={cn(
"text-base font-medium leading-none",
isCompleted && "line-through text-muted-foreground"
)}
>
{action.name}
</div>
</div>
{/* Completion Badge */}
{isCompleted && <CheckCircle className="h-4 w-4 text-green-500 dark:text-green-400" />}
</div>
{action.description && (
<div className="text-sm text-muted-foreground">
{action.description}
</div>
)}
{/* Details for Control Flow */}
{isWait && (
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50/80 dark:text-amber-300 dark:bg-amber-900/30 w-fit px-2 py-1 rounded border border-amber-100 dark:border-amber-800/50">
<Clock className="h-3 w-3" />
Wait {String(action.parameters.duration || 1)}s
</div>
)}
{action.type.includes("loop") && (
<div className="flex items-center gap-2 text-xs text-blue-700 bg-blue-50/80 dark:text-blue-300 dark:bg-blue-900/30 w-fit px-2 py-1 rounded border border-blue-100 dark:border-blue-800/50">
<Repeat className="h-3 w-3" />
{String(action.parameters.iterations || 1)} Iterations
</div>
)}
{((!!isContainer && children.length > 0) ? (
<div className="mt-4 space-y-2">
{/* Loop Iteration Status & Controls */}
{action.type.includes("loop") && (
<div className="flex items-center justify-between bg-blue-50/50 dark:bg-blue-900/20 p-2 rounded mb-2 border border-blue-100 dark:border-blue-800/50">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-white dark:bg-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
Iteration {currentIteration} of {iterations}
</Badge>
{isIterationComplete && currentIteration < iterations && (
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium animate-pulse">
All actions complete. Ready for next iteration.
</span>
)}
{isLoopComplete && (
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
Loop complete!
</span>
)}
</div>
{isLoopComplete ? (
<Button
size="sm"
onClick={(e) => {
e.preventDefault();
onCompleted();
}}
className="h-7 text-xs bg-green-600 hover:bg-green-700 text-white dark:bg-green-600 dark:hover:bg-green-500"
>
<CheckCircle className="mr-1 h-3 w-3" />
Finish Loop
</Button>
) : (
isIterationComplete && currentIteration < iterations && !readOnly && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
onClick={(e) => {
e.preventDefault();
onCompleted();
}}
className="h-7 text-xs"
>
<ChevronRight className="mr-1 h-3 w-3" />
Exit Loop
</Button>
<Button
size="sm"
onClick={(e) => {
e.preventDefault();
handleNextIteration();
}}
className="h-7 text-xs"
>
<Repeat className="mr-1 h-3 w-3" />
Next Iteration
</Button>
</div>
)
)}
</div>
)}
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{action.type.includes("loop") ? "Loop Body" : "Actions"}
</div>
{children.map((child, idx) => (
<WizardActionItem
key={`${child.id || idx}-${currentIteration}`}
action={child as ActionData}
index={idx}
isActive={isActive && !isCompleted && !completedChildren.has(idx)}
isCompleted={isCompleted || completedChildren.has(idx)}
onExecute={onExecute}
onExecuteRobot={onExecuteRobot}
onSkip={onSkip}
onCompleted={() => handleChildCompleted(idx)}
readOnly={readOnly || isCompleted || completedChildren.has(idx) || (action.type.includes("parallel") && true)}
isExecuting={isExecuting}
depth={depth + 1}
isRobotConnected={isRobotConnected}
/>
))}
</div>
) : null) as any}
{/* Active Action Controls */}
{isActive && !readOnly && (
<div className="pt-3 flex flex-wrap items-center gap-3">
{/* Parallel Container Controls */}
{isContainer && action.type.includes("parallel") ? (
<>
<Button
size="sm"
className={cn(
"shadow-sm min-w-[100px]",
isButtonDisabled && "opacity-50 cursor-not-allowed"
)}
onClick={async (e) => {
e.preventDefault();
// Run all child robot actions
const children = (action.parameters.children as ActionData[]) || [];
for (const child of children) {
if (child.pluginId) {
// Fire and forget - don't await sequentially
onExecuteRobot(
child.pluginId,
child.type.includes(".") ? child.type.split(".").pop()! : child.type,
child.parameters || {},
{ autoAdvance: false }
).catch(console.error);
}
}
}}
disabled={isButtonDisabled}
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
</Button>
</>
) : (
/* Standard Single Action Controls */
(action.pluginId && !["hristudio-woz"].includes(action.pluginId!) && (action.pluginId !== "hristudio-core" || isWait)) ? (
<>
<Button
size="sm"
className={cn(
"shadow-sm min-w-[100px]",
isButtonDisabled && "opacity-50 cursor-not-allowed"
)}
onClick={async (e) => {
e.preventDefault();
setIsRunningLocal(true);
if (isWait) {
const duration = Number(action.parameters.duration || 1);
setCountdown(Math.ceil(duration));
}
try {
await onExecuteRobot(
action.pluginId!,
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
action.parameters || {},
{ autoAdvance: false }
);
onCompleted();
} catch (error) {
console.error("Action execution error:", error);
} finally {
setIsRunningLocal(false);
setCountdown(null);
}
}}
disabled={isExecuting || isRunningLocal || (!isWait && !isRobotConnected)}
title={!isWait && !isRobotConnected ? "Robot disconnected" : undefined}
>
{isRunningLocal ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
{isWait ? (countdown !== null && countdown > 0 ? `Wait (${countdown}s)...` : "Finishing...") : "Running..."}
</>
) : (
<>
<Play className="mr-2 h-3.5 w-3.5" />
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>
</>
) : (
// Manual/Wizard Actions (Leaf nodes)
!isContainer && action.type !== "wizard_wait_for_response" && (
<Button
size="sm"
onClick={(e) => {
e.preventDefault();
onCompleted();
}}
disabled={isExecuting}
>
<CheckCircle className="mr-2 h-3.5 w-3.5" />
Mark Complete
</Button>
)
)
)}
</div>
)}
{/* Branching / Choice UI */}
{isActive &&
(action.type === "wizard_wait_for_response" || isBranch) &&
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) => {
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 hover:border-primary hover:bg-primary/5"
onClick={(e) => {
e.preventDefault();
onExecute(action.id, { value, label, nextStepId });
onCompleted();
}}
disabled={readOnly || isExecuting}
>
<div className="flex flex-col items-start gap-1">
<span className="font-medium">{String(label)}</span>
</div>
</Button>
);
})}
</div>
)}
{/* Retry for failed/completed robot actions */}
{isCompleted && action.pluginId && !isContainer && (
<div className="pt-1 flex items-center gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-muted-foreground hover:text-primary"
onClick={(e) => {
e.preventDefault();
onExecuteRobot(
action.pluginId!,
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
action.parameters || {},
{ autoAdvance: false }
);
}}
disabled={isExecuting}
>
<RotateCcw className="mr-1.5 h-3 w-3" />
Retry
</Button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -18,12 +18,8 @@ import {
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { Separator } from "~/components/ui/separator";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { ScrollArea } from "~/components/ui/scroll-area";
import { RobotActionsPanel } from "../RobotActionsPanel";
interface StepData {
id: string;
@@ -95,16 +91,7 @@ interface WizardControlPanelProps {
actionId: string,
parameters?: Record<string, unknown>,
) => void;
onExecuteRobotAction?: (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
) => Promise<void>;
studyId?: string;
_isConnected: boolean;
isStarting?: boolean;
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
readOnly?: boolean;
}
@@ -119,30 +106,10 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
onCompleteTrial,
onAbortTrial,
onExecuteAction,
onExecuteRobotAction,
studyId,
_isConnected,
isStarting = false,
onSetAutonomousLife,
readOnly = false,
}: WizardControlPanelProps) {
const [autonomousLife, setAutonomousLife] = React.useState(true);
const handleAutonomousLifeChange = async (checked: boolean) => {
setAutonomousLife(checked); // Optimistic update
if (onSetAutonomousLife) {
try {
const result = await onSetAutonomousLife(checked);
if (result === false) {
throw new Error("Service unavailable");
}
} catch (error) {
console.error("Failed to set autonomous life:", error);
setAutonomousLife(!checked); // Revert on failure
// Optional: Toast error?
}
}
};
return (
<div className="flex h-full flex-col" id="tour-wizard-controls">
@@ -170,7 +137,7 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
<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"
className="w-full justify-start 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"
onClick={() => onExecuteAction("intervene")}
disabled={readOnly}
>
@@ -207,50 +174,27 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
Controls available during trial
</div>
)}
</div>
<Separator />
{/* 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>
) : (
<Badge variant="outline" className="text-muted-foreground border-muted-foreground/30 text-xs">Offline</Badge>
)}
{/* Step Navigation */}
<div className="pt-4 border-t space-y-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Navigation</span>
<select
className="w-full text-xs p-2 rounded-md border bg-background"
value={currentStepIndex}
onChange={(e) => onNextStep(parseInt(e.target.value, 10))}
disabled={readOnly}
>
{steps.map((step, idx) => (
<option key={step.id} value={idx}>
{idx + 1}. {step.name}
</option>
))}
</select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
<Switch
id="tour-wizard-autonomous"
checked={!!autonomousLife}
onCheckedChange={handleAutonomousLifeChange}
disabled={!_isConnected || readOnly}
className="scale-75"
/>
</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 >
</div>
</div>
);
});

View File

@@ -1,6 +1,8 @@
"use client";
import React from "react";
import { WizardActionItem } from "./WizardActionItem";
import {
Play,
SkipForward,
@@ -111,6 +113,7 @@ interface WizardExecutionPanelProps {
completedActionsCount: number;
onActionCompleted: () => void;
readOnly?: boolean;
rosConnected?: boolean;
}
export function WizardExecutionPanel({
@@ -131,6 +134,7 @@ export function WizardExecutionPanel({
completedActionsCount,
onActionCompleted,
readOnly = false,
rosConnected,
}: WizardExecutionPanelProps) {
// Local state removed in favor of parent state to prevent reset on re-render
// const [completedCount, setCompletedCount] = React.useState(0);
@@ -207,11 +211,85 @@ export function WizardExecutionPanel({
// Active trial state
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex-1 min-h-0 relative">
<ScrollArea className="h-full w-full">
{/* 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 isUpcoming = idx > currentStepIndex;
return (
<div
key={step.id}
className="flex items-center gap-2 flex-shrink-0"
>
<button
onClick={() => onStepSelect(idx)}
disabled={readOnly}
className={`
group relative flex items-center gap-2 rounded-lg border-2 px-3 py-2 transition-all
${isCurrent
? "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"
}
${readOnly ? "cursor-default" : "cursor-pointer"}
`}
>
{/* Step Number/Icon */}
<div
className={`
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"
}
`}
>
{isCompleted ? (
<CheckCircle className="h-3.5 w-3.5" />
) : (
idx + 1
)}
</div>
{/* Step Name */}
<span
className={`text-xs font-medium max-w-[120px] truncate ${isCurrent
? "text-foreground"
: isCompleted
? "text-muted-foreground"
: "text-muted-foreground/60"
}`}
title={step.name}
>
{step.name}
</span>
</button>
{/* Arrow Connector */}
{idx < steps.length - 1 && (
<ArrowRight
className={`h-4 w-4 flex-shrink-0 ${isCompleted ? "text-primary/40" : "text-muted-foreground/30"
}`}
/>
)}
</div>
);
})}
</div>
</div>
{/* Current Step Details - NO SCROLL */}
<div className="flex-1 min-h-0 overflow-hidden">
<div className="h-full overflow-y-auto">
<div className="pr-4">
{currentStep ? (
<div className="flex flex-col gap-4 p-4 max-w-2xl mx-auto">
<div className="flex flex-col gap-4 p-4 max-w-5xl mx-auto w-full">
{/* Header Info */}
<div className="space-y-1 pb-4 border-b">
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
@@ -226,7 +304,7 @@ export function WizardExecutionPanel({
{currentStep.actions.map((action, idx) => {
const isCompleted = idx < activeActionIndex;
const isActive: boolean = idx === activeActionIndex;
const isLast = idx === currentStep.actions!.length - 1;
const isLast = idx === (currentStep.actions?.length || 0) - 1;
return (
<div
@@ -257,176 +335,25 @@ export function WizardExecutionPanel({
)}
</div>
{/* 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.description && (
<div className="text-sm text-muted-foreground">
{action.description}
</div>
)}
{/* Active Action Controls */}
{isActive === true ? (
<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>
) : null}
{/* Wizard Wait For Response / Branching UI */}
{isActive === true &&
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>
) : null}
{/* Completed State Actions */}
{isCompleted && action.pluginId && (
<div className="pt-1 flex items-center gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-muted-foreground hover:text-primary"
onClick={(e) => {
e.preventDefault();
onExecuteRobotAction(
action.pluginId!,
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
action.parameters || {},
{ autoAdvance: false },
);
}}
disabled={readOnly || isExecuting}
>
<RotateCcw className="mr-1.5 h-3 w-3" />
Retry
</Button>
</div>
)}
</div>
</div>
{/* Action Content */}
<WizardActionItem
action={action as any} // Cast to ActionData
index={idx}
isActive={isActive}
isCompleted={isCompleted}
onExecute={onExecuteAction}
onExecuteRobot={onExecuteRobotAction}
onSkip={onSkipAction}
onCompleted={onActionCompleted}
readOnly={readOnly}
isExecuting={isExecuting}
isRobotConnected={rosConnected}
/>
</div>
);
})}
</div>
)
}
)}
{/* Manual Advance Button */}
{activeActionIndex >= (currentStep.actions?.length || 0) && (
@@ -453,7 +380,7 @@ export function WizardExecutionPanel({
</div>
)}
</div>
</ScrollArea>
</div>
</div>
</div>
);

View File

@@ -12,7 +12,10 @@ import { Separator } from "~/components/ui/separator";
import { ScrollArea } from "~/components/ui/scroll-area";
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 {
rosConnected: boolean;
@@ -33,6 +36,14 @@ interface WizardMonitoringPanelProps {
actionId: string,
parameters: Record<string, unknown>,
) => Promise<unknown>;
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
onExecuteRobotAction?: (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
) => Promise<void>;
studyId?: string;
trialId?: string;
readOnly?: boolean;
}
@@ -44,8 +55,28 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
connectRos,
disconnectRos,
executeRosAction,
onSetAutonomousLife,
onExecuteRobotAction,
studyId,
trialId,
readOnly = false,
}: WizardMonitoringPanelProps) {
const [autonomousLife, setAutonomousLife] = React.useState(true);
const handleAutonomousLifeChange = React.useCallback(async (checked: boolean) => {
setAutonomousLife(checked); // Optimistic update
if (onSetAutonomousLife) {
try {
const result = await onSetAutonomousLife(checked);
if (result === false) {
throw new Error("Service unavailable");
}
} catch (error) {
console.error("Failed to set autonomous life:", error);
setAutonomousLife(!checked); // Revert on failure
}
}
}, [onSetAutonomousLife]);
return (
<div className="flex h-full flex-col gap-2 p-2">
{/* Camera View - Always Visible */}
@@ -166,6 +197,35 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
<Separator />
{/* Autonomous Life Toggle */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
<Switch
id="tour-wizard-autonomous"
checked={!!autonomousLife}
onCheckedChange={handleAutonomousLifeChange}
disabled={!rosConnected || readOnly}
className="scale-75"
/>
</div>
</div>
<Separator />
{/* Robot Actions Panel */}
{studyId && trialId && onExecuteRobotAction ? (
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
<RobotActionsPanel
studyId={studyId}
trialId={trialId}
onExecuteAction={onExecuteRobotAction}
/>
</div>
) : null}
<Separator />
{/* Movement Controls */}
{rosConnected && (
<div className="space-y-2">

View File

@@ -14,7 +14,7 @@ import {
SelectValue,
} from "~/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { HorizontalTimeline } from "~/components/trials/timeline/HorizontalTimeline";
interface TrialEvent {
type: string;
@@ -31,7 +31,7 @@ interface WizardObservationPaneProps {
) => Promise<void>;
isSubmitting?: boolean;
readOnly?: boolean;
activeTab?: "notes" | "timeline";
}
export function WizardObservationPane({
@@ -39,7 +39,6 @@ export function WizardObservationPane({
isSubmitting = false,
trialEvents = [],
readOnly = false,
activeTab = "notes",
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
const [note, setNote] = useState("");
const [category, setCategory] = useState("observation");
@@ -71,7 +70,7 @@ export function WizardObservationPane({
return (
<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-1 flex flex-col p-4 m-0">
<div className="flex flex-1 flex-col gap-2">
<Textarea
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
@@ -142,10 +141,6 @@ export function WizardObservationPane({
)}
</div>
</div>
<div className={`flex-1 m-0 min-h-0 p-4 ${activeTab !== "timeline" ? "hidden" : ""}`}>
<HorizontalTimeline events={trialEvents} />
</div>
</div>
);
}