mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-04 23:46:32 -05:00
feat: Introduce dedicated participant, experiment, and trial detail/edit pages, enable MinIO, and refactor dashboard navigation.
This commit is contained in:
@@ -96,33 +96,33 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(contextStudyId
|
||||
? [
|
||||
{
|
||||
label: "Study",
|
||||
href: `/studies/${contextStudyId}`,
|
||||
},
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
]
|
||||
{
|
||||
label: "Study",
|
||||
href: `/studies/${contextStudyId}`,
|
||||
},
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
]
|
||||
: [
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
]),
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
]),
|
||||
];
|
||||
|
||||
useBreadcrumbsEffect(breadcrumbs);
|
||||
@@ -161,7 +161,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
router.push(`/trials/${newTrial!.id}`);
|
||||
router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`);
|
||||
} else {
|
||||
const updatedTrial = await updateTrialMutation.mutateAsync({
|
||||
id: trialId!,
|
||||
@@ -170,7 +170,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
router.push(`/trials/${updatedTrial!.id}`);
|
||||
router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
@@ -51,27 +51,22 @@ const statusConfig = {
|
||||
scheduled: {
|
||||
label: "Scheduled",
|
||||
className: "bg-blue-100 text-blue-800",
|
||||
icon: "📅",
|
||||
},
|
||||
in_progress: {
|
||||
label: "In Progress",
|
||||
className: "bg-yellow-100 text-yellow-800",
|
||||
icon: "▶️",
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className: "bg-green-100 text-green-800",
|
||||
icon: "✅",
|
||||
},
|
||||
aborted: {
|
||||
label: "Aborted",
|
||||
className: "bg-gray-100 text-gray-800",
|
||||
icon: "❌",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
className: "bg-red-100 text-red-800",
|
||||
icon: "⚠️",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -145,7 +140,7 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
<div className="max-w-[250px]">
|
||||
<div className="truncate font-medium">
|
||||
<Link
|
||||
href={`/experiments/${experimentId}`}
|
||||
href={`/studies/${row.original.studyId}/experiments/${experimentId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{String(experimentName)}
|
||||
@@ -175,7 +170,7 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
<div className="max-w-[150px]">
|
||||
{participantId ? (
|
||||
<Link
|
||||
href={`/participants/${participantId}`}
|
||||
href={`/studies/${row.original.studyId}/participants/${participantId}`}
|
||||
className="font-mono text-sm hover:underline"
|
||||
>
|
||||
{(participantCode ?? "Unknown") as string}
|
||||
@@ -232,10 +227,10 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
|
||||
return (
|
||||
<Badge className={statusInfo.className}>
|
||||
<span className="mr-1">{statusInfo.icon}</span>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -366,79 +361,94 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const trial = row.original;
|
||||
|
||||
if (!trial?.id) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm">No actions</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(trial.id)}
|
||||
>
|
||||
Copy trial ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
View details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{trial.status === "scheduled" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}
|
||||
>
|
||||
Start trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "in_progress" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}
|
||||
>
|
||||
Control trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
View analysis
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
Edit trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
Cancel trial
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <ActionsCell row={row} />,
|
||||
},
|
||||
];
|
||||
|
||||
function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
const trial = row.original;
|
||||
const router = React.useMemo(() => require("next/navigation").useRouter(), []); // Dynamic import to avoid hook rules in static context? No, this component is rendered in Table.
|
||||
// Actually, hooks must be at top level. This ActionsCell will be a regular component.
|
||||
// But useRouter might fail if columns is not in component tree?
|
||||
// Table cells are rendered by flexRender in React, so they are components.
|
||||
// importing useRouter is fine.
|
||||
|
||||
const utils = api.useUtils();
|
||||
const duplicateMutation = api.trials.duplicate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.trials.list.invalidate();
|
||||
// toast.success("Trial duplicated"); // We need toast
|
||||
},
|
||||
});
|
||||
|
||||
if (!trial?.id) {
|
||||
return <span className="text-muted-foreground text-sm">No actions</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(trial.id)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{trial.status === "scheduled" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "in_progress" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Gamepad2 className="mr-2 h-4 w-4" />
|
||||
Control Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
|
||||
<LineChart className="mr-2 h-4 w-4" />
|
||||
Analysis
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => duplicateMutation.mutate({ id: trial.id })}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface TrialsTableProps {
|
||||
studyId?: string;
|
||||
}
|
||||
|
||||
203
src/components/trials/timeline/HorizontalTimeline.tsx
Normal file
203
src/components/trials/timeline/HorizontalTimeline.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
|
||||
|
||||
interface TimelineEvent {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface HorizontalTimelineProps {
|
||||
events: TimelineEvent[];
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
|
||||
export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTimelineProps) {
|
||||
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
No events recorded yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate time range
|
||||
const timestamps = events.map(e => e.timestamp.getTime());
|
||||
const minTime = startTime?.getTime() ?? Math.min(...timestamps);
|
||||
const maxTime = endTime?.getTime() ?? Math.max(...timestamps);
|
||||
const duration = maxTime - minTime;
|
||||
|
||||
// Generate time markers (every 10 seconds or appropriate interval)
|
||||
const getTimeMarkers = () => {
|
||||
const markers: Date[] = [];
|
||||
const interval = duration > 300000 ? 60000 : duration > 60000 ? 30000 : 10000; // 1min, 30s, or 10s intervals
|
||||
|
||||
for (let time = minTime; time <= maxTime; time += interval) {
|
||||
markers.push(new Date(time));
|
||||
}
|
||||
if (markers[markers.length - 1]?.getTime() !== maxTime) {
|
||||
markers.push(new Date(maxTime));
|
||||
}
|
||||
return markers;
|
||||
};
|
||||
|
||||
const timeMarkers = getTimeMarkers();
|
||||
|
||||
// Get position percentage for a timestamp
|
||||
const getPosition = (timestamp: Date) => {
|
||||
if (duration === 0) return 50;
|
||||
return ((timestamp.getTime() - minTime) / duration) * 100;
|
||||
};
|
||||
|
||||
// Get color and icon for event type
|
||||
const getEventStyle = (eventType: string) => {
|
||||
if (eventType.includes("start") || eventType === "trial_started") {
|
||||
return { color: "bg-blue-500", Icon: Flag };
|
||||
} else if (eventType.includes("complete") || eventType === "trial_completed") {
|
||||
return { color: "bg-green-500", Icon: CheckCircle };
|
||||
} else if (eventType.includes("robot") || eventType.includes("action")) {
|
||||
return { color: "bg-purple-500", Icon: Bot };
|
||||
} else if (eventType.includes("wizard") || eventType.includes("intervention")) {
|
||||
return { color: "bg-orange-500", Icon: User };
|
||||
} else if (eventType.includes("note") || eventType.includes("annotation")) {
|
||||
return { color: "bg-yellow-500", Icon: MessageSquare };
|
||||
} else if (eventType.includes("error") || eventType.includes("issue")) {
|
||||
return { color: "bg-red-500", Icon: AlertTriangle };
|
||||
}
|
||||
return { color: "bg-gray-500", Icon: Activity };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Timeline visualization */}
|
||||
<div className="relative">
|
||||
<ScrollArea className="w-full">
|
||||
<div className="min-w-[800px] px-4 py-8">
|
||||
{/* Time markers */}
|
||||
<div className="relative h-20 mb-8">
|
||||
{/* Main horizontal line */}
|
||||
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-border" style={{ transform: 'translateY(-50%)' }} />
|
||||
|
||||
{/* Time labels */}
|
||||
{timeMarkers.map((marker, i) => {
|
||||
const pos = getPosition(marker);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute"
|
||||
style={{ left: `${pos}%`, top: '50%', transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-[10px] font-mono text-muted-foreground mb-2">
|
||||
{marker.toLocaleTimeString([], {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Event markers */}
|
||||
<div className="relative h-40">
|
||||
{/* Timeline line for events */}
|
||||
<div className="absolute top-20 left-0 right-0 h-0.5 bg-border" />
|
||||
|
||||
{events.map((event, i) => {
|
||||
const pos = getPosition(event.timestamp);
|
||||
const { color, Icon } = getEventStyle(event.type);
|
||||
const isSelected = selectedEvent === event;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${pos}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}}
|
||||
>
|
||||
{/* Clickable marker group */}
|
||||
<button
|
||||
onClick={() => setSelectedEvent(isSelected ? null : event)}
|
||||
className="flex flex-col items-center gap-1 cursor-pointer group"
|
||||
title={event.message || event.type}
|
||||
>
|
||||
{/* Vertical dash */}
|
||||
<div className={`
|
||||
w-1 h-20 ${color} rounded-full
|
||||
group-hover:w-1.5 transition-all
|
||||
${isSelected ? 'w-1.5 ring-2 ring-offset-2 ring-offset-background ring-primary' : ''}
|
||||
`} />
|
||||
|
||||
{/* Icon indicator */}
|
||||
<div className={`
|
||||
p-1.5 rounded-full ${color} bg-opacity-20
|
||||
group-hover:bg-opacity-30 transition-all
|
||||
${isSelected ? 'ring-2 ring-primary bg-opacity-40' : ''}
|
||||
`}>
|
||||
<Icon className={`h-3.5 w-3.5 ${color.replace('bg-', 'text-')}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Selected event details */}
|
||||
{selectedEvent && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedEvent.type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{selectedEvent.timestamp.toLocaleTimeString([], {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{selectedEvent.message && (
|
||||
<p className="text-sm">{selectedEvent.message}</p>
|
||||
)}
|
||||
{selectedEvent.data !== undefined && selectedEvent.data !== null && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
Event data
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-muted rounded text-[10px] overflow-auto">
|
||||
{JSON.stringify(selectedEvent.data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/trials/views/TrialAnalysisView.tsx
Normal file
124
src/components/trials/views/TrialAnalysisView.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { LineChart, BarChart, Clock, Database, FileText } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface TrialAnalysisViewProps {
|
||||
trial: {
|
||||
id: string;
|
||||
status: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
experiment: { name: string };
|
||||
participant: { participantCode: string };
|
||||
eventCount?: number;
|
||||
mediaCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Status</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold capitalize">{trial.status.replace("_", " ")}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{trial.completedAt
|
||||
? `Completed ${formatDistanceToNow(new Date(trial.completedAt), { addSuffix: true })}`
|
||||
: "Not completed"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Duration</CardTitle>
|
||||
<BarChart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{trial.duration ? `${Math.floor(trial.duration / 60)}m ${trial.duration % 60}s` : "N/A"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total execution time
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Events Logged</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{trial.eventCount ?? 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
System & user events
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Media Files</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{trial.mediaCount ?? 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recordings & snapshots
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="events">Event Log</TabsTrigger>
|
||||
<TabsTrigger value="charts">Charts</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Analysis Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Summary of trial execution for {trial.participant.participantCode} in experiment {trial.experiment.name}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<LineChart className="h-10 w-10 mx-auto mb-2 opacity-20" />
|
||||
<p>Detailed analysis visualizations coming soon.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event Log</CardTitle>
|
||||
<CardDescription>
|
||||
Chronological record of all trial events.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Event log view placeholder.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
@@ -9,6 +10,12 @@ import { PanelsContainer } from "~/components/experiments/designer/layout/Panels
|
||||
import { WizardControlPanel } from "./panels/WizardControlPanel";
|
||||
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
|
||||
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
|
||||
import { WizardObservationPane } from "./panels/WizardObservationPane";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/ui/resizable";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useWizardRos } from "~/hooks/useWizardRos";
|
||||
import { toast } from "sonner";
|
||||
@@ -42,6 +49,16 @@ interface WizardInterfaceProps {
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
interface ActionData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
pluginId: string | null;
|
||||
}
|
||||
|
||||
interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -53,6 +70,7 @@ interface StepData {
|
||||
| "conditional_branch";
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
actions: ActionData[];
|
||||
}
|
||||
|
||||
export const WizardInterface = React.memo(function WizardInterface({
|
||||
@@ -65,6 +83,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
initialTrial.startedAt ? new Date(initialTrial.startedAt) : null,
|
||||
);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
// Persistent tab states to prevent resets from parent re-renders
|
||||
const [controlPanelTab, setControlPanelTab] = useState<
|
||||
@@ -73,9 +92,16 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||
"current" | "timeline" | "events"
|
||||
>(trial.status === "in_progress" ? "current" : "timeline");
|
||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||
"status" | "robot" | "events"
|
||||
>("status");
|
||||
const [completedActionsCount, setCompletedActionsCount] = useState(0);
|
||||
|
||||
// Reset completed actions when step changes
|
||||
useEffect(() => {
|
||||
setCompletedActionsCount(0);
|
||||
}, [currentStepIndex]);
|
||||
|
||||
// Get experiment steps from API
|
||||
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
|
||||
@@ -100,6 +126,13 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
},
|
||||
});
|
||||
|
||||
// Log robot action mutation (for client-side execution)
|
||||
const logRobotActionMutation = api.trials.logRobotAction.useMutation({
|
||||
onError: (error) => {
|
||||
console.error("Failed to log robot action:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Map database step types to component step types
|
||||
const mapStepType = (dbType: string) => {
|
||||
switch (dbType) {
|
||||
@@ -136,6 +169,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
connect: connectRos,
|
||||
disconnect: disconnectRos,
|
||||
executeRobotAction: executeRosAction,
|
||||
setAutonomousLife,
|
||||
} = useWizardRos({
|
||||
autoConnect: true,
|
||||
onActionCompleted,
|
||||
@@ -152,6 +186,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
},
|
||||
);
|
||||
|
||||
// Poll for trial events
|
||||
const { data: fetchedEvents } = api.trials.getEvents.useQuery(
|
||||
{ trialId: trial.id, limit: 100 },
|
||||
{
|
||||
refetchInterval: 3000,
|
||||
staleTime: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
// Update local trial state from polling
|
||||
useEffect(() => {
|
||||
if (pollingData) {
|
||||
@@ -168,7 +211,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}
|
||||
}, [pollingData]);
|
||||
|
||||
// Auto-start trial on mount if scheduled
|
||||
useEffect(() => {
|
||||
if (trial.status === "scheduled") {
|
||||
handleStartTrial();
|
||||
}
|
||||
}, []); // Run once on mount
|
||||
|
||||
// Trial events from robot actions
|
||||
|
||||
const trialEvents = useMemo<
|
||||
Array<{
|
||||
type: string;
|
||||
@@ -176,7 +227,38 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}>
|
||||
>(() => [], []);
|
||||
>(() => {
|
||||
return (fetchedEvents ?? []).map(event => {
|
||||
let message: string | undefined;
|
||||
const eventData = event.data as any;
|
||||
|
||||
// Extract or generate message based on event type
|
||||
if (event.eventType.startsWith('annotation_')) {
|
||||
message = eventData?.description || eventData?.label || 'Annotation added';
|
||||
} else if (event.eventType.startsWith('robot_action_')) {
|
||||
const actionName = event.eventType.replace('robot_action_', '').replace(/_/g, ' ');
|
||||
message = `Robot action: ${actionName}`;
|
||||
} else if (event.eventType === 'trial_started') {
|
||||
message = 'Trial started';
|
||||
} else if (event.eventType === 'trial_completed') {
|
||||
message = 'Trial completed';
|
||||
} else if (event.eventType === 'step_changed') {
|
||||
message = `Step changed to: ${eventData?.stepName || 'next step'}`;
|
||||
} else if (event.eventType.startsWith('wizard_')) {
|
||||
message = eventData?.notes || eventData?.message || event.eventType.replace('wizard_', '').replace(/_/g, ' ');
|
||||
} else {
|
||||
// Generic fallback
|
||||
message = eventData?.notes || eventData?.message || eventData?.description || event.eventType.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
return {
|
||||
type: event.eventType,
|
||||
timestamp: new Date(event.timestamp),
|
||||
data: event.data,
|
||||
message,
|
||||
};
|
||||
}).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
|
||||
}, [fetchedEvents]);
|
||||
|
||||
// Transform experiment steps to component format
|
||||
const steps: StepData[] =
|
||||
@@ -187,6 +269,15 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
type: mapStepType(step.type),
|
||||
parameters: step.parameters ?? {},
|
||||
order: step.order ?? index,
|
||||
actions: step.actions?.map((action) => ({
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
type: action.type,
|
||||
parameters: action.parameters ?? {},
|
||||
order: action.order,
|
||||
pluginId: action.pluginId,
|
||||
})) ?? [],
|
||||
})) ?? [];
|
||||
|
||||
const currentStep = steps[currentStepIndex] ?? null;
|
||||
@@ -261,6 +352,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
status: data.status,
|
||||
completedAt: data.completedAt,
|
||||
});
|
||||
toast.success("Trial completed! Redirecting to analysis...");
|
||||
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -314,6 +407,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
// Note: Step transitions can be enhanced later with database logging
|
||||
}
|
||||
@@ -335,15 +429,72 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}
|
||||
};
|
||||
|
||||
// Mutations for annotations
|
||||
const addAnnotationMutation = api.trials.addAnnotation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Note added");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to add note", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddAnnotation = async (
|
||||
description: string,
|
||||
category?: string,
|
||||
tags?: string[],
|
||||
) => {
|
||||
await addAnnotationMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
description,
|
||||
category,
|
||||
tags,
|
||||
});
|
||||
};
|
||||
|
||||
// Mutation for events (Acknowledge)
|
||||
const logEventMutation = api.trials.logEvent.useMutation({
|
||||
onSuccess: () => toast.success("Event logged"),
|
||||
});
|
||||
|
||||
// Mutation for interventions
|
||||
const addInterventionMutation = api.trials.addIntervention.useMutation({
|
||||
onSuccess: () => toast.success("Intervention logged"),
|
||||
});
|
||||
|
||||
const handleExecuteAction = async (
|
||||
actionId: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
console.log("Executing action:", actionId, parameters);
|
||||
|
||||
if (actionId === "acknowledge") {
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "wizard_acknowledge",
|
||||
data: parameters,
|
||||
});
|
||||
handleNextStep();
|
||||
} else if (actionId === "intervene") {
|
||||
await addInterventionMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "manual_intervention",
|
||||
description: "Wizard manual intervention triggered",
|
||||
data: parameters,
|
||||
});
|
||||
} else if (actionId === "note") {
|
||||
await addAnnotationMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
description: String(parameters?.content || "Quick note"),
|
||||
category: String(parameters?.category || "quick_note")
|
||||
});
|
||||
}
|
||||
|
||||
// Note: Action execution can be enhanced later with tRPC mutations
|
||||
} catch (error) {
|
||||
console.error("Failed to execute action:", error);
|
||||
toast.error("Failed to execute action");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -352,22 +503,34 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
options?: { autoAdvance?: boolean },
|
||||
) => {
|
||||
try {
|
||||
setIsExecutingAction(true);
|
||||
// Try direct WebSocket execution first for better performance
|
||||
if (rosConnected) {
|
||||
try {
|
||||
await executeRosAction(pluginName, actionId, parameters);
|
||||
const result = await executeRosAction(pluginName, actionId, parameters);
|
||||
|
||||
const duration =
|
||||
result.endTime && result.startTime
|
||||
? result.endTime.getTime() - result.startTime.getTime()
|
||||
: 0;
|
||||
|
||||
// Log to trial events for data capture
|
||||
await executeRobotActionMutation.mutateAsync({
|
||||
await logRobotActionMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
pluginName,
|
||||
actionId,
|
||||
parameters,
|
||||
duration,
|
||||
result: { status: result.status },
|
||||
});
|
||||
|
||||
toast.success(`Robot action executed: ${actionId}`);
|
||||
if (options?.autoAdvance) {
|
||||
handleNextStep();
|
||||
}
|
||||
} catch (rosError) {
|
||||
console.warn(
|
||||
"WebSocket execution failed, falling back to tRPC:",
|
||||
@@ -383,6 +546,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
});
|
||||
|
||||
toast.success(`Robot action executed via fallback: ${actionId}`);
|
||||
if (options?.autoAdvance) {
|
||||
handleNextStep();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use tRPC execution if WebSocket not connected
|
||||
@@ -394,17 +560,51 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
});
|
||||
|
||||
toast.success(`Robot action executed: ${actionId}`);
|
||||
if (options?.autoAdvance) {
|
||||
handleNextStep();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to execute robot action:", error);
|
||||
toast.error(`Failed to execute robot action: ${actionId}`, {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsExecutingAction(false);
|
||||
}
|
||||
},
|
||||
[rosConnected, executeRosAction, executeRobotActionMutation, trial.id],
|
||||
);
|
||||
|
||||
const handleSkipAction = useCallback(
|
||||
async (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
options?: { autoAdvance?: boolean },
|
||||
) => {
|
||||
try {
|
||||
await logRobotActionMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
pluginName,
|
||||
actionId,
|
||||
parameters,
|
||||
duration: 0,
|
||||
result: { skipped: true },
|
||||
});
|
||||
|
||||
toast.info(`Action skipped: ${actionId}`);
|
||||
if (options?.autoAdvance) {
|
||||
handleNextStep();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to skip action:", error);
|
||||
toast.error("Failed to skip action");
|
||||
}
|
||||
},
|
||||
[logRobotActionMutation, trial.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Compact Status Bar */}
|
||||
@@ -451,58 +651,78 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No connection status alert - ROS connection shown in monitoring panel */}
|
||||
|
||||
{/* Main Content - Three Panel Layout */}
|
||||
{/* Main Content with Vertical Resizable Split */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<PanelsContainer
|
||||
left={
|
||||
<WizardControlPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
onStartTrial={handleStartTrial}
|
||||
onPauseTrial={handlePauseTrial}
|
||||
onNextStep={handleNextStep}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
onAbortTrial={handleAbortTrial}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
_isConnected={rosConnected}
|
||||
activeTab={controlPanelTab}
|
||||
onTabChange={setControlPanelTab}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<ResizablePanel defaultSize={75} minSize={30}>
|
||||
<PanelsContainer
|
||||
left={
|
||||
<WizardControlPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
onStartTrial={handleStartTrial}
|
||||
onPauseTrial={handlePauseTrial}
|
||||
onNextStep={handleNextStep}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
onAbortTrial={handleAbortTrial}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
_isConnected={rosConnected}
|
||||
activeTab={controlPanelTab}
|
||||
onTabChange={setControlPanelTab}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
/>
|
||||
}
|
||||
center={
|
||||
<WizardExecutionPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
trialEvents={trialEvents}
|
||||
onStepSelect={(index: number) => setCurrentStepIndex(index)}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
activeTab={executionPanelTab}
|
||||
onTabChange={setExecutionPanelTab}
|
||||
onSkipAction={handleSkipAction}
|
||||
isExecuting={isExecutingAction}
|
||||
onNextStep={handleNextStep}
|
||||
completedActionsCount={completedActionsCount}
|
||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
rosConnecting={rosConnecting}
|
||||
rosError={rosError ?? undefined}
|
||||
robotStatus={robotStatus}
|
||||
connectRos={connectRos}
|
||||
disconnectRos={disconnectRos}
|
||||
executeRosAction={executeRosAction}
|
||||
/>
|
||||
}
|
||||
showDividers={true}
|
||||
className="h-full"
|
||||
/>
|
||||
}
|
||||
center={
|
||||
<WizardExecutionPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel defaultSize={25} minSize={10}>
|
||||
<WizardObservationPane
|
||||
onAddAnnotation={handleAddAnnotation}
|
||||
isSubmitting={addAnnotationMutation.isPending}
|
||||
trialEvents={trialEvents}
|
||||
onStepSelect={(index: number) => setCurrentStepIndex(index)}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
activeTab={executionPanelTab}
|
||||
onTabChange={setExecutionPanelTab}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
rosConnecting={rosConnecting}
|
||||
rosError={rosError ?? undefined}
|
||||
robotStatus={robotStatus}
|
||||
connectRos={connectRos}
|
||||
disconnectRos={disconnectRos}
|
||||
executeRosAction={executeRosAction}
|
||||
/>
|
||||
}
|
||||
showDividers={true}
|
||||
className="h-full"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@ import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
@@ -35,6 +37,15 @@ interface StepData {
|
||||
| "conditional_branch";
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
pluginId: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface TrialData {
|
||||
@@ -86,6 +97,7 @@ interface WizardControlPanelProps {
|
||||
activeTab: "control" | "step" | "actions" | "robot";
|
||||
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
||||
isStarting?: boolean;
|
||||
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||
}
|
||||
|
||||
export function WizardControlPanel({
|
||||
@@ -105,65 +117,28 @@ export function WizardControlPanel({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
isStarting = false,
|
||||
onSetAutonomousLife,
|
||||
}: WizardControlPanelProps) {
|
||||
const progress =
|
||||
steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
|
||||
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return { variant: "outline" as const, icon: Clock };
|
||||
case "in_progress":
|
||||
return { variant: "default" as const, icon: Play };
|
||||
case "completed":
|
||||
return { variant: "secondary" as const, icon: CheckCircle };
|
||||
case "aborted":
|
||||
case "failed":
|
||||
return { variant: "destructive" as const, icon: X };
|
||||
default:
|
||||
return { variant: "outline" as const, icon: Clock };
|
||||
const handleAutonomousLifeChange = async (checked: boolean) => {
|
||||
setAutonomousLife(checked); // Optimistic update
|
||||
if (onSetAutonomousLife) {
|
||||
try {
|
||||
const result = await onSetAutonomousLife(checked);
|
||||
if (result === false) {
|
||||
throw new Error("Service unavailable");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set autonomous life:", error);
|
||||
setAutonomousLife(!checked); // Revert on failure
|
||||
// Optional: Toast error?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(trial.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Trial Info Header */}
|
||||
<div className="border-b p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={statusConfig.variant}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Session #{trial.sessionNumber}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium">
|
||||
{trial.participant.participantCode}
|
||||
</div>
|
||||
|
||||
{trial.status === "in_progress" && steps.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span>
|
||||
{currentStepIndex + 1} of {steps.length}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabbed Content */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -275,17 +250,36 @@ export function WizardControlPanel({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Connection Status */}
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">Connection</div>
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium">Robot Status</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Status
|
||||
Connection
|
||||
</span>
|
||||
<Badge variant="default" className="text-xs">
|
||||
Polling
|
||||
</Badge>
|
||||
{_isConnected ? (
|
||||
<Badge variant="default" className="bg-green-600 text-xs">
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-yellow-600 border-yellow-600 text-xs">
|
||||
Polling...
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="autonomous-life"
|
||||
checked={autonomousLife}
|
||||
onCheckedChange={handleAutonomousLifeChange}
|
||||
disabled={!_isConnected}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
Zap,
|
||||
Eye,
|
||||
List,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -24,12 +28,21 @@ interface StepData {
|
||||
name: string;
|
||||
description: string | null;
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
pluginId: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface TrialData {
|
||||
@@ -75,8 +88,25 @@ interface WizardExecutionPanelProps {
|
||||
actionId: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => void;
|
||||
activeTab: "current" | "timeline" | "events";
|
||||
onTabChange: (tab: "current" | "timeline" | "events") => void;
|
||||
onExecuteRobotAction: (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
options?: { autoAdvance?: boolean },
|
||||
) => Promise<void>;
|
||||
activeTab: "current" | "timeline" | "events"; // Deprecated/Ignored
|
||||
onTabChange: (tab: "current" | "timeline" | "events") => void; // Deprecated/Ignored
|
||||
onSkipAction: (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
options?: { autoAdvance?: boolean },
|
||||
) => Promise<void>;
|
||||
isExecuting?: boolean;
|
||||
onNextStep?: () => void;
|
||||
onCompleteTrial?: () => void;
|
||||
completedActionsCount: number;
|
||||
onActionCompleted: () => void;
|
||||
}
|
||||
|
||||
export function WizardExecutionPanel({
|
||||
@@ -87,9 +117,21 @@ export function WizardExecutionPanel({
|
||||
trialEvents,
|
||||
onStepSelect,
|
||||
onExecuteAction,
|
||||
onExecuteRobotAction,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onSkipAction,
|
||||
isExecuting = false,
|
||||
onNextStep,
|
||||
onCompleteTrial,
|
||||
completedActionsCount,
|
||||
onActionCompleted,
|
||||
}: WizardExecutionPanelProps) {
|
||||
// Local state removed in favor of parent state to prevent reset on re-render
|
||||
// const [completedCount, setCompletedCount] = React.useState(0);
|
||||
|
||||
const activeActionIndex = completedActionsCount;
|
||||
|
||||
const getStepIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "wizard_action":
|
||||
@@ -169,7 +211,7 @@ export function WizardExecutionPanel({
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{trial.completedAt &&
|
||||
`Ended at ${new Date(trial.completedAt).toLocaleTimeString()}`}
|
||||
`Ended at ${new Date(trial.completedAt).toLocaleTimeString()} `}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -209,281 +251,228 @@ export function WizardExecutionPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabbed Content */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => {
|
||||
if (
|
||||
value === "current" ||
|
||||
value === "timeline" ||
|
||||
value === "events"
|
||||
) {
|
||||
onTabChange(value);
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-2 py-1">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="current" className="text-xs">
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
Current
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="timeline" className="text-xs">
|
||||
<List className="mr-1 h-3 w-3" />
|
||||
Timeline
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" className="text-xs">
|
||||
<Activity className="mr-1 h-3 w-3" />
|
||||
Events
|
||||
{trialEvents.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
{trialEvents.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{/* Current Step Tab */}
|
||||
<TabsContent value="current" className="m-0 h-full">
|
||||
<div className="h-full">
|
||||
{currentStep ? (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
{/* Current Step Display */}
|
||||
<div className="flex-1 space-y-4 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full">
|
||||
{React.createElement(getStepIcon(currentStep.type), {
|
||||
className: "h-5 w-5 text-primary",
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-medium">
|
||||
{currentStep.name}
|
||||
</h4>
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{currentStep.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simplified Content - Sequential Focus */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
{currentStep ? (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
{/* Header Info (Simplified) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
|
||||
{currentStep.description && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{currentStep.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step-specific content */}
|
||||
{currentStep.type === "wizard_action" && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">
|
||||
Available Actions
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onExecuteAction("acknowledge")}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Acknowledge Step
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Manual Intervention
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() =>
|
||||
onExecuteAction("note", {
|
||||
content: "Step observation",
|
||||
})
|
||||
}
|
||||
>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Add Observation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.type === "robot_action" && (
|
||||
<Alert>
|
||||
<Bot className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<div className="font-medium">
|
||||
Robot Action in Progress
|
||||
</div>
|
||||
<div className="mt-1 text-xs">
|
||||
The robot is executing this step. Monitor status in
|
||||
the monitoring panel.
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{currentStep.type === "parallel_steps" && (
|
||||
<Alert>
|
||||
<Activity className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<div className="font-medium">Parallel Execution</div>
|
||||
<div className="mt-1 text-xs">
|
||||
Multiple actions are running simultaneously.
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="text-muted-foreground text-sm mt-1">{currentStep.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No current step available
|
||||
</div>
|
||||
|
||||
{/* Action Sequence */}
|
||||
{currentStep.actions && currentStep.actions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Execution Sequence
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{currentStep.actions.map((action, idx) => {
|
||||
const isCompleted = idx < activeActionIndex;
|
||||
const isActive = idx === activeActionIndex;
|
||||
const isPending = idx > activeActionIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={`group relative flex items-center gap-4 rounded-xl border p-5 transition-all ${isActive ? "bg-card border-primary ring-1 ring-primary shadow-md" :
|
||||
isCompleted ? "bg-muted/30 border-transparent opacity-70" :
|
||||
"bg-card border-border opacity-50"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border text-sm font-medium ${isCompleted ? "bg-transparent text-green-600 border-green-600" :
|
||||
isActive ? "bg-transparent text-primary border-primary font-bold shadow-sm" :
|
||||
"bg-transparent text-muted-foreground border-transparent"
|
||||
}`}>
|
||||
{isCompleted ? <CheckCircle className="h-5 w-5" /> : idx + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font - medium truncate ${isCompleted ? "line-through text-muted-foreground" : ""} `}>{action.name}</div>
|
||||
{action.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{action.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action.pluginId && isActive && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-9 px-3 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("Skip clicked");
|
||||
// Fire and forget
|
||||
onSkipAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
? action.type.split(".").pop()!
|
||||
: action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
size="default"
|
||||
className="h-10 px-4 shadow-sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("Execute clicked");
|
||||
onExecuteRobotAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
? action.type.split(".").pop()!
|
||||
: action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false },
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Execute
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback for actions with no plugin ID (e.g. manual steps) */}
|
||||
{!action.pluginId && isActive && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onActionCompleted();
|
||||
}}
|
||||
>
|
||||
Mark Done
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed State Indicator */}
|
||||
{isCompleted && (
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="text-xs font-medium text-green-600">
|
||||
Done
|
||||
</div>
|
||||
{action.pluginId && (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
title="Retry Action"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Execute again without advancing count
|
||||
onExecuteRobotAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-100"
|
||||
title="Mark Issue"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onExecuteAction("note", {
|
||||
content: `Reported issue with action: ${action.name}`,
|
||||
category: "system_issue"
|
||||
});
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Manual Advance Button */}
|
||||
{activeActionIndex >= (currentStep.actions?.length || 0) && (
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
|
||||
className={`w-full text-white shadow-md transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-green-600 hover:bg-green-700"
|
||||
}`}
|
||||
>
|
||||
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Wizard Controls (If applicable) */}
|
||||
{currentStep.type === "wizard_action" && (
|
||||
<div className="rounded-xl border border-dashed p-6 space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 justify-start"
|
||||
onClick={() => onExecuteAction("acknowledge")}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Acknowledge
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 justify-start"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Intervene
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Timeline Tab */}
|
||||
<TabsContent value="timeline" className="m-0 h-full">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 p-3">
|
||||
{steps.map((step, index) => {
|
||||
const status = getStepStatus(index);
|
||||
const StepIcon = getStepIcon(step.type);
|
||||
const isActive = index === currentStepIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-lg p-2 transition-colors ${
|
||||
isActive ? "bg-primary/5 border-primary/20 border" : ""
|
||||
}`}
|
||||
onClick={() => onStepSelect(index)}
|
||||
>
|
||||
{/* Step Number and Status */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
|
||||
status === "completed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: status === "active"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{status === "completed" ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`mt-1 h-4 w-0.5 ${
|
||||
status === "completed"
|
||||
? "bg-green-200"
|
||||
: "bg-border"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<StepIcon className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<div className="truncate text-sm font-medium">
|
||||
{step.name}
|
||||
</div>
|
||||
<Badge
|
||||
variant={getStepVariant(status)}
|
||||
className="ml-auto flex-shrink-0 text-xs"
|
||||
>
|
||||
{step.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{step.description && (
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isActive && trial.status === "in_progress" && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<div className="bg-primary h-1.5 w-1.5 animate-pulse rounded-full" />
|
||||
<span className="text-primary text-xs">
|
||||
Executing
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Events Tab */}
|
||||
<TabsContent value="events" className="m-0 h-full">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
{trialEvents.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="text-muted-foreground text-center text-sm">
|
||||
No events recorded yet
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trialEvents
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((event, index) => (
|
||||
<div
|
||||
key={`${event.timestamp.getTime()}-${index}`}
|
||||
className="border-border/50 flex items-start gap-2 rounded-lg border p-2"
|
||||
>
|
||||
<div className="bg-muted flex h-6 w-6 flex-shrink-0 items-center justify-center rounded">
|
||||
<Activity className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium capitalize">
|
||||
{event.type.replace(/_/g, " ")}
|
||||
</div>
|
||||
{event.message && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{event.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{event.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
No active step
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
158
src/components/trials/wizard/panels/WizardObservationPane.tsx
Normal file
158
src/components/trials/wizard/panels/WizardObservationPane.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Send, Hash, Tag, Clock, Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { HorizontalTimeline } from "~/components/trials/timeline/HorizontalTimeline";
|
||||
|
||||
interface TrialEvent {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface WizardObservationPaneProps {
|
||||
onAddAnnotation: (
|
||||
description: string,
|
||||
category?: string,
|
||||
tags?: string[],
|
||||
) => Promise<void>;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export function WizardObservationPane({
|
||||
onAddAnnotation,
|
||||
isSubmitting = false,
|
||||
trialEvents = [],
|
||||
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [category, setCategory] = useState("observation");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [currentTag, setCurrentTag] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!note.trim()) return;
|
||||
|
||||
await onAddAnnotation(note, category, tags);
|
||||
setNote("");
|
||||
setTags([]);
|
||||
setCurrentTag("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const trimmed = currentTag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
setTags([...tags, trimmed]);
|
||||
setCurrentTag("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-t bg-background">
|
||||
<Tabs defaultValue="notes" className="flex h-full flex-col">
|
||||
<div className="border-b px-4 bg-muted/30">
|
||||
<TabsList className="h-9 -mb-px bg-transparent p-0">
|
||||
<TabsTrigger value="notes" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
|
||||
Notes & Observations
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="timeline" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
|
||||
Timeline
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="notes" className="flex-1 flex flex-col p-4 m-0 data-[state=inactive]:hidden">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Textarea
|
||||
placeholder="Type your observation here..."
|
||||
className="flex-1 resize-none font-mono text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="observation">Observation</SelectItem>
|
||||
<SelectItem value="participant_behavior">Behavior</SelectItem>
|
||||
<SelectItem value="system_issue">System Issue</SelectItem>
|
||||
<SelectItem value="success">Success</SelectItem>
|
||||
<SelectItem value="failure">Failure</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8">
|
||||
<Tag className="h-3 w-3 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add tags..."
|
||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
value={currentTag}
|
||||
onChange={(e) => setCurrentTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
onBlur={addTag}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !note.trim()}
|
||||
className="h-8"
|
||||
>
|
||||
<Send className="mr-2 h-3 w-3" />
|
||||
Add Note
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="px-1 py-0 text-[10px] cursor-pointer hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setTags(tags.filter((t) => t !== tag))}
|
||||
>
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="timeline" className="flex-1 m-0 min-h-0 p-4 data-[state=inactive]:hidden">
|
||||
<HorizontalTimeline events={trialEvents} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user