mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat: Implement digital signatures for participant consent and introduce study forms management.
This commit is contained in:
@@ -85,7 +85,9 @@ function DateTimePicker({
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="date-picker" className="text-xs">Date</Label>
|
||||
<Label htmlFor="date-picker" className="text-xs">
|
||||
Date
|
||||
</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -93,7 +95,7 @@ function DateTimePicker({
|
||||
id="date-picker"
|
||||
className={cn(
|
||||
"w-[240px] justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
@@ -112,7 +114,9 @@ function DateTimePicker({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="time-picker" className="text-xs">Time</Label>
|
||||
<Label htmlFor="time-picker" className="text-xs">
|
||||
Time
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="time-picker"
|
||||
@@ -122,7 +126,7 @@ function DateTimePicker({
|
||||
disabled={!value}
|
||||
className="w-[120px]"
|
||||
/>
|
||||
<Clock className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,8 +201,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
{ participantId: selectedParticipantId },
|
||||
{
|
||||
enabled: !!selectedParticipantId && mode === "create",
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -213,33 +217,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);
|
||||
@@ -250,7 +254,9 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
form.reset({
|
||||
experimentId: trial.experimentId,
|
||||
participantId: trial?.participantId ?? "",
|
||||
scheduledAt: trial.scheduledAt ? new Date(trial.scheduledAt) : undefined,
|
||||
scheduledAt: trial.scheduledAt
|
||||
? new Date(trial.scheduledAt)
|
||||
: undefined,
|
||||
wizardId: trial.wizardId ?? undefined,
|
||||
notes: trial.notes ?? "",
|
||||
sessionNumber: trial.sessionNumber ?? 1,
|
||||
@@ -334,9 +340,9 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
||||
layout="full-width"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Left Column: Main Info (Spans 2) */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="experimentId">Experiment *</Label>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban, Printer } from "lucide-react";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
Gamepad2,
|
||||
LineChart,
|
||||
Ban,
|
||||
Printer,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
@@ -125,10 +134,7 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
|
||||
return (
|
||||
<div className="font-mono text-sm">
|
||||
<Link
|
||||
href={href}
|
||||
className="hover:underline"
|
||||
>
|
||||
<Link href={href} className="hover:underline">
|
||||
#{Number(sessionNumber)}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -240,12 +246,7 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className={statusInfo.className}>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return <Badge className={statusInfo.className}>{statusInfo.label}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -363,7 +364,7 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
@@ -383,14 +384,18 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
{trial.status === "completed" && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
|
||||
<Link
|
||||
href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}
|
||||
>
|
||||
<LineChart className="mr-1.5 h-3.5 w-3.5" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
{/* We link to the analysis page with a query param to trigger print/export */}
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis?export=true`}>
|
||||
<Link
|
||||
href={`/studies/${trial.studyId}/trials/${trial.id}/analysis?export=true`}
|
||||
>
|
||||
<Printer className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export
|
||||
</Link>
|
||||
@@ -398,7 +403,11 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
</>
|
||||
)}
|
||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||
<Button size="sm" variant="ghost" className="h-8 w-8 p-0 text-muted-foreground hover:text-red-600">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground h-8 w-8 p-0 hover:text-red-600"
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
<span className="sr-only">Cancel</span>
|
||||
</Button>
|
||||
|
||||
@@ -3,149 +3,199 @@
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { CheckCircle, AlertTriangle, Info, Bot, User, Flag, MessageSquare, Activity } from "lucide-react";
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Bot,
|
||||
User,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
|
||||
// Define the shape of our data (matching schema)
|
||||
export interface TrialEvent {
|
||||
id: string;
|
||||
trialId: string;
|
||||
eventType: string;
|
||||
timestamp: Date | string;
|
||||
data: any;
|
||||
createdBy: string | null;
|
||||
id: string;
|
||||
trialId: string;
|
||||
eventType: string;
|
||||
timestamp: Date | string;
|
||||
data: any;
|
||||
createdBy: string | null;
|
||||
}
|
||||
|
||||
// Helper to format timestamp relative to start
|
||||
function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
|
||||
if (!startTime) return "--:--";
|
||||
const date = new Date(timestamp);
|
||||
const diff = date.getTime() - startTime.getTime();
|
||||
if (diff < 0) return "0:00";
|
||||
if (!startTime) return "--:--";
|
||||
const date = new Date(timestamp);
|
||||
const diff = date.getTime() - startTime.getTime();
|
||||
if (diff < 0) return "0:00";
|
||||
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
// Optional: extended formatting for longer durations
|
||||
const h = Math.floor(m / 60);
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
// Optional: extended formatting for longer durations
|
||||
const h = Math.floor(m / 60);
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
if (h > 0) {
|
||||
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
|
||||
{
|
||||
id: "timestamp",
|
||||
header: "Time",
|
||||
accessorKey: "timestamp",
|
||||
size: 90,
|
||||
meta: {
|
||||
style: { width: '90px', minWidth: '90px' }
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.timestamp);
|
||||
return (
|
||||
<div className="flex flex-col py-0.5">
|
||||
<span className="font-mono font-medium text-xs">
|
||||
{formatRelativeTime(row.original.timestamp, startTime)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
|
||||
{date.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
id: "timestamp",
|
||||
header: "Time",
|
||||
accessorKey: "timestamp",
|
||||
size: 90,
|
||||
meta: {
|
||||
style: { width: "90px", minWidth: "90px" },
|
||||
},
|
||||
{
|
||||
accessorKey: "eventType",
|
||||
header: "Event Type",
|
||||
size: 160,
|
||||
meta: {
|
||||
style: { width: '160px', minWidth: '160px' }
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue("eventType") as string;
|
||||
const isError = type.includes("error");
|
||||
const isIntervention = type.includes("intervention");
|
||||
const isRobot = type.includes("robot");
|
||||
const isStep = type.includes("step");
|
||||
|
||||
const isObservation = type.includes("annotation") || type.includes("note");
|
||||
const isJump = type.includes("jump"); // intervention_step_jump
|
||||
const isActionComplete = type.includes("marked_complete");
|
||||
|
||||
let Icon = Activity;
|
||||
if (isError) Icon = AlertTriangle;
|
||||
else if (isIntervention || isJump) Icon = User; // Jumps are interventions
|
||||
else if (isRobot) Icon = Bot;
|
||||
else if (isStep) Icon = Flag;
|
||||
else if (isObservation) Icon = MessageSquare;
|
||||
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
|
||||
|
||||
return (
|
||||
<div className="flex items-center py-0.5">
|
||||
<Badge variant="outline" className={cn(
|
||||
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
|
||||
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
|
||||
(isIntervention || isJump) && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
|
||||
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
|
||||
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
|
||||
isObservation && "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
|
||||
isActionComplete && "border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400"
|
||||
)}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.timestamp);
|
||||
return (
|
||||
<div className="flex flex-col py-0.5">
|
||||
<span className="font-mono text-xs font-medium">
|
||||
{formatRelativeTime(row.original.timestamp, startTime)}
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden text-[10px] group-hover:block">
|
||||
{date.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
accessorKey: "data",
|
||||
header: "Details",
|
||||
cell: ({ row }) => {
|
||||
const data = row.original.data;
|
||||
const type = row.getValue("eventType") as string;
|
||||
|
||||
// Wrapper for density and alignment
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="py-0.5 min-w-[300px] whitespace-normal break-words text-xs leading-normal">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!data || Object.keys(data).length === 0) return <Wrapper><span className="text-muted-foreground">-</span></Wrapper>;
|
||||
|
||||
// Smart Formatting
|
||||
if (type.includes("jump")) {
|
||||
return (
|
||||
<Wrapper>
|
||||
Jumped to step <strong>{data.stepName || (data.toIndex !== undefined ? data.toIndex + 1 : "?")}</strong>
|
||||
<span className="text-muted-foreground ml-1">(Manual)</span>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
if (type.includes("skipped")) {
|
||||
return <Wrapper><span className="text-orange-600 dark:text-orange-400">Skipped: {data.actionId}</span></Wrapper>;
|
||||
}
|
||||
if (type.includes("marked_complete")) {
|
||||
return <Wrapper><span className="text-green-600 dark:text-green-400">Manually marked complete</span></Wrapper>;
|
||||
}
|
||||
if (type.includes("annotation") || type.includes("note")) {
|
||||
return <Wrapper><span className="italic text-foreground/80">{data.description || data.note || data.message || "No content"}</span></Wrapper>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<code className="font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border inline-block max-w-full truncate align-middle">
|
||||
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
|
||||
</code>
|
||||
</Wrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "eventType",
|
||||
header: "Event Type",
|
||||
size: 160,
|
||||
meta: {
|
||||
style: { width: "160px", minWidth: "160px" },
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue("eventType") as string;
|
||||
const isError = type.includes("error");
|
||||
const isIntervention = type.includes("intervention");
|
||||
const isRobot = type.includes("robot");
|
||||
const isStep = type.includes("step");
|
||||
|
||||
const isObservation =
|
||||
type.includes("annotation") || type.includes("note");
|
||||
const isJump = type.includes("jump"); // intervention_step_jump
|
||||
const isActionComplete = type.includes("marked_complete");
|
||||
|
||||
let Icon = Activity;
|
||||
if (isError) Icon = AlertTriangle;
|
||||
else if (isIntervention || isJump)
|
||||
Icon = User; // Jumps are interventions
|
||||
else if (isRobot) Icon = Bot;
|
||||
else if (isStep) Icon = Flag;
|
||||
else if (isObservation) Icon = MessageSquare;
|
||||
else if (type.includes("completed") || isActionComplete)
|
||||
Icon = CheckCircle;
|
||||
|
||||
return (
|
||||
<div className="flex items-center py-0.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px] font-medium capitalize",
|
||||
isError &&
|
||||
"border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
|
||||
(isIntervention || isJump) &&
|
||||
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
|
||||
isRobot &&
|
||||
"border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
|
||||
isStep &&
|
||||
"border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
|
||||
isObservation &&
|
||||
"border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
|
||||
isActionComplete &&
|
||||
"border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "data",
|
||||
header: "Details",
|
||||
cell: ({ row }) => {
|
||||
const data = row.original.data;
|
||||
const type = row.getValue("eventType") as string;
|
||||
|
||||
// Wrapper for density and alignment
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="min-w-[300px] py-0.5 text-xs leading-normal break-words whitespace-normal">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!data || Object.keys(data).length === 0)
|
||||
return (
|
||||
<Wrapper>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
// Smart Formatting
|
||||
if (type.includes("jump")) {
|
||||
return (
|
||||
<Wrapper>
|
||||
Jumped to step{" "}
|
||||
<strong>
|
||||
{data.stepName ||
|
||||
(data.toIndex !== undefined ? data.toIndex + 1 : "?")}
|
||||
</strong>
|
||||
<span className="text-muted-foreground ml-1">(Manual)</span>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
if (type.includes("skipped")) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
Skipped: {data.actionId}
|
||||
</span>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
if (type.includes("marked_complete")) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
Manually marked complete
|
||||
</span>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
if (type.includes("annotation") || type.includes("note")) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<span className="text-foreground/80 italic">
|
||||
{data.description || data.note || data.message || "No content"}
|
||||
</span>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<code className="text-muted-foreground bg-muted/50 inline-block max-w-full truncate rounded border px-1.5 py-0.5 align-middle font-mono">
|
||||
{JSON.stringify(data)
|
||||
.replace(/[{""}]/g, " ")
|
||||
.trim()}
|
||||
</code>
|
||||
</Wrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,295 +2,405 @@
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { usePlayback } from "../playback/PlaybackContext";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
User,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
Activity,
|
||||
Video
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
User,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
Activity,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { type TrialEvent } from "./events-columns";
|
||||
|
||||
interface EventsDataTableProps {
|
||||
data: TrialEvent[];
|
||||
startTime?: Date;
|
||||
data: TrialEvent[];
|
||||
startTime?: Date;
|
||||
}
|
||||
|
||||
// Helper to format timestamp relative to start
|
||||
function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
|
||||
if (!startTime) return "--:--";
|
||||
const date = new Date(timestamp);
|
||||
const diff = date.getTime() - startTime.getTime();
|
||||
if (diff < 0) return "0:00";
|
||||
if (!startTime) return "--:--";
|
||||
const date = new Date(timestamp);
|
||||
const diff = date.getTime() - startTime.getTime();
|
||||
if (diff < 0) return "0:00";
|
||||
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
|
||||
// Optional: extended formatting for longer durations
|
||||
const h = Math.floor(m / 60);
|
||||
// Optional: extended formatting for longer durations
|
||||
const h = Math.floor(m / 60);
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
if (h > 0) {
|
||||
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
||||
const { seekTo, events, currentEventIndex } = usePlayback();
|
||||
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
|
||||
const [globalFilter, setGlobalFilter] = React.useState<string>("");
|
||||
const { seekTo, events, currentEventIndex } = usePlayback();
|
||||
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
|
||||
const [globalFilter, setGlobalFilter] = React.useState<string>("");
|
||||
|
||||
// Enhanced filtering logic
|
||||
const filteredData = React.useMemo(() => {
|
||||
return data.filter(event => {
|
||||
// Type filter
|
||||
if (eventTypeFilter !== "all" && !event.eventType.includes(eventTypeFilter)) {
|
||||
return false;
|
||||
}
|
||||
// Enhanced filtering logic
|
||||
const filteredData = React.useMemo(() => {
|
||||
return data.filter((event) => {
|
||||
// Type filter
|
||||
if (
|
||||
eventTypeFilter !== "all" &&
|
||||
!event.eventType.includes(eventTypeFilter)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Global text search (checks type and data)
|
||||
if (globalFilter) {
|
||||
const searchLower = globalFilter.toLowerCase();
|
||||
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
|
||||
// Safe JSON stringify check
|
||||
const dataString = event.data ? JSON.stringify(event.data).toLowerCase() : "";
|
||||
const dataMatch = dataString.includes(searchLower);
|
||||
// Global text search (checks type and data)
|
||||
if (globalFilter) {
|
||||
const searchLower = globalFilter.toLowerCase();
|
||||
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
|
||||
// Safe JSON stringify check
|
||||
const dataString = event.data
|
||||
? JSON.stringify(event.data).toLowerCase()
|
||||
: "";
|
||||
const dataMatch = dataString.includes(searchLower);
|
||||
|
||||
return typeMatch || dataMatch;
|
||||
}
|
||||
return typeMatch || dataMatch;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [data, eventTypeFilter, globalFilter]);
|
||||
return true;
|
||||
});
|
||||
}, [data, eventTypeFilter, globalFilter]);
|
||||
|
||||
// Active Event Logic & Auto-scroll
|
||||
// Match filtered events with global playback "active event" via ID
|
||||
const activeEventId = React.useMemo(() => {
|
||||
if (currentEventIndex >= 0 && currentEventIndex < events.length) {
|
||||
// We need to match the type of ID used in data/events
|
||||
// Assuming events from context are TrialEvent compatible
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const evt = events[currentEventIndex] as any;
|
||||
return evt?.id;
|
||||
}
|
||||
return null;
|
||||
}, [events, currentEventIndex]);
|
||||
// Active Event Logic & Auto-scroll
|
||||
// Match filtered events with global playback "active event" via ID
|
||||
const activeEventId = React.useMemo(() => {
|
||||
if (currentEventIndex >= 0 && currentEventIndex < events.length) {
|
||||
// We need to match the type of ID used in data/events
|
||||
// Assuming events from context are TrialEvent compatible
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const evt = events[currentEventIndex] as any;
|
||||
return evt?.id;
|
||||
}
|
||||
return null;
|
||||
}, [events, currentEventIndex]);
|
||||
|
||||
const rowRefs = React.useRef<{ [key: string]: HTMLTableRowElement | null }>({});
|
||||
const rowRefs = React.useRef<{ [key: string]: HTMLTableRowElement | null }>(
|
||||
{},
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeEventId && rowRefs.current[activeEventId]) {
|
||||
rowRefs.current[activeEventId]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [activeEventId]);
|
||||
React.useEffect(() => {
|
||||
if (activeEventId && rowRefs.current[activeEventId]) {
|
||||
rowRefs.current[activeEventId]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [activeEventId]);
|
||||
|
||||
const handleRowClick = (event: TrialEvent) => {
|
||||
if (!startTime) return;
|
||||
const timeMs = new Date(event.timestamp).getTime();
|
||||
const seekSeconds = (timeMs - startTime.getTime()) / 1000;
|
||||
seekTo(Math.max(0, seekSeconds));
|
||||
};
|
||||
const handleRowClick = (event: TrialEvent) => {
|
||||
if (!startTime) return;
|
||||
const timeMs = new Date(event.timestamp).getTime();
|
||||
const seekSeconds = (timeMs - startTime.getTime()) / 1000;
|
||||
seekTo(Math.max(0, seekSeconds));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<Input
|
||||
id="tour-analytics-filter"
|
||||
placeholder="Search event data..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="All Events" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Events</SelectItem>
|
||||
<SelectItem value="action_executed">Actions</SelectItem>
|
||||
<SelectItem value="action_skipped">Skipped Actions</SelectItem>
|
||||
<SelectItem value="intervention">Interventions</SelectItem>
|
||||
<SelectItem value="robot">Robot Actions</SelectItem>
|
||||
<SelectItem value="step">Step Changes</SelectItem>
|
||||
<SelectItem value="error">Errors</SelectItem>
|
||||
<SelectItem value="annotation">Notes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mr-2">
|
||||
{filteredData.length} events
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tour-analytics-table" className="rounded-md border bg-background">
|
||||
<div>
|
||||
<Table className="w-full">
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="w-[100px]">Time</TableHead>
|
||||
<TableHead className="w-[180px]">Event Type</TableHead>
|
||||
<TableHead className="w-auto">Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredData.map((event, index) => {
|
||||
const type = event.eventType;
|
||||
const data = event.data;
|
||||
|
||||
// Type Logic
|
||||
const isError = type.includes("error");
|
||||
const isIntervention = type.includes("intervention");
|
||||
const isRobot = type.includes("robot");
|
||||
const isStep = type.includes("step");
|
||||
const isObservation = type.includes("annotation") || type.includes("note");
|
||||
const isJump = type.includes("jump");
|
||||
const isActionComplete = type.includes("marked_complete");
|
||||
const isCamera = type.includes("camera");
|
||||
|
||||
let Icon = Activity;
|
||||
if (isError) Icon = AlertTriangle;
|
||||
else if (isIntervention || isJump) Icon = User;
|
||||
else if (isRobot) Icon = Bot;
|
||||
else if (isStep) Icon = Flag;
|
||||
else if (isObservation) Icon = MessageSquare;
|
||||
else if (isCamera) Icon = Video;
|
||||
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
|
||||
|
||||
// Details Logic
|
||||
let detailsContent;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any; // Cast for easier access
|
||||
|
||||
if (type.includes("jump")) {
|
||||
detailsContent = (
|
||||
<>Jumped to step <strong>{d?.stepName || (d?.toIndex !== undefined ? d.toIndex + 1 : "?")}</strong> <span className="text-muted-foreground ml-1">(Manual)</span></>
|
||||
);
|
||||
} else if (type.includes("skipped")) {
|
||||
detailsContent = <span className="text-orange-600 dark:text-orange-400">Skipped: {d?.actionId}</span>;
|
||||
} else if (type.includes("marked_complete")) {
|
||||
detailsContent = <span className="text-green-600 dark:text-green-400">Manually marked complete</span>;
|
||||
} else if (type.includes("annotation") || type.includes("note")) {
|
||||
detailsContent = <span className="italic text-foreground/80">{d?.description || d?.note || d?.message || "No content"}</span>;
|
||||
} else if (type.includes("step")) {
|
||||
detailsContent = <span>Step: <strong>{d?.stepName || d?.name || (d?.index !== undefined ? `Index ${d.index}` : "")}</strong></span>;
|
||||
} else if (type.includes("action_executed")) {
|
||||
const name = d?.actionName || d?.actionId;
|
||||
const meta = d?.actionType ? `(${d.actionType})` : d?.type ? `(${d.type})` : "";
|
||||
detailsContent = <span>Executed: <strong>{name}</strong> <span className="text-muted-foreground text-[10px] ml-1">{meta}</span></span>;
|
||||
} else if (type.includes("robot") || type.includes("say") || type.includes("speech")) {
|
||||
const text = d?.text || d?.message || d?.data?.text;
|
||||
detailsContent = (
|
||||
<span>
|
||||
Robot: <strong>{d?.command || d?.type || "Action"}</strong>
|
||||
{text && <span className="text-muted-foreground ml-1">"{text}"</span>}
|
||||
</span>
|
||||
);
|
||||
} else if (type.includes("intervention")) {
|
||||
detailsContent = <span className="text-orange-600 dark:text-orange-400">Intervention: {d?.type || "Manual Action"}</span>;
|
||||
} else if (type === "trial_started") {
|
||||
detailsContent = <span className="text-green-600 font-medium">Trial Started</span>;
|
||||
} else if (type === "trial_completed") {
|
||||
detailsContent = <span className="text-blue-600 font-medium">Trial Completed</span>;
|
||||
} else if (type === "trial_paused") {
|
||||
detailsContent = <span className="text-yellow-600 font-medium">Trial Paused</span>;
|
||||
} else if (isCamera) {
|
||||
detailsContent = <span className="font-medium text-teal-600 dark:text-teal-400">{type === "camera_started" ? "Recording Started" : type === "camera_stopped" ? "Recording Stopped" : "Camera Event"}</span>;
|
||||
} else {
|
||||
// Default
|
||||
if (d && Object.keys(d).length > 0) {
|
||||
detailsContent = (
|
||||
<code className="font-mono text-muted-foreground bg-muted/50 px-1 py-0.5 rounded border inline-block max-w-full truncate align-middle text-[10px]">
|
||||
{JSON.stringify(d).replace(/[{"}]/g, " ").trim()}
|
||||
</code>
|
||||
);
|
||||
} else {
|
||||
detailsContent = <span className="text-muted-foreground text-xs">-</span>;
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = activeEventId === event.id;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={event.id || index}
|
||||
ref={(el) => {
|
||||
if (event.id) rowRefs.current[event.id] = el;
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer h-auto border-l-2 border-transparent transition-colors",
|
||||
isActive
|
||||
? "bg-muted border-l-primary"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => handleRowClick(event)}
|
||||
>
|
||||
<TableCell className="py-1 align-top w-[100px]">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-medium text-xs">
|
||||
{formatRelativeTime(event.timestamp, startTime)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-1 align-top w-[180px]">
|
||||
<div className="flex items-center">
|
||||
<Badge variant="outline" className={cn(
|
||||
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
|
||||
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
|
||||
(isIntervention || isJump) && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
|
||||
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
|
||||
isCamera && "border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-900/50 dark:bg-teal-900/20 dark:text-teal-400",
|
||||
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
|
||||
isObservation && "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
|
||||
isActionComplete && "border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400"
|
||||
)}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-1 align-top w-auto">
|
||||
<div className="text-xs break-words whitespace-normal leading-normal min-w-0">
|
||||
{detailsContent}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<Input
|
||||
id="tour-analytics-filter"
|
||||
placeholder="Search event data..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="All Events" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Events</SelectItem>
|
||||
<SelectItem value="action_executed">Actions</SelectItem>
|
||||
<SelectItem value="action_skipped">Skipped Actions</SelectItem>
|
||||
<SelectItem value="intervention">Interventions</SelectItem>
|
||||
<SelectItem value="robot">Robot Actions</SelectItem>
|
||||
<SelectItem value="step">Step Changes</SelectItem>
|
||||
<SelectItem value="error">Errors</SelectItem>
|
||||
<SelectItem value="annotation">Notes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
<div className="text-muted-foreground mr-2 text-xs">
|
||||
{filteredData.length} events
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="tour-analytics-table"
|
||||
className="bg-background rounded-md border"
|
||||
>
|
||||
<div>
|
||||
<Table className="w-full">
|
||||
<TableHeader className="bg-background sticky top-0 z-10 shadow-sm">
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="w-[100px]">Time</TableHead>
|
||||
<TableHead className="w-[180px]">Event Type</TableHead>
|
||||
<TableHead className="w-auto">Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredData.map((event, index) => {
|
||||
const type = event.eventType;
|
||||
const data = event.data;
|
||||
|
||||
// Type Logic
|
||||
const isError = type.includes("error");
|
||||
const isIntervention = type.includes("intervention");
|
||||
const isRobot = type.includes("robot");
|
||||
const isStep = type.includes("step");
|
||||
const isObservation =
|
||||
type.includes("annotation") || type.includes("note");
|
||||
const isJump = type.includes("jump");
|
||||
const isActionComplete = type.includes("marked_complete");
|
||||
const isCamera = type.includes("camera");
|
||||
|
||||
let Icon = Activity;
|
||||
if (isError) Icon = AlertTriangle;
|
||||
else if (isIntervention || isJump) Icon = User;
|
||||
else if (isRobot) Icon = Bot;
|
||||
else if (isStep) Icon = Flag;
|
||||
else if (isObservation) Icon = MessageSquare;
|
||||
else if (isCamera) Icon = Video;
|
||||
else if (type.includes("completed") || isActionComplete)
|
||||
Icon = CheckCircle;
|
||||
|
||||
// Details Logic
|
||||
let detailsContent;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any; // Cast for easier access
|
||||
|
||||
if (type.includes("jump")) {
|
||||
detailsContent = (
|
||||
<>
|
||||
Jumped to step{" "}
|
||||
<strong>
|
||||
{d?.stepName ||
|
||||
(d?.toIndex !== undefined ? d.toIndex + 1 : "?")}
|
||||
</strong>{" "}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
(Manual)
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
} else if (type.includes("skipped")) {
|
||||
detailsContent = (
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
Skipped: {d?.actionId}
|
||||
</span>
|
||||
);
|
||||
} else if (type.includes("marked_complete")) {
|
||||
detailsContent = (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
Manually marked complete
|
||||
</span>
|
||||
);
|
||||
} else if (
|
||||
type.includes("annotation") ||
|
||||
type.includes("note")
|
||||
) {
|
||||
detailsContent = (
|
||||
<span className="text-foreground/80 italic">
|
||||
{d?.description ||
|
||||
d?.note ||
|
||||
d?.message ||
|
||||
"No content"}
|
||||
</span>
|
||||
);
|
||||
} else if (type.includes("step")) {
|
||||
detailsContent = (
|
||||
<span>
|
||||
Step:{" "}
|
||||
<strong>
|
||||
{d?.stepName ||
|
||||
d?.name ||
|
||||
(d?.index !== undefined ? `Index ${d.index}` : "")}
|
||||
</strong>
|
||||
</span>
|
||||
);
|
||||
} else if (type.includes("action_executed")) {
|
||||
const name = d?.actionName || d?.actionId;
|
||||
const meta = d?.actionType
|
||||
? `(${d.actionType})`
|
||||
: d?.type
|
||||
? `(${d.type})`
|
||||
: "";
|
||||
detailsContent = (
|
||||
<span>
|
||||
Executed: <strong>{name}</strong>{" "}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
{meta}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
} else if (
|
||||
type.includes("robot") ||
|
||||
type.includes("say") ||
|
||||
type.includes("speech")
|
||||
) {
|
||||
const text = d?.text || d?.message || d?.data?.text;
|
||||
detailsContent = (
|
||||
<span>
|
||||
Robot:{" "}
|
||||
<strong>{d?.command || d?.type || "Action"}</strong>
|
||||
{text && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
"{text}"
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
} else if (type.includes("intervention")) {
|
||||
detailsContent = (
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
Intervention: {d?.type || "Manual Action"}
|
||||
</span>
|
||||
);
|
||||
} else if (type === "trial_started") {
|
||||
detailsContent = (
|
||||
<span className="font-medium text-green-600">
|
||||
Trial Started
|
||||
</span>
|
||||
);
|
||||
} else if (type === "trial_completed") {
|
||||
detailsContent = (
|
||||
<span className="font-medium text-blue-600">
|
||||
Trial Completed
|
||||
</span>
|
||||
);
|
||||
} else if (type === "trial_paused") {
|
||||
detailsContent = (
|
||||
<span className="font-medium text-yellow-600">
|
||||
Trial Paused
|
||||
</span>
|
||||
);
|
||||
} else if (isCamera) {
|
||||
detailsContent = (
|
||||
<span className="font-medium text-teal-600 dark:text-teal-400">
|
||||
{type === "camera_started"
|
||||
? "Recording Started"
|
||||
: type === "camera_stopped"
|
||||
? "Recording Stopped"
|
||||
: "Camera Event"}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
// Default
|
||||
if (d && Object.keys(d).length > 0) {
|
||||
detailsContent = (
|
||||
<code className="text-muted-foreground bg-muted/50 inline-block max-w-full truncate rounded border px-1 py-0.5 align-middle font-mono text-[10px]">
|
||||
{JSON.stringify(d).replace(/[{"}]/g, " ").trim()}
|
||||
</code>
|
||||
);
|
||||
} else {
|
||||
detailsContent = (
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = activeEventId === event.id;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={event.id || index}
|
||||
ref={(el) => {
|
||||
if (event.id) rowRefs.current[event.id] = el;
|
||||
}}
|
||||
className={cn(
|
||||
"h-auto cursor-pointer border-l-2 border-transparent transition-colors",
|
||||
isActive
|
||||
? "bg-muted border-l-primary"
|
||||
: "hover:bg-muted/50",
|
||||
)}
|
||||
onClick={() => handleRowClick(event)}
|
||||
>
|
||||
<TableCell className="w-[100px] py-1 align-top">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs font-medium">
|
||||
{formatRelativeTime(event.timestamp, startTime)}
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden text-[10px] group-hover:block">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[180px] py-1 align-top">
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px] font-medium capitalize",
|
||||
isError &&
|
||||
"border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
|
||||
(isIntervention || isJump) &&
|
||||
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
|
||||
isRobot &&
|
||||
"border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
|
||||
isCamera &&
|
||||
"border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-900/50 dark:bg-teal-900/20 dark:text-teal-400",
|
||||
isStep &&
|
||||
"border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
|
||||
isObservation &&
|
||||
"border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
|
||||
isActionComplete &&
|
||||
"border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-auto py-1 align-top">
|
||||
<div className="min-w-0 text-xs leading-normal break-words whitespace-normal">
|
||||
{detailsContent}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,198 +4,239 @@ import React, { useMemo, useRef, useState } from "react";
|
||||
import { usePlayback } from "./PlaybackContext";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
Circle,
|
||||
Bot,
|
||||
User,
|
||||
Activity
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
Circle,
|
||||
Bot,
|
||||
User,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = Math.floor(seconds % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = Math.floor(seconds % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function EventTimeline() {
|
||||
const {
|
||||
duration,
|
||||
currentTime,
|
||||
events,
|
||||
seekTo,
|
||||
startTime: contextStartTime
|
||||
} = usePlayback();
|
||||
const {
|
||||
duration,
|
||||
currentTime,
|
||||
events,
|
||||
seekTo,
|
||||
startTime: contextStartTime,
|
||||
} = usePlayback();
|
||||
|
||||
// Determine effective time range
|
||||
const sortedEvents = useMemo(() => {
|
||||
return [...events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
}, [events]);
|
||||
|
||||
const startTime = useMemo(() => {
|
||||
if (contextStartTime) return new Date(contextStartTime).getTime();
|
||||
return 0;
|
||||
}, [contextStartTime]);
|
||||
|
||||
const effectiveDuration = useMemo(() => {
|
||||
if (duration > 0) return duration * 1000;
|
||||
return 60000; // 1 min default
|
||||
}, [duration]);
|
||||
|
||||
// Dimensions
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Helpers
|
||||
const getPercentage = (timestampMs: number) => {
|
||||
const offset = timestampMs - startTime;
|
||||
return Math.max(0, Math.min(100, (offset / effectiveDuration) * 100));
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const pct = Math.max(0, Math.min(1, x / rect.width));
|
||||
seekTo(pct * (effectiveDuration / 1000));
|
||||
};
|
||||
|
||||
const currentProgress = (currentTime * 1000 / effectiveDuration) * 100;
|
||||
|
||||
// Generate ticks
|
||||
const ticks = useMemo(() => {
|
||||
const count = 10;
|
||||
return Array.from({ length: count + 1 }).map((_, i) => ({
|
||||
pct: (i / count) * 100,
|
||||
label: formatTime((effectiveDuration / 1000) * (i / count))
|
||||
}));
|
||||
}, [effectiveDuration]);
|
||||
|
||||
const getEventIcon = (type: string) => {
|
||||
if (type.includes("intervention") || type.includes("wizard") || type.includes("jump")) return <User className="h-4 w-4" />;
|
||||
if (type.includes("robot") || type.includes("action")) return <Bot className="h-4 w-4" />;
|
||||
if (type.includes("completed")) return <CheckCircle className="h-4 w-4" />;
|
||||
if (type.includes("start")) return <Flag className="h-4 w-4" />;
|
||||
if (type.includes("note") || type.includes("annotation")) return <MessageSquare className="h-4 w-4" />;
|
||||
if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
|
||||
return <Activity className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const getEventColor = (type: string) => {
|
||||
if (type.includes("intervention") || type.includes("wizard") || type.includes("jump")) return "bg-orange-100 text-orange-600 border-orange-200";
|
||||
if (type.includes("robot") || type.includes("action")) return "bg-purple-100 text-purple-600 border-purple-200";
|
||||
if (type.includes("completed")) return "bg-green-100 text-green-600 border-green-200";
|
||||
if (type.includes("start")) return "bg-blue-100 text-blue-600 border-blue-200";
|
||||
if (type.includes("note") || type.includes("annotation")) return "bg-yellow-100 text-yellow-600 border-yellow-200";
|
||||
if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
|
||||
return "bg-slate-100 text-slate-600 border-slate-200";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-28 flex flex-col justify-center px-8 select-none">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{/* Main Interactive Area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-16 flex items-center cursor-pointer group"
|
||||
onClick={handleSeek}
|
||||
>
|
||||
{/* The Timeline Line (Horizontal) */}
|
||||
<div className="absolute left-0 right-0 h-0.5 top-1/2 -mt-px bg-border group-hover:bg-border/80 transition-colors" />
|
||||
|
||||
{/* Progress Fill */}
|
||||
<div
|
||||
className="absolute left-0 h-0.5 bg-primary/30 pointer-events-none"
|
||||
style={{ width: `${currentProgress}%`, top: '50%', marginTop: '-1px' }}
|
||||
/>
|
||||
|
||||
{/* Playhead (Scanner) */}
|
||||
<div
|
||||
className="absolute h-16 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
|
||||
style={{ left: `${currentProgress}%`, top: '50%', transform: 'translateY(-50%)' }}
|
||||
>
|
||||
{/* Knob */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-red-500 rounded-full shadow border border-white" />
|
||||
</div>
|
||||
|
||||
{/* Events (Avatars/Dots) */}
|
||||
{sortedEvents.map((event, i) => {
|
||||
const pct = getPercentage(new Date(event.timestamp).getTime());
|
||||
|
||||
// Smart Formatting Logic
|
||||
const details = (() => {
|
||||
const { eventType, data } = event;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any;
|
||||
|
||||
if (eventType.includes("jump")) return `Jumped to step ${d?.stepName || d?.toIndex + 1 || "?"} (Manual)`;
|
||||
if (eventType.includes("skipped")) return `Skipped: ${d?.actionId}`;
|
||||
if (eventType.includes("marked_complete")) return "Manually marked complete";
|
||||
if (eventType.includes("annotation") || eventType.includes("note")) return d?.description || d?.note || d?.message || "Note";
|
||||
|
||||
if (!d || Object.keys(d).length === 0) return null;
|
||||
return JSON.stringify(d).slice(0, 100).replace(/[{""}]/g, " ").trim();
|
||||
})();
|
||||
|
||||
return (
|
||||
<Tooltip key={i}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="absolute z-20 top-1/2 left-0 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center group/event cursor-pointer p-2"
|
||||
style={{ left: `${pct}%` }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// startTime is in ms, timestamp is Date string or obj
|
||||
const timeMs = new Date(event.timestamp).getTime();
|
||||
const seekSeconds = (timeMs - startTime) / 1000;
|
||||
seekTo(Math.max(0, seekSeconds));
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-transform hover:scale-125 hover:z-50 bg-background relative z-20",
|
||||
getEventColor(event.eventType)
|
||||
)}>
|
||||
{getEventIcon(event.eventType)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider mb-0.5">{event.eventType.replace(/_/g, " ")}</div>
|
||||
<div className="text-[10px] font-mono opacity-70 mb-1">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
{!!details && (
|
||||
<div className="bg-muted/50 p-1.5 rounded text-[10px] max-w-[220px] break-words whitespace-normal border">
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ticks (Below) */}
|
||||
{ticks.map((tick, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-10 text-[10px] font-mono text-muted-foreground transform -translate-x-1/2 pointer-events-none flex flex-col items-center"
|
||||
style={{ left: `${tick.pct}%` }}
|
||||
>
|
||||
{/* Tick Mark */}
|
||||
<div className="w-px h-2 bg-border mb-1" />
|
||||
{tick.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
// Determine effective time range
|
||||
const sortedEvents = useMemo(() => {
|
||||
return [...events].sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
}, [events]);
|
||||
|
||||
const startTime = useMemo(() => {
|
||||
if (contextStartTime) return new Date(contextStartTime).getTime();
|
||||
return 0;
|
||||
}, [contextStartTime]);
|
||||
|
||||
const effectiveDuration = useMemo(() => {
|
||||
if (duration > 0) return duration * 1000;
|
||||
return 60000; // 1 min default
|
||||
}, [duration]);
|
||||
|
||||
// Dimensions
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Helpers
|
||||
const getPercentage = (timestampMs: number) => {
|
||||
const offset = timestampMs - startTime;
|
||||
return Math.max(0, Math.min(100, (offset / effectiveDuration) * 100));
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const pct = Math.max(0, Math.min(1, x / rect.width));
|
||||
seekTo(pct * (effectiveDuration / 1000));
|
||||
};
|
||||
|
||||
const currentProgress = ((currentTime * 1000) / effectiveDuration) * 100;
|
||||
|
||||
// Generate ticks
|
||||
const ticks = useMemo(() => {
|
||||
const count = 10;
|
||||
return Array.from({ length: count + 1 }).map((_, i) => ({
|
||||
pct: (i / count) * 100,
|
||||
label: formatTime((effectiveDuration / 1000) * (i / count)),
|
||||
}));
|
||||
}, [effectiveDuration]);
|
||||
|
||||
const getEventIcon = (type: string) => {
|
||||
if (
|
||||
type.includes("intervention") ||
|
||||
type.includes("wizard") ||
|
||||
type.includes("jump")
|
||||
)
|
||||
return <User className="h-4 w-4" />;
|
||||
if (type.includes("robot") || type.includes("action"))
|
||||
return <Bot className="h-4 w-4" />;
|
||||
if (type.includes("completed")) return <CheckCircle className="h-4 w-4" />;
|
||||
if (type.includes("start")) return <Flag className="h-4 w-4" />;
|
||||
if (type.includes("note") || type.includes("annotation"))
|
||||
return <MessageSquare className="h-4 w-4" />;
|
||||
if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
|
||||
return <Activity className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const getEventColor = (type: string) => {
|
||||
if (
|
||||
type.includes("intervention") ||
|
||||
type.includes("wizard") ||
|
||||
type.includes("jump")
|
||||
)
|
||||
return "bg-orange-100 text-orange-600 border-orange-200";
|
||||
if (type.includes("robot") || type.includes("action"))
|
||||
return "bg-purple-100 text-purple-600 border-purple-200";
|
||||
if (type.includes("completed"))
|
||||
return "bg-green-100 text-green-600 border-green-200";
|
||||
if (type.includes("start"))
|
||||
return "bg-blue-100 text-blue-600 border-blue-200";
|
||||
if (type.includes("note") || type.includes("annotation"))
|
||||
return "bg-yellow-100 text-yellow-600 border-yellow-200";
|
||||
if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
|
||||
return "bg-slate-100 text-slate-600 border-slate-200";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-28 w-full flex-col justify-center px-8 select-none">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{/* Main Interactive Area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="group relative flex h-16 w-full cursor-pointer items-center"
|
||||
onClick={handleSeek}
|
||||
>
|
||||
{/* The Timeline Line (Horizontal) */}
|
||||
<div className="bg-border group-hover:bg-border/80 absolute top-1/2 right-0 left-0 -mt-px h-0.5 transition-colors" />
|
||||
|
||||
{/* Progress Fill */}
|
||||
<div
|
||||
className="bg-primary/30 pointer-events-none absolute left-0 h-0.5"
|
||||
style={{
|
||||
width: `${currentProgress}%`,
|
||||
top: "50%",
|
||||
marginTop: "-1px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Playhead (Scanner) */}
|
||||
<div
|
||||
className="pointer-events-none absolute z-30 h-16 w-px bg-red-500 transition-all duration-75"
|
||||
style={{
|
||||
left: `${currentProgress}%`,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
{/* Knob */}
|
||||
<div className="absolute top-1/2 left-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-red-500 shadow" />
|
||||
</div>
|
||||
|
||||
{/* Events (Avatars/Dots) */}
|
||||
{sortedEvents.map((event, i) => {
|
||||
const pct = getPercentage(new Date(event.timestamp).getTime());
|
||||
|
||||
// Smart Formatting Logic
|
||||
const details = (() => {
|
||||
const { eventType, data } = event;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any;
|
||||
|
||||
if (eventType.includes("jump"))
|
||||
return `Jumped to step ${d?.stepName || d?.toIndex + 1 || "?"} (Manual)`;
|
||||
if (eventType.includes("skipped"))
|
||||
return `Skipped: ${d?.actionId}`;
|
||||
if (eventType.includes("marked_complete"))
|
||||
return "Manually marked complete";
|
||||
if (
|
||||
eventType.includes("annotation") ||
|
||||
eventType.includes("note")
|
||||
)
|
||||
return d?.description || d?.note || d?.message || "Note";
|
||||
|
||||
if (!d || Object.keys(d).length === 0) return null;
|
||||
return JSON.stringify(d)
|
||||
.slice(0, 100)
|
||||
.replace(/[{""}]/g, " ")
|
||||
.trim();
|
||||
})();
|
||||
|
||||
return (
|
||||
<Tooltip key={i}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="group/event absolute top-1/2 left-0 z-20 flex -translate-x-1/2 -translate-y-1/2 transform cursor-pointer flex-col items-center p-2"
|
||||
style={{ left: `${pct}%` }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// startTime is in ms, timestamp is Date string or obj
|
||||
const timeMs = new Date(event.timestamp).getTime();
|
||||
const seekSeconds = (timeMs - startTime) / 1000;
|
||||
seekTo(Math.max(0, seekSeconds));
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background relative z-20 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-transform hover:z-50 hover:scale-125",
|
||||
getEventColor(event.eventType),
|
||||
)}
|
||||
>
|
||||
{getEventIcon(event.eventType)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<div className="mb-0.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{event.eventType.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div className="mb-1 font-mono text-[10px] opacity-70">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
{!!details && (
|
||||
<div className="bg-muted/50 max-w-[220px] rounded border p-1.5 text-[10px] break-words whitespace-normal">
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ticks (Below) */}
|
||||
{ticks.map((tick, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-muted-foreground pointer-events-none absolute top-10 flex -translate-x-1/2 transform flex-col items-center font-mono text-[10px]"
|
||||
style={{ left: `${tick.pct}%` }}
|
||||
>
|
||||
{/* Tick Mark */}
|
||||
<div className="bg-border mb-1 h-2 w-px" />
|
||||
{tick.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,130 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface TrialEvent {
|
||||
eventType: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
eventType: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface PlaybackContextType {
|
||||
// State
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
playbackRate: number;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
// State
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
playbackRate: number;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
|
||||
// Actions
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlay: () => void;
|
||||
seekTo: (time: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setCurrentTime: (time: number) => void; // Used by VideoPlayer to update state
|
||||
// Actions
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlay: () => void;
|
||||
seekTo: (time: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setCurrentTime: (time: number) => void; // Used by VideoPlayer to update state
|
||||
|
||||
// Data
|
||||
events: TrialEvent[];
|
||||
currentEventIndex: number; // Index of the last event that happened before currentTime
|
||||
// Data
|
||||
events: TrialEvent[];
|
||||
currentEventIndex: number; // Index of the last event that happened before currentTime
|
||||
}
|
||||
|
||||
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
||||
|
||||
export function usePlayback() {
|
||||
const context = useContext(PlaybackContext);
|
||||
if (!context) {
|
||||
throw new Error("usePlayback must be used within a PlaybackProvider");
|
||||
}
|
||||
return context;
|
||||
const context = useContext(PlaybackContext);
|
||||
if (!context) {
|
||||
throw new Error("usePlayback must be used within a PlaybackProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface PlaybackProviderProps {
|
||||
children: React.ReactNode;
|
||||
events?: TrialEvent[];
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
children: React.ReactNode;
|
||||
events?: TrialEvent[];
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
|
||||
export function PlaybackProvider({ children, events = [], startTime, endTime }: PlaybackProviderProps) {
|
||||
const trialDuration = React.useMemo(() => {
|
||||
if (startTime && endTime) return (new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000;
|
||||
return 0;
|
||||
}, [startTime, endTime]);
|
||||
export function PlaybackProvider({
|
||||
children,
|
||||
events = [],
|
||||
startTime,
|
||||
endTime,
|
||||
}: PlaybackProviderProps) {
|
||||
const trialDuration = React.useMemo(() => {
|
||||
if (startTime && endTime)
|
||||
return (
|
||||
(new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000
|
||||
);
|
||||
return 0;
|
||||
}, [startTime, endTime]);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(trialDuration);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(trialDuration);
|
||||
|
||||
useEffect(() => {
|
||||
if (trialDuration > 0 && duration === 0) {
|
||||
setDuration(trialDuration);
|
||||
}
|
||||
}, [trialDuration, duration]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
useEffect(() => {
|
||||
if (trialDuration > 0 && duration === 0) {
|
||||
setDuration(trialDuration);
|
||||
}
|
||||
}, [trialDuration, duration]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
|
||||
// Derived state: find the latest event index based on currentTime
|
||||
const currentEventIndex = React.useMemo(() => {
|
||||
if (!startTime || events.length === 0) return -1;
|
||||
// Derived state: find the latest event index based on currentTime
|
||||
const currentEventIndex = React.useMemo(() => {
|
||||
if (!startTime || events.length === 0) return -1;
|
||||
|
||||
// Find the last event that occurred before or at currentTime
|
||||
// Events are assumed to be sorted by timestamp
|
||||
// Using basic iteration for now, optimization possible for large lists
|
||||
let lastIndex = -1;
|
||||
// Find the last event that occurred before or at currentTime
|
||||
// Events are assumed to be sorted by timestamp
|
||||
// Using basic iteration for now, optimization possible for large lists
|
||||
let lastIndex = -1;
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const eventTime = new Date(events[i]!.timestamp).getTime();
|
||||
const startStr = new Date(startTime).getTime();
|
||||
const relativeSeconds = (eventTime - startStr) / 1000;
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const eventTime = new Date(events[i]!.timestamp).getTime();
|
||||
const startStr = new Date(startTime).getTime();
|
||||
const relativeSeconds = (eventTime - startStr) / 1000;
|
||||
|
||||
if (relativeSeconds <= currentTime) {
|
||||
lastIndex = i;
|
||||
} else {
|
||||
break; // Events are sorted, so we can stop
|
||||
}
|
||||
}
|
||||
return lastIndex;
|
||||
}, [currentTime, events, startTime]);
|
||||
if (relativeSeconds <= currentTime) {
|
||||
lastIndex = i;
|
||||
} else {
|
||||
break; // Events are sorted, so we can stop
|
||||
}
|
||||
}
|
||||
return lastIndex;
|
||||
}, [currentTime, events, startTime]);
|
||||
|
||||
// Actions
|
||||
const play = () => setIsPlaying(true);
|
||||
const pause = () => setIsPlaying(false);
|
||||
const togglePlay = () => setIsPlaying(p => !p);
|
||||
// Actions
|
||||
const play = () => setIsPlaying(true);
|
||||
const pause = () => setIsPlaying(false);
|
||||
const togglePlay = () => setIsPlaying((p) => !p);
|
||||
|
||||
const seekTo = (time: number) => {
|
||||
setCurrentTime(time);
|
||||
// Dispatch seek event to video player via some mechanism if needed,
|
||||
// usually VideoPlayer observes this context or we use a Ref to control it.
|
||||
// Actually, simple way: Context holds state, VideoPlayer listens to state?
|
||||
// No, VideoPlayer usually drives time.
|
||||
// Let's assume VideoPlayer updates `setCurrentTime` as it plays.
|
||||
// But if *we* seek (e.g. from timeline), we need to tell VideoPlayer to jump.
|
||||
// We might need a `seekRequest` timestamp or similar signal.
|
||||
};
|
||||
const seekTo = (time: number) => {
|
||||
setCurrentTime(time);
|
||||
// Dispatch seek event to video player via some mechanism if needed,
|
||||
// usually VideoPlayer observes this context or we use a Ref to control it.
|
||||
// Actually, simple way: Context holds state, VideoPlayer listens to state?
|
||||
// No, VideoPlayer usually drives time.
|
||||
// Let's assume VideoPlayer updates `setCurrentTime` as it plays.
|
||||
// But if *we* seek (e.g. from timeline), we need to tell VideoPlayer to jump.
|
||||
// We might need a `seekRequest` timestamp or similar signal.
|
||||
};
|
||||
|
||||
const value: PlaybackContextType = {
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
seekTo,
|
||||
setPlaybackRate,
|
||||
setDuration,
|
||||
setCurrentTime,
|
||||
events,
|
||||
currentEventIndex,
|
||||
startTime,
|
||||
endTime,
|
||||
};
|
||||
const value: PlaybackContextType = {
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
seekTo,
|
||||
setPlaybackRate,
|
||||
setDuration,
|
||||
setCurrentTime,
|
||||
events,
|
||||
currentEventIndex,
|
||||
startTime,
|
||||
endTime,
|
||||
};
|
||||
|
||||
return (
|
||||
<PlaybackContext.Provider value={value}>
|
||||
{children}
|
||||
</PlaybackContext.Provider>
|
||||
);
|
||||
return (
|
||||
<PlaybackContext.Provider value={value}>
|
||||
{children}
|
||||
</PlaybackContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,146 +8,158 @@ import { Slider } from "~/components/ui/slider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface PlaybackPlayerProps {
|
||||
src: string;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const {
|
||||
currentTime,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
togglePlay,
|
||||
play,
|
||||
pause
|
||||
} = usePlayback();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const {
|
||||
currentTime,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
togglePlay,
|
||||
play,
|
||||
pause,
|
||||
} = usePlayback();
|
||||
|
||||
const [isBuffering, setIsBuffering] = React.useState(true);
|
||||
const [volume, setVolume] = React.useState(1);
|
||||
const [muted, setMuted] = React.useState(false);
|
||||
const [isBuffering, setIsBuffering] = React.useState(true);
|
||||
const [volume, setVolume] = React.useState(1);
|
||||
const [muted, setMuted] = React.useState(false);
|
||||
|
||||
// Sync Play/Pause state
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
// Sync Play/Pause state
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying && video.paused) {
|
||||
video.play().catch(console.error);
|
||||
} else if (!isPlaying && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
if (isPlaying && video.paused) {
|
||||
video.play().catch(console.error);
|
||||
} else if (!isPlaying && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// Sync Playback Rate
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = playbackRate;
|
||||
}
|
||||
}, [playbackRate]);
|
||||
// Sync Playback Rate
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = playbackRate;
|
||||
}
|
||||
}, [playbackRate]);
|
||||
|
||||
// Sync Seek (External seek request)
|
||||
// Note: This is tricky because normal playback also updates currentTime.
|
||||
// We need to differentiate between "time updated by video" and "time updated by user seek".
|
||||
// For now, we'll let the video drive the context time, and rely on the Parent/Context
|
||||
// to call a imperative sync if needed, or we implement a "seekRequest" signal in context.
|
||||
// simpler: If context time differs significantly from video time, we seek.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
// Sync Seek (External seek request)
|
||||
// Note: This is tricky because normal playback also updates currentTime.
|
||||
// We need to differentiate between "time updated by video" and "time updated by user seek".
|
||||
// For now, we'll let the video drive the context time, and rely on the Parent/Context
|
||||
// to call a imperative sync if needed, or we implement a "seekRequest" signal in context.
|
||||
// simpler: If context time differs significantly from video time, we seek.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (Math.abs(video.currentTime - currentTime) > 0.5) {
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
}, [currentTime]);
|
||||
if (Math.abs(video.currentTime - currentTime) > 0.5) {
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
}, [currentTime]);
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
setIsBuffering(false);
|
||||
}
|
||||
};
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
setIsBuffering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWaiting = () => setIsBuffering(true);
|
||||
const handlePlaying = () => setIsBuffering(false);
|
||||
const handleEnded = () => pause();
|
||||
const handleWaiting = () => setIsBuffering(true);
|
||||
const handlePlaying = () => setIsBuffering(false);
|
||||
const handleEnded = () => pause();
|
||||
|
||||
return (
|
||||
<div className="group relative rounded-lg overflow-hidden border bg-black shadow-sm">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
muted={muted}
|
||||
className="w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onWaiting={handleWaiting}
|
||||
onPlaying={handlePlaying}
|
||||
onEnded={handleEnded}
|
||||
/>
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-lg border bg-black shadow-sm">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
muted={muted}
|
||||
className="h-full w-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onWaiting={handleWaiting}
|
||||
onPlaying={handlePlaying}
|
||||
onEnded={handleEnded}
|
||||
/>
|
||||
|
||||
{/* Overlay Controls (Visible on Hover/Pause) */}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 opacity-0 transition-opacity group-hover:opacity-100 data-[paused=true]:opacity-100" data-paused={!isPlaying}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6 fill-current" />}
|
||||
</Button>
|
||||
{/* Overlay Controls (Visible on Hover/Pause) */}
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 opacity-0 transition-opacity group-hover:opacity-100 data-[paused=true]:opacity-100"
|
||||
data-paused={!isPlaying}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-6 w-6" />
|
||||
) : (
|
||||
<Play className="h-6 w-6 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
min={0}
|
||||
max={videoRef.current?.duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={([val]) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = val ?? 0;
|
||||
setCurrentTime(val ?? 0);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
min={0}
|
||||
max={videoRef.current?.duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={([val]) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = val ?? 0;
|
||||
setCurrentTime(val ?? 0);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs font-mono text-white/90">
|
||||
{formatTime(currentTime)} / {formatTime(videoRef.current?.duration || 0)}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-white/90">
|
||||
{formatTime(currentTime)} /{" "}
|
||||
{formatTime(videoRef.current?.duration || 0)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => setMuted(!muted)}
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBuffering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 pointer-events-none">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-white/80" />
|
||||
</div>
|
||||
)}
|
||||
</AspectRatio>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => setMuted(!muted)}
|
||||
>
|
||||
{muted || volume === 0 ? (
|
||||
<VolumeX className="h-5 w-5" />
|
||||
) : (
|
||||
<Volume2 className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{isBuffering && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-white/80" />
|
||||
</div>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
@@ -4,200 +4,229 @@ 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";
|
||||
import {
|
||||
Flag,
|
||||
CheckCircle,
|
||||
Bot,
|
||||
User,
|
||||
MessageSquare,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
|
||||
interface TimelineEvent {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface HorizontalTimelineProps {
|
||||
events: TimelineEvent[];
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
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 };
|
||||
};
|
||||
export function HorizontalTimeline({
|
||||
events,
|
||||
startTime,
|
||||
endTime,
|
||||
}: HorizontalTimelineProps) {
|
||||
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (events.length === 0) {
|
||||
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%)' }} />
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
No events recorded yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* 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>
|
||||
// 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;
|
||||
|
||||
{/* 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" />
|
||||
// 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
|
||||
|
||||
{events.map((event, i) => {
|
||||
const pos = getPosition(event.timestamp);
|
||||
const { color, Icon } = getEventStyle(event.type);
|
||||
const isSelected = selectedEvent === event;
|
||||
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;
|
||||
};
|
||||
|
||||
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' : ''}
|
||||
`} />
|
||||
const timeMarkers = getTimeMarkers();
|
||||
|
||||
{/* 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>
|
||||
// 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 mb-8 h-20">
|
||||
{/* Main horizontal line */}
|
||||
<div
|
||||
className="bg-border absolute top-1/2 right-0 left-0 h-0.5"
|
||||
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-muted-foreground mb-2 font-mono text-[10px]">
|
||||
{marker.toLocaleTimeString([], {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-border h-4 w-px" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
{/* Event markers */}
|
||||
<div className="relative h-40">
|
||||
{/* Timeline line for events */}
|
||||
<div className="bg-border absolute top-20 right-0 left-0 h-0.5" />
|
||||
|
||||
{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="group flex cursor-pointer flex-col items-center gap-1"
|
||||
title={event.message || event.type}
|
||||
>
|
||||
{/* Vertical dash */}
|
||||
<div
|
||||
className={`h-20 w-1 ${color} rounded-full transition-all group-hover:w-1.5 ${isSelected ? "ring-offset-background ring-primary w-1.5 ring-2 ring-offset-2" : ""} `}
|
||||
/>
|
||||
|
||||
{/* Icon indicator */}
|
||||
<div
|
||||
className={`rounded-full p-1.5 ${color} bg-opacity-20 group-hover:bg-opacity-30 transition-all ${isSelected ? "ring-primary bg-opacity-40 ring-2" : ""} `}
|
||||
>
|
||||
<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-muted-foreground font-mono text-xs">
|
||||
{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="text-muted-foreground hover:text-foreground cursor-pointer">
|
||||
Event data
|
||||
</summary>
|
||||
<pre className="bg-muted mt-2 overflow-auto rounded p-2 text-[10px]">
|
||||
{JSON.stringify(selectedEvent.data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
@@ -6,7 +5,21 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import Link from "next/link";
|
||||
import { LineChart, BarChart, Printer, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
LineChart,
|
||||
BarChart,
|
||||
Printer,
|
||||
Clock,
|
||||
Database,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
VideoOff,
|
||||
Info,
|
||||
Bot,
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { PlaybackProvider } from "../playback/PlaybackContext";
|
||||
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
|
||||
@@ -15,336 +28,420 @@ import { api } from "~/trpc/react";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/ui/resizable";
|
||||
import { EventsDataTable } from "../analysis/events-data-table";
|
||||
|
||||
interface TrialAnalysisViewProps {
|
||||
trial: {
|
||||
id: string;
|
||||
status: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
experiment: { name: string; studyId: string };
|
||||
participant: { participantCode: string };
|
||||
eventCount?: number;
|
||||
mediaCount?: number;
|
||||
media?: { url: string; mediaType: string; format?: string; contentType?: string }[];
|
||||
};
|
||||
backHref: string;
|
||||
trial: {
|
||||
id: string;
|
||||
status: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
experiment: { name: string; studyId: string };
|
||||
participant: { participantCode: string };
|
||||
eventCount?: number;
|
||||
mediaCount?: number;
|
||||
media?: {
|
||||
url: string;
|
||||
mediaType: string;
|
||||
format?: string;
|
||||
contentType?: string;
|
||||
}[];
|
||||
};
|
||||
backHref: string;
|
||||
}
|
||||
|
||||
export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
// Fetch events for timeline
|
||||
const { data: events = [] } = api.trials.getEvents.useQuery({
|
||||
trialId: trial.id,
|
||||
limit: 1000
|
||||
}, {
|
||||
refetchInterval: 5000
|
||||
});
|
||||
// Fetch events for timeline
|
||||
const { data: events = [] } = api.trials.getEvents.useQuery(
|
||||
{
|
||||
trialId: trial.id,
|
||||
limit: 1000,
|
||||
},
|
||||
{
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-print effect
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.get('export') === 'true') {
|
||||
// Small delay to ensure rendering
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 1000);
|
||||
}
|
||||
}, []);
|
||||
// Auto-print effect
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.get("export") === "true") {
|
||||
// Small delay to ensure rendering
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 1000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const videoMedia = trial.media?.find(m => m.mediaType === "video" || (m as any).contentType?.startsWith("video/"));
|
||||
const videoUrl = videoMedia?.url;
|
||||
const videoMedia = trial.media?.find(
|
||||
(m) =>
|
||||
m.mediaType === "video" || (m as any).contentType?.startsWith("video/"),
|
||||
);
|
||||
const videoUrl = videoMedia?.url;
|
||||
|
||||
// Metrics
|
||||
const interventionCount = events.filter(e => e.eventType.includes("intervention")).length;
|
||||
const errorCount = events.filter(e => e.eventType.includes("error")).length;
|
||||
const robotActionCount = events.filter(e => e.eventType.includes("robot_action")).length;
|
||||
// Metrics
|
||||
const interventionCount = events.filter((e) =>
|
||||
e.eventType.includes("intervention"),
|
||||
).length;
|
||||
const errorCount = events.filter((e) => e.eventType.includes("error")).length;
|
||||
const robotActionCount = events.filter((e) =>
|
||||
e.eventType.includes("robot_action"),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
||||
<div id="trial-analysis-content" className="flex h-full flex-col gap-2 p-3 text-sm">
|
||||
{/* Header Context */}
|
||||
<PageHeader
|
||||
title={trial.experiment.name}
|
||||
description={`Session ${trial.id.slice(0, 8)} • ${trial.startedAt?.toLocaleDateString() ?? 'Unknown Date'} ${trial.startedAt?.toLocaleTimeString() ?? ''}`}
|
||||
badges={[
|
||||
{
|
||||
label: trial.status.toUpperCase(),
|
||||
variant: trial.status === 'completed' ? 'default' : 'secondary',
|
||||
className: trial.status === 'completed' ? 'bg-green-500 hover:bg-green-600' : ''
|
||||
}
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<style jsx global>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 15mm;
|
||||
}
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
/* Hide everything by default */
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* Show only our content */
|
||||
#trial-analysis-content, #trial-analysis-content * {
|
||||
visibility: visible;
|
||||
}
|
||||
#trial-analysis-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Hide specific non-printable elements */
|
||||
#tour-trial-video,
|
||||
button,
|
||||
.no-print,
|
||||
[role="dialog"],
|
||||
header,
|
||||
nav {
|
||||
display: none !important;
|
||||
}
|
||||
return (
|
||||
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
||||
<div
|
||||
id="trial-analysis-content"
|
||||
className="flex h-full flex-col gap-2 p-3 text-sm"
|
||||
>
|
||||
{/* Header Context */}
|
||||
<PageHeader
|
||||
title={trial.experiment.name}
|
||||
description={`Session ${trial.id.slice(0, 8)} • ${trial.startedAt?.toLocaleDateString() ?? "Unknown Date"} ${trial.startedAt?.toLocaleTimeString() ?? ""}`}
|
||||
badges={[
|
||||
{
|
||||
label: trial.status.toUpperCase(),
|
||||
variant: trial.status === "completed" ? "default" : "secondary",
|
||||
className:
|
||||
trial.status === "completed"
|
||||
? "bg-green-500 hover:bg-green-600"
|
||||
: "",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<style jsx global>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 15mm;
|
||||
}
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
/* Hide everything by default */
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* Show only our content */
|
||||
#trial-analysis-content,
|
||||
#trial-analysis-content * {
|
||||
visibility: visible;
|
||||
}
|
||||
#trial-analysis-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Adjust Metrics for Print */
|
||||
#tour-trial-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
#tour-trial-metrics .rounded-xl {
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Hide specific non-printable elements */
|
||||
#tour-trial-video,
|
||||
button,
|
||||
.no-print,
|
||||
[role="dialog"],
|
||||
header,
|
||||
nav {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Expand Timeline */
|
||||
.h-28 {
|
||||
height: 120px !important;
|
||||
page-break-inside: avoid;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Adjust Metrics for Print */
|
||||
#tour-trial-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
#tour-trial-metrics .rounded-xl {
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Remove Panel Resizing constraints */
|
||||
[data-panel-group-direction="vertical"] {
|
||||
flex-direction: column !important;
|
||||
display: block !important;
|
||||
height: auto !important;
|
||||
}
|
||||
[data-panel] {
|
||||
flex: none !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
[data-panel-resize-handle] {
|
||||
display: none !important;
|
||||
}
|
||||
/* Expand Timeline */
|
||||
.h-28 {
|
||||
height: 120px !important;
|
||||
page-break-inside: avoid;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Table Styles: Clean & Full Width */
|
||||
#tour-trial-events {
|
||||
display: block !important;
|
||||
border: none !important;
|
||||
height: auto !important;
|
||||
}
|
||||
#tour-trial-events [data-radix-scroll-area-viewport] {
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
}
|
||||
/* Hide "Filter" input wrapper if visible */
|
||||
#tour-trial-events .border-b {
|
||||
border-bottom: 2px solid #000 !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Export Report
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
/* Remove Panel Resizing constraints */
|
||||
[data-panel-group-direction="vertical"] {
|
||||
flex-direction: column !important;
|
||||
display: block !important;
|
||||
height: auto !important;
|
||||
}
|
||||
[data-panel] {
|
||||
flex: none !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
[data-panel-resize-handle] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
{/* Top Section: Metrics & Optional Video Grid */}
|
||||
<div className="flex flex-col xl:flex-row gap-3 shrink-0">
|
||||
<Card id="tour-trial-metrics" className="shadow-sm flex-1">
|
||||
<CardContent className="p-0 h-full">
|
||||
<div className="grid grid-cols-2 grid-rows-2 h-full divide-x divide-y">
|
||||
<div className="flex flex-col p-4 md:p-6 justify-center">
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
|
||||
<Clock className="h-4 w-4 text-blue-500" /> Duration
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{trial.duration ? <span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> : "--:--"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col p-4 md:p-6 justify-center border-t-0">
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
|
||||
<Bot className="h-4 w-4 text-purple-500" /> Robot Actions
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{robotActionCount}</p>
|
||||
</div>
|
||||
<div className="flex flex-col p-4 md:p-6 justify-center">
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" /> Interventions
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{interventionCount}</p>
|
||||
</div>
|
||||
<div className="flex flex-col p-4 md:p-6 justify-center">
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
|
||||
<Activity className="h-4 w-4 text-green-500" /> Completeness
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-2xl font-bold">
|
||||
<span className={cn(
|
||||
"inline-block h-3 w-3 rounded-full",
|
||||
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
|
||||
)} />
|
||||
{trial.status === 'completed' ? '100%' : 'Incomplete'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{videoUrl && (
|
||||
<Card id="tour-trial-video" className="shadow-sm w-full xl:w-[500px] overflow-hidden shrink-0 bg-black/5 dark:bg-black/40 border">
|
||||
<div className="aspect-video w-full h-full relative flex items-center justify-center bg-black">
|
||||
<div className="absolute inset-0">
|
||||
<PlaybackPlayer src={videoUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Workspace: Vertical Layout */}
|
||||
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background flex flex-col">
|
||||
|
||||
{/* FIXED TIMELINE: Always visible at top */}
|
||||
<div id="tour-trial-timeline" className="shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-1">
|
||||
<EventTimeline />
|
||||
</div>
|
||||
|
||||
{/* BOTTOM: Events Table */}
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-background" id="tour-trial-events">
|
||||
<Tabs defaultValue="events" className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0 bg-muted/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="events" className="text-xs">All Events</TabsTrigger>
|
||||
<TabsTrigger value="observations" className="text-xs">Observations ({events.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note').length})</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Filter..."
|
||||
className="h-7 w-[150px] text-xs"
|
||||
disabled
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<Badge variant="outline" className="text-[10px] font-normal">{events.length} Total</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="events" className="flex-1 min-h-0 mt-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-0">
|
||||
<EventsDataTable
|
||||
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
|
||||
startTime={trial.startedAt ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="observations" className="flex-1 min-h-0 mt-0 bg-muted/5">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-3 max-w-2xl mx-auto">
|
||||
{events.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note').length > 0 ? (
|
||||
events
|
||||
.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note')
|
||||
.map((e, i) => {
|
||||
const data = e.data as any;
|
||||
return (
|
||||
<Card key={i} className="border shadow-none">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between space-y-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
|
||||
{data?.category || "Note"}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{trial.startedAt ? formatTime(new Date(e.timestamp).getTime() - new Date(trial.startedAt).getTime()) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(e.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-2">
|
||||
<p className="text-sm">
|
||||
{data?.description || data?.note || data?.message || "No content"}
|
||||
</p>
|
||||
{data?.tags && data.tags.length > 0 && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{data.tags.map((t: string, ti: number) => (
|
||||
<Badge key={ti} variant="secondary" className="text-[10px] h-5 px-1.5">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
<Info className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||
No observations recorded for this session.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
/* Table Styles: Clean & Full Width */
|
||||
#tour-trial-events {
|
||||
display: block !important;
|
||||
border: none !important;
|
||||
height: auto !important;
|
||||
}
|
||||
#tour-trial-events [data-radix-scroll-area-viewport] {
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
}
|
||||
/* Hide "Filter" input wrapper if visible */
|
||||
#tour-trial-events .border-b {
|
||||
border-bottom: 2px solid #000 !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Export Report
|
||||
</Button>
|
||||
</div>
|
||||
</PlaybackProvider>
|
||||
);
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Top Section: Metrics & Optional Video Grid */}
|
||||
<div className="flex shrink-0 flex-col gap-3 xl:flex-row">
|
||||
<Card id="tour-trial-metrics" className="flex-1 shadow-sm">
|
||||
<CardContent className="h-full p-0">
|
||||
<div className="grid h-full grid-cols-2 grid-rows-2 divide-x divide-y">
|
||||
<div className="flex flex-col justify-center p-4 md:p-6">
|
||||
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||
<Clock className="h-4 w-4 text-blue-500" /> Duration
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{trial.duration ? (
|
||||
<span>
|
||||
{Math.floor(trial.duration / 60)}m {trial.duration % 60}
|
||||
s
|
||||
</span>
|
||||
) : (
|
||||
"--:--"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center border-t-0 p-4 md:p-6">
|
||||
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||
<Bot className="h-4 w-4 text-purple-500" /> Robot Actions
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{robotActionCount}</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center p-4 md:p-6">
|
||||
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />{" "}
|
||||
Interventions
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{interventionCount}</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center p-4 md:p-6">
|
||||
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||
<Activity className="h-4 w-4 text-green-500" /> Completeness
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-2xl font-bold">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3 w-3 rounded-full",
|
||||
trial.status === "completed"
|
||||
? "bg-green-500"
|
||||
: "bg-yellow-500",
|
||||
)}
|
||||
/>
|
||||
{trial.status === "completed" ? "100%" : "Incomplete"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{videoUrl && (
|
||||
<Card
|
||||
id="tour-trial-video"
|
||||
className="w-full shrink-0 overflow-hidden border bg-black/5 shadow-sm xl:w-[500px] dark:bg-black/40"
|
||||
>
|
||||
<div className="relative flex aspect-video h-full w-full items-center justify-center bg-black">
|
||||
<div className="absolute inset-0">
|
||||
<PlaybackPlayer src={videoUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Workspace: Vertical Layout */}
|
||||
<div className="bg-background flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border shadow-sm">
|
||||
{/* FIXED TIMELINE: Always visible at top */}
|
||||
<div
|
||||
id="tour-trial-timeline"
|
||||
className="bg-background/95 supports-[backdrop-filter]:bg-background/60 shrink-0 border-b p-1 backdrop-blur"
|
||||
>
|
||||
<EventTimeline />
|
||||
</div>
|
||||
|
||||
{/* BOTTOM: Events Table */}
|
||||
<div
|
||||
className="bg-background flex min-h-0 flex-1 flex-col"
|
||||
id="tour-trial-events"
|
||||
>
|
||||
<Tabs defaultValue="events" className="flex h-full flex-col">
|
||||
<div className="bg-muted/10 flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="events" className="text-xs">
|
||||
All Events
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="observations" className="text-xs">
|
||||
Observations (
|
||||
{
|
||||
events.filter(
|
||||
(e) =>
|
||||
e.eventType.startsWith("annotation") ||
|
||||
e.eventType === "wizard_note",
|
||||
).length
|
||||
}
|
||||
)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Filter..."
|
||||
className="h-7 w-[150px] text-xs"
|
||||
disabled
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{events.length} Total
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="events" className="mt-0 min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-0">
|
||||
<EventsDataTable
|
||||
data={events.map((e) => ({
|
||||
...e,
|
||||
timestamp: new Date(e.timestamp),
|
||||
}))}
|
||||
startTime={trial.startedAt ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="observations"
|
||||
className="bg-muted/5 mt-0 min-h-0 flex-1"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-2xl space-y-3 p-4">
|
||||
{events.filter(
|
||||
(e) =>
|
||||
e.eventType.startsWith("annotation") ||
|
||||
e.eventType === "wizard_note",
|
||||
).length > 0 ? (
|
||||
events
|
||||
.filter(
|
||||
(e) =>
|
||||
e.eventType.startsWith("annotation") ||
|
||||
e.eventType === "wizard_note",
|
||||
)
|
||||
.map((e, i) => {
|
||||
const data = e.data as any;
|
||||
return (
|
||||
<Card key={i} className="border shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-3 pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-yellow-200 bg-yellow-50 text-yellow-700"
|
||||
>
|
||||
{data?.category || "Note"}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{trial.startedAt
|
||||
? formatTime(
|
||||
new Date(e.timestamp).getTime() -
|
||||
new Date(trial.startedAt).getTime(),
|
||||
)
|
||||
: "--:--"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{new Date(e.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-2">
|
||||
<p className="text-sm">
|
||||
{data?.description ||
|
||||
data?.note ||
|
||||
data?.message ||
|
||||
"No content"}
|
||||
</p>
|
||||
{data?.tags && data.tags.length > 0 && (
|
||||
<div className="mt-2 flex gap-1">
|
||||
{data.tags.map((t: string, ti: number) => (
|
||||
<Badge
|
||||
key={ti}
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px]"
|
||||
>
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
<Info className="mx-auto mb-2 h-8 w-8 opacity-20" />
|
||||
No observations recorded for this session.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlaybackProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper specific to this file if needed, otherwise ignore.
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
function formatTime(ms: number) {
|
||||
if (ms < 0) return "0:00";
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
if (ms < 0) return "0:00";
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -255,10 +255,10 @@ export function RobotActionsPanel({
|
||||
// Look for ROS2 configuration in the action definition
|
||||
const actionConfig = (actionDef as any).ros2
|
||||
? {
|
||||
topic: (actionDef as any).ros2.topic,
|
||||
messageType: (actionDef as any).ros2.messageType,
|
||||
payloadMapping: (actionDef as any).ros2.payloadMapping,
|
||||
}
|
||||
topic: (actionDef as any).ros2.topic,
|
||||
messageType: (actionDef as any).ros2.messageType,
|
||||
payloadMapping: (actionDef as any).ros2.payloadMapping,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await executeRosAction(
|
||||
@@ -635,7 +635,7 @@ export function RobotActionsPanel({
|
||||
<CardContent className="space-y-4">
|
||||
{/* Parameters */}
|
||||
{selectedAction.parameters &&
|
||||
selectedAction.parameters.length > 0 ? (
|
||||
selectedAction.parameters.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base">Parameters</Label>
|
||||
{selectedAction.parameters.map((param, index) =>
|
||||
@@ -662,9 +662,9 @@ export function RobotActionsPanel({
|
||||
className="w-full"
|
||||
>
|
||||
{selectedPluginData &&
|
||||
executingActions.has(
|
||||
`${selectedPluginData.plugin.name}.${selectedAction.id}`,
|
||||
) ? (
|
||||
executingActions.has(
|
||||
`${selectedPluginData.plugin.name}.${selectedAction.id}`,
|
||||
) ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Executing...
|
||||
@@ -962,7 +962,7 @@ export function RobotActionsPanel({
|
||||
<CardContent className="space-y-4">
|
||||
{/* Parameters */}
|
||||
{selectedAction?.parameters &&
|
||||
(selectedAction?.parameters?.length ?? 0) > 0 ? (
|
||||
(selectedAction?.parameters?.length ?? 0) > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base">Parameters</Label>
|
||||
{selectedAction?.parameters?.map((param, index) =>
|
||||
@@ -990,10 +990,10 @@ export function RobotActionsPanel({
|
||||
className="w-full"
|
||||
>
|
||||
{selectedPluginData &&
|
||||
selectedAction &&
|
||||
executingActions.has(
|
||||
`${selectedPluginData?.plugin.name}.${selectedAction?.id}`,
|
||||
) ? (
|
||||
selectedAction &&
|
||||
executingActions.has(
|
||||
`${selectedPluginData?.plugin.name}.${selectedAction?.id}`,
|
||||
) ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Executing...
|
||||
|
||||
@@ -1,273 +1,315 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||
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 {
|
||||
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;
|
||||
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>;
|
||||
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;
|
||||
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,
|
||||
open,
|
||||
onOpenChange,
|
||||
studyId,
|
||||
pluginId,
|
||||
settingsSchema,
|
||||
}: RobotSettingsModalProps) {
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
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 }
|
||||
// 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}`);
|
||||
},
|
||||
});
|
||||
// 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>);
|
||||
}
|
||||
});
|
||||
// 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 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));
|
||||
};
|
||||
|
||||
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;
|
||||
// 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-muted-foreground text-xs">
|
||||
{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="flex-1 space-y-0.5">
|
||||
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||
{schema.description && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{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-muted-foreground text-xs">
|
||||
{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-muted-foreground text-xs">
|
||||
{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 (
|
||||
<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>
|
||||
<div key={fullPath} className="space-y-2">
|
||||
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||
{schema.description && (
|
||||
<p className="text-muted-foreground text-xs">{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-h-[80vh] max-w-2xl 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="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</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);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Play,
|
||||
Target,
|
||||
Users,
|
||||
SkipForward
|
||||
SkipForward,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
@@ -22,10 +22,10 @@ interface TrialProgressProps {
|
||||
id: string;
|
||||
name: string;
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
description?: string;
|
||||
duration?: number;
|
||||
parameters?: Record<string, unknown>;
|
||||
@@ -118,7 +118,8 @@ export function TrialProgress({
|
||||
return "pending";
|
||||
|
||||
// Default fallback if jumping around without explicitly adding to sets
|
||||
if (index < currentStepIndex && !skippedSteps.has(index)) return "completed";
|
||||
if (index < currentStepIndex && !skippedSteps.has(index))
|
||||
return "completed";
|
||||
|
||||
return "upcoming";
|
||||
};
|
||||
@@ -211,12 +212,13 @@ export function TrialProgress({
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
className={`h-2 ${trialStatus === "completed"
|
||||
? "bg-green-100"
|
||||
: trialStatus === "aborted" || trialStatus === "failed"
|
||||
? "bg-red-100"
|
||||
: "bg-blue-100"
|
||||
}`}
|
||||
className={`h-2 ${
|
||||
trialStatus === "completed"
|
||||
? "bg-green-100"
|
||||
: trialStatus === "aborted" || trialStatus === "failed"
|
||||
? "bg-red-100"
|
||||
: "bg-blue-100"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>Start</span>
|
||||
@@ -255,47 +257,51 @@ export function TrialProgress({
|
||||
{/* Connection Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`absolute top-12 left-6 h-6 w-0.5 ${getStepStatus(index + 1) === "completed" ||
|
||||
className={`absolute top-12 left-6 h-6 w-0.5 ${
|
||||
getStepStatus(index + 1) === "completed" ||
|
||||
(getStepStatus(index + 1) === "active" &&
|
||||
status === "completed")
|
||||
? "bg-green-300"
|
||||
: "bg-slate-300"
|
||||
}`}
|
||||
? "bg-green-300"
|
||||
: "bg-slate-300"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step Card */}
|
||||
<div
|
||||
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${status === "active"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
||||
: status === "completed"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: status === "aborted"
|
||||
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${
|
||||
status === "active"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
||||
: status === "completed"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: "border-slate-200 bg-slate-50"
|
||||
}`}
|
||||
: status === "aborted"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: "border-slate-200 bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
{/* Step Number & Status */}
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<div
|
||||
className={`flex h-8 w-12 items-center justify-center rounded-lg ${status === "active"
|
||||
? statusConfig.bgColor
|
||||
: status === "completed"
|
||||
? "bg-green-100"
|
||||
: status === "aborted"
|
||||
? "bg-red-100"
|
||||
: "bg-slate-100"
|
||||
}`}
|
||||
className={`flex h-8 w-12 items-center justify-center rounded-lg ${
|
||||
status === "active"
|
||||
? statusConfig.bgColor
|
||||
: status === "completed"
|
||||
? "bg-green-100"
|
||||
: status === "aborted"
|
||||
? "bg-red-100"
|
||||
: "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${status === "active"
|
||||
? statusConfig.textColor
|
||||
: status === "completed"
|
||||
? "text-green-700"
|
||||
: status === "aborted"
|
||||
? "text-red-700"
|
||||
: "text-slate-600"
|
||||
}`}
|
||||
className={`text-sm font-medium ${
|
||||
status === "active"
|
||||
? statusConfig.textColor
|
||||
: status === "completed"
|
||||
? "text-green-700"
|
||||
: status === "aborted"
|
||||
? "text-red-700"
|
||||
: "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
@@ -312,14 +318,15 @@ export function TrialProgress({
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h5
|
||||
className={`truncate font-medium ${status === "active"
|
||||
? "text-slate-900"
|
||||
: status === "completed"
|
||||
? "text-green-900"
|
||||
: status === "aborted"
|
||||
? "text-red-900"
|
||||
: "text-slate-700"
|
||||
}`}
|
||||
className={`truncate font-medium ${
|
||||
status === "active"
|
||||
? "text-slate-900"
|
||||
: status === "completed"
|
||||
? "text-green-900"
|
||||
: status === "aborted"
|
||||
? "text-red-900"
|
||||
: "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</h5>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Pause,
|
||||
SkipForward
|
||||
SkipForward,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "~/lib/utils";
|
||||
@@ -78,11 +78,7 @@ interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional";
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
nextStepId?: string;
|
||||
@@ -91,7 +87,13 @@ interface StepData {
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
variant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
@@ -112,7 +114,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const router = useRouter();
|
||||
|
||||
// UI State
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||
"current" | "timeline" | "events"
|
||||
>("timeline");
|
||||
|
||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||
@@ -189,11 +193,14 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
toast.success(`Robot action completed: ${execution.actionId}`);
|
||||
}, []);
|
||||
|
||||
const onActionFailed = useCallback((execution: { actionId: string; error?: string }) => {
|
||||
toast.error(`Robot action failed: ${execution.actionId}`, {
|
||||
description: execution.error,
|
||||
});
|
||||
}, []);
|
||||
const onActionFailed = useCallback(
|
||||
(execution: { actionId: string; error?: string }) => {
|
||||
toast.error(`Robot action failed: ${execution.actionId}`, {
|
||||
description: execution.error,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ROS WebSocket connection for robot control
|
||||
const {
|
||||
@@ -218,7 +225,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
async (enabled: boolean) => {
|
||||
return setAutonomousLifeRaw(enabled);
|
||||
},
|
||||
[setAutonomousLifeRaw]
|
||||
[setAutonomousLifeRaw],
|
||||
);
|
||||
|
||||
// Use polling for trial status updates (no trial WebSocket server exists)
|
||||
@@ -237,7 +244,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
{
|
||||
refetchInterval: 3000,
|
||||
staleTime: 1000,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update local trial state from polling only if changed
|
||||
@@ -245,15 +252,18 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
if (pollingData && JSON.stringify(pollingData) !== JSON.stringify(trial)) {
|
||||
// Only update if specific fields we care about have changed to avoid
|
||||
// unnecessary re-renders that might cause UI flashing
|
||||
if (pollingData.status !== trial.status ||
|
||||
if (
|
||||
pollingData.status !== trial.status ||
|
||||
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
|
||||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
|
||||
|
||||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()
|
||||
) {
|
||||
setTrial((prev) => {
|
||||
// Double check inside setter to be safe
|
||||
if (prev.status === pollingData.status &&
|
||||
if (
|
||||
prev.status === pollingData.status &&
|
||||
prev.startedAt?.getTime() === pollingData.startedAt?.getTime() &&
|
||||
prev.completedAt?.getTime() === pollingData.completedAt?.getTime()) {
|
||||
prev.completedAt?.getTime() === pollingData.completedAt?.getTime()
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
@@ -288,60 +298,80 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
message?: string;
|
||||
}>
|
||||
>(() => {
|
||||
return (fetchedEvents ?? []).map(event => {
|
||||
let message: string | undefined;
|
||||
const eventData = event.data as any;
|
||||
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, ' ');
|
||||
}
|
||||
// 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
|
||||
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[] = useMemo(() =>
|
||||
experimentSteps?.map((step, index) => ({
|
||||
id: step.id,
|
||||
name: step.name ?? `Step ${index + 1}`,
|
||||
description: step.description,
|
||||
type: mapStepType(step.type),
|
||||
// Fix: Conditions are at root level from API
|
||||
conditions: (step as any).conditions ?? (step as any).trigger?.conditions ?? undefined,
|
||||
parameters: step.parameters ?? {},
|
||||
order: step.order ?? index,
|
||||
actions: step.actions?.filter(a => a.type !== 'branch').map((action) => ({
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
type: action.type,
|
||||
parameters: action.parameters ?? {},
|
||||
order: action.order,
|
||||
pluginId: action.pluginId,
|
||||
const steps: StepData[] = useMemo(
|
||||
() =>
|
||||
experimentSteps?.map((step, index) => ({
|
||||
id: step.id,
|
||||
name: step.name ?? `Step ${index + 1}`,
|
||||
description: step.description,
|
||||
type: mapStepType(step.type),
|
||||
// Fix: Conditions are at root level from API
|
||||
conditions:
|
||||
(step as any).conditions ??
|
||||
(step as any).trigger?.conditions ??
|
||||
undefined,
|
||||
parameters: step.parameters ?? {},
|
||||
order: step.order ?? index,
|
||||
actions:
|
||||
step.actions
|
||||
?.filter((a) => a.type !== "branch")
|
||||
.map((action) => ({
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
type: action.type,
|
||||
parameters: action.parameters ?? {},
|
||||
order: action.order,
|
||||
pluginId: action.pluginId,
|
||||
})) ?? [],
|
||||
})) ?? [],
|
||||
})) ?? [], [experimentSteps]);
|
||||
|
||||
[experimentSteps],
|
||||
);
|
||||
|
||||
const currentStep = steps[currentStepIndex] ?? null;
|
||||
const totalSteps = steps.length;
|
||||
@@ -416,7 +446,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
completedAt: data.completedAt,
|
||||
});
|
||||
toast.success("Trial completed! Redirecting to analysis...");
|
||||
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
|
||||
router.push(
|
||||
`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -472,8 +504,6 @@ 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,
|
||||
@@ -506,7 +536,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type: "trial_resumed",
|
||||
data: { timestamp: new Date() }
|
||||
data: { timestamp: new Date() },
|
||||
});
|
||||
setIsPaused(false);
|
||||
toast.success("Trial resumed");
|
||||
@@ -517,7 +547,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
|
||||
const handleNextStep = (targetIndex?: number) => {
|
||||
// If explicit target provided (from branching choice), use it
|
||||
if (typeof targetIndex === 'number') {
|
||||
if (typeof targetIndex === "number") {
|
||||
// Find step by index to ensure safety
|
||||
if (targetIndex >= 0 && targetIndex < steps.length) {
|
||||
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
|
||||
@@ -531,8 +561,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
toIndex: targetIndex,
|
||||
fromStepId: steps[currentStepIndex]?.id,
|
||||
toStepId: steps[targetIndex]?.id,
|
||||
reason: "manual_choice"
|
||||
}
|
||||
reason: "manual_choice",
|
||||
},
|
||||
});
|
||||
|
||||
setCompletedActionsCount(0);
|
||||
@@ -546,13 +576,23 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const currentStep = steps[currentStepIndex];
|
||||
|
||||
// Check if we have a stored response that dictates the next step
|
||||
if (currentStep?.type === 'conditional' && currentStep.conditions?.options && lastResponse) {
|
||||
const matchedOption = currentStep.conditions.options.find(opt => opt.value === lastResponse);
|
||||
if (
|
||||
currentStep?.type === "conditional" &&
|
||||
currentStep.conditions?.options &&
|
||||
lastResponse
|
||||
) {
|
||||
const matchedOption = currentStep.conditions.options.find(
|
||||
(opt) => opt.value === lastResponse,
|
||||
);
|
||||
if (matchedOption && matchedOption.nextStepId) {
|
||||
// Find index of the target step
|
||||
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
|
||||
const targetIndex = steps.findIndex(
|
||||
(s) => s.id === matchedOption.nextStepId,
|
||||
);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
|
||||
console.log(
|
||||
`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`,
|
||||
);
|
||||
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
@@ -561,8 +601,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
fromIndex: currentStepIndex,
|
||||
toIndex: targetIndex,
|
||||
condition: matchedOption.label,
|
||||
value: lastResponse
|
||||
}
|
||||
value: lastResponse,
|
||||
},
|
||||
});
|
||||
|
||||
setCurrentStepIndex(targetIndex);
|
||||
@@ -573,12 +613,17 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}
|
||||
|
||||
// Check for explicit nextStepId in conditions (e.g. for end of branch)
|
||||
console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions);
|
||||
console.log(
|
||||
"[WizardInterface] Checking for nextStepId condition:",
|
||||
currentStep?.conditions,
|
||||
);
|
||||
if (currentStep?.conditions?.nextStepId) {
|
||||
const nextId = String(currentStep.conditions.nextStepId);
|
||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||
const targetIndex = steps.findIndex((s) => s.id === nextId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
|
||||
console.log(
|
||||
`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`,
|
||||
);
|
||||
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
@@ -586,12 +631,12 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
data: {
|
||||
fromIndex: currentStepIndex,
|
||||
toIndex: targetIndex,
|
||||
reason: "condition_next_step"
|
||||
}
|
||||
reason: "condition_next_step",
|
||||
},
|
||||
});
|
||||
|
||||
// Mark steps as skipped
|
||||
setSkippedSteps(prev => {
|
||||
setSkippedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (let i = currentStepIndex + 1; i < targetIndex; i++) {
|
||||
if (!completedSteps.has(i)) {
|
||||
@@ -602,7 +647,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
});
|
||||
|
||||
// Mark current as complete
|
||||
setCompletedSteps(prev => {
|
||||
setCompletedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(currentStepIndex);
|
||||
return next;
|
||||
@@ -612,17 +657,21 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
setCompletedActionsCount(0);
|
||||
return;
|
||||
} else {
|
||||
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`);
|
||||
console.warn(
|
||||
`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly.");
|
||||
console.log(
|
||||
"[WizardInterface] No nextStepId found in conditions, proceeding linearly.",
|
||||
);
|
||||
}
|
||||
|
||||
// Default: Linear progression
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex < steps.length) {
|
||||
// Mark current step as complete
|
||||
setCompletedSteps(prev => {
|
||||
setCompletedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(currentStepIndex);
|
||||
return next;
|
||||
@@ -638,8 +687,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
fromStepId: currentStep?.id,
|
||||
toStepId: steps[nextIndex]?.id,
|
||||
stepName: steps[nextIndex]?.name,
|
||||
method: "auto"
|
||||
}
|
||||
method: "auto",
|
||||
},
|
||||
});
|
||||
|
||||
setCurrentStepIndex(nextIndex);
|
||||
@@ -661,13 +710,13 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
fromStepId: currentStep?.id,
|
||||
toStepId: steps[index]?.id,
|
||||
stepName: steps[index]?.name,
|
||||
method: "manual"
|
||||
}
|
||||
method: "manual",
|
||||
},
|
||||
});
|
||||
|
||||
// Mark current as complete if leaving it?
|
||||
// Maybe better to only mark on "Next" or explicit complete.
|
||||
// If I jump away, I might not be done.
|
||||
// If I jump away, I might not be done.
|
||||
// I'll leave 'completedSteps' update to explicit actions or completion.
|
||||
|
||||
setCurrentStepIndex(index);
|
||||
@@ -676,7 +725,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const handleCompleteTrial = async () => {
|
||||
try {
|
||||
// Mark final step as complete
|
||||
setCompletedSteps(prev => {
|
||||
setCompletedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(currentStepIndex);
|
||||
return next;
|
||||
@@ -692,7 +741,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
archiveTrialMutation.mutate({ id: trial.id });
|
||||
|
||||
// Immediately navigate to analysis
|
||||
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
|
||||
router.push(
|
||||
`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to complete trial:", error);
|
||||
}
|
||||
@@ -701,8 +752,6 @@ 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);
|
||||
}
|
||||
@@ -731,8 +780,6 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Mutation for interventions
|
||||
const addInterventionMutation = api.trials.addIntervention.useMutation({
|
||||
onSuccess: () => toast.success("Intervention logged"),
|
||||
@@ -753,9 +800,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
// If nextStepId is provided, jump immediately
|
||||
if (parameters.nextStepId) {
|
||||
const nextId = String(parameters.nextStepId);
|
||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||
const targetIndex = steps.findIndex((s) => s.id === nextId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`);
|
||||
console.log(
|
||||
`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`,
|
||||
);
|
||||
handleNextStep(targetIndex);
|
||||
return; // Exit after jump
|
||||
}
|
||||
@@ -780,7 +829,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
await addAnnotationMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
description: String(parameters?.content || "Quick note"),
|
||||
category: String(parameters?.category || "quick_note")
|
||||
category: String(parameters?.category || "quick_note"),
|
||||
});
|
||||
} else {
|
||||
// Generic action logging - now with more details
|
||||
@@ -789,11 +838,17 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
let actionType = "unknown";
|
||||
|
||||
// Helper to search recursively
|
||||
const findAction = (actions: ActionData[], id: string): ActionData | undefined => {
|
||||
const findAction = (
|
||||
actions: ActionData[],
|
||||
id: string,
|
||||
): ActionData | undefined => {
|
||||
for (const action of actions) {
|
||||
if (action.id === id) return action;
|
||||
if (action.parameters?.children) {
|
||||
const found = findAction(action.parameters.children as ActionData[], id);
|
||||
const found = findAction(
|
||||
action.parameters.children as ActionData[],
|
||||
id,
|
||||
);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
@@ -821,10 +876,13 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
actionType = foundAction.type;
|
||||
} else {
|
||||
// Fallback for Wizard Actions (often have label/value in parameters)
|
||||
if (parameters?.label && typeof parameters.label === 'string') {
|
||||
if (parameters?.label && typeof parameters.label === "string") {
|
||||
actionName = parameters.label;
|
||||
actionType = "wizard_button";
|
||||
} else if (parameters?.value && typeof parameters.value === 'string') {
|
||||
} else if (
|
||||
parameters?.value &&
|
||||
typeof parameters.value === "string"
|
||||
) {
|
||||
actionName = parameters.value;
|
||||
actionType = "wizard_input";
|
||||
}
|
||||
@@ -837,8 +895,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
actionId,
|
||||
actionName,
|
||||
actionType,
|
||||
parameters
|
||||
}
|
||||
parameters,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -877,7 +935,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
// Try direct WebSocket execution first for better performance
|
||||
if (rosConnected) {
|
||||
try {
|
||||
const result = await executeRosAction(pluginName, actionId, parameters);
|
||||
const result = await executeRosAction(
|
||||
pluginName,
|
||||
actionId,
|
||||
parameters,
|
||||
);
|
||||
|
||||
const duration =
|
||||
result.endTime && result.startTime
|
||||
@@ -962,8 +1024,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
type: "intervention_action_skipped",
|
||||
data: {
|
||||
actionId,
|
||||
parameters
|
||||
}
|
||||
parameters,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -979,18 +1041,19 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
[logRobotActionMutation, trial.id, logEventMutation, handleNextStep],
|
||||
);
|
||||
|
||||
const handleLogEvent = useCallback((type: string, data?: any) => {
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type,
|
||||
data
|
||||
});
|
||||
}, [logEventMutation, trial.id]);
|
||||
|
||||
|
||||
const handleLogEvent = useCallback(
|
||||
(type: string, data?: any) => {
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type,
|
||||
data,
|
||||
});
|
||||
},
|
||||
[logEventMutation, trial.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
|
||||
<div className="bg-background flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden">
|
||||
<PageHeader
|
||||
title="Trial Execution"
|
||||
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||
@@ -998,11 +1061,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button
|
||||
onClick={handleStartTrial}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Button onClick={handleStartTrial} size="sm" className="gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
Start Trial
|
||||
</Button>
|
||||
@@ -1016,7 +1075,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
onClick={isPaused ? handleResumeTrial : handlePauseTrial}
|
||||
className="gap-2"
|
||||
>
|
||||
{isPaused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
|
||||
{isPaused ? (
|
||||
<Play className="h-4 w-4" />
|
||||
) : (
|
||||
<Pause className="h-4 w-4" />
|
||||
)}
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
</Button>
|
||||
|
||||
@@ -1065,11 +1128,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
/>
|
||||
|
||||
{/* Main Grid - Single Row */}
|
||||
<div className="flex-1 min-h-0 flex gap-2 px-2 pb-2">
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-2 px-2 pb-2">
|
||||
{/* Center - Execution Workspace */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
|
||||
<div className="bg-background flex flex-1 flex-col overflow-hidden rounded-lg border shadow-sm">
|
||||
<div className="bg-muted/30 flex min-h-[45px] items-center border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Trial Execution</span>
|
||||
{currentStep && (
|
||||
@@ -1081,7 +1143,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="mr-2 text-xs text-muted-foreground font-medium">
|
||||
<div className="text-muted-foreground mr-2 text-xs font-medium">
|
||||
Step {currentStepIndex + 1} / {steps.length}
|
||||
</div>
|
||||
|
||||
@@ -1097,7 +1159,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-muted/10 pb-0">
|
||||
<div className="bg-muted/10 flex-1 overflow-auto pb-0">
|
||||
<div id="tour-wizard-timeline" className="h-full">
|
||||
<WizardExecutionPanel
|
||||
trial={trial}
|
||||
@@ -1116,9 +1178,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
isExecuting={isExecutingAction}
|
||||
onNextStep={handleNextStep}
|
||||
completedActionsCount={completedActionsCount}
|
||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||
onActionCompleted={() => setCompletedActionsCount((c) => c + 1)}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
readOnly={
|
||||
trial.status === "completed" || _userRole === "observer"
|
||||
}
|
||||
rosConnected={rosConnected}
|
||||
onLogEvent={handleLogEvent}
|
||||
/>
|
||||
@@ -1127,11 +1191,13 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Tools Tabs (Collapsible) */}
|
||||
<div className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-[350px] lg:w-[400px]",
|
||||
rightCollapsed && "hidden"
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background flex w-[350px] flex-col overflow-hidden rounded-lg border shadow-sm lg:w-[400px]",
|
||||
rightCollapsed && "hidden",
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted/30 flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">Tools</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1142,29 +1208,46 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden bg-background">
|
||||
<Tabs defaultValue="camera_obs" className="flex flex-col h-full w-full">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-muted/30 px-3 py-1 shrink-0 h-10">
|
||||
<TabsTrigger value="camera_obs" className="text-xs flex-1">Camera & Obs</TabsTrigger>
|
||||
<TabsTrigger value="robot" className="text-xs flex-1">Robot Control</TabsTrigger>
|
||||
<div className="bg-background flex-1 overflow-hidden">
|
||||
<Tabs
|
||||
defaultValue="camera_obs"
|
||||
className="flex h-full w-full flex-col"
|
||||
>
|
||||
<TabsList className="bg-muted/30 h-10 w-full shrink-0 justify-start rounded-none border-b px-3 py-1">
|
||||
<TabsTrigger value="camera_obs" className="flex-1 text-xs">
|
||||
Camera & Obs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="robot" className="flex-1 text-xs">
|
||||
Robot Control
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="camera_obs" className="flex-1 flex-col m-0 p-0 h-full overflow-hidden min-h-0 data-[state=active]:flex">
|
||||
<div className="flex-none bg-muted/30 border-b h-48 sm:h-56 relative group shrink-0">
|
||||
<WebcamPanel readOnly={trial.status === 'completed'} trialId={trial.id} trialStatus={trial.status} />
|
||||
<TabsContent
|
||||
value="camera_obs"
|
||||
className="m-0 h-full min-h-0 flex-1 flex-col overflow-hidden p-0 data-[state=active]:flex"
|
||||
>
|
||||
<div className="bg-muted/30 group relative h-48 flex-none shrink-0 border-b sm:h-56">
|
||||
<WebcamPanel
|
||||
readOnly={trial.status === "completed"}
|
||||
trialId={trial.id}
|
||||
trialStatus={trial.status}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div className="bg-muted/10 min-h-0 flex-1 overflow-auto">
|
||||
<WizardObservationPane
|
||||
onAddAnnotation={handleAddAnnotation}
|
||||
onFlagIntervention={() => handleExecuteAction("intervene")}
|
||||
isSubmitting={addAnnotationMutation.isPending}
|
||||
trialEvents={trialEvents}
|
||||
readOnly={trial.status === 'completed'}
|
||||
readOnly={trial.status === "completed"}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="robot" className="flex-1 flex-col m-0 p-0 h-full overflow-hidden min-h-0 data-[state=active]:flex">
|
||||
<TabsContent
|
||||
value="robot"
|
||||
className="m-0 h-full min-h-0 flex-1 flex-col overflow-hidden p-0 data-[state=active]:flex"
|
||||
>
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
rosConnecting={rosConnecting}
|
||||
@@ -1178,7 +1261,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
studyId={trial.experiment.studyId}
|
||||
trialId={trial.id}
|
||||
trialStatus={trial.status}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
readOnly={
|
||||
trial.status === "completed" || _userRole === "observer"
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Play,
|
||||
StickyNote,
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Play,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -16,118 +16,126 @@ import { cn } from "~/lib/utils";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
export interface TrialStatusBarProps {
|
||||
currentStepIndex: number;
|
||||
totalSteps: number;
|
||||
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
rosConnected: boolean;
|
||||
eventsCount: number;
|
||||
completedActionsCount: number;
|
||||
totalActionsCount: number;
|
||||
onAddNote?: () => void;
|
||||
className?: string;
|
||||
currentStepIndex: number;
|
||||
totalSteps: number;
|
||||
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
rosConnected: boolean;
|
||||
eventsCount: number;
|
||||
completedActionsCount: number;
|
||||
totalActionsCount: number;
|
||||
onAddNote?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TrialStatusBar({
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
trialStatus,
|
||||
rosConnected,
|
||||
eventsCount,
|
||||
completedActionsCount,
|
||||
totalActionsCount,
|
||||
onAddNote,
|
||||
className,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
trialStatus,
|
||||
rosConnected,
|
||||
eventsCount,
|
||||
completedActionsCount,
|
||||
totalActionsCount,
|
||||
onAddNote,
|
||||
className,
|
||||
}: TrialStatusBarProps) {
|
||||
const progressPercentage = useMemo(
|
||||
() => (totalSteps > 0 ? ((currentStepIndex + 1) / totalSteps) * 100 : 0),
|
||||
[currentStepIndex, totalSteps],
|
||||
);
|
||||
const progressPercentage = useMemo(
|
||||
() => (totalSteps > 0 ? ((currentStepIndex + 1) / totalSteps) * 100 : 0),
|
||||
[currentStepIndex, totalSteps],
|
||||
);
|
||||
|
||||
const actionProgress = useMemo(
|
||||
() =>
|
||||
totalActionsCount > 0
|
||||
? (completedActionsCount / totalActionsCount) * 100
|
||||
: 0,
|
||||
[completedActionsCount, totalActionsCount],
|
||||
);
|
||||
const actionProgress = useMemo(
|
||||
() =>
|
||||
totalActionsCount > 0
|
||||
? (completedActionsCount / totalActionsCount) * 100
|
||||
: 0,
|
||||
[completedActionsCount, totalActionsCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
|
||||
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Step Progress */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<GitBranch className="h-3.5 w-3.5 opacity-70" />
|
||||
Step {currentStepIndex + 1}/{totalSteps}
|
||||
</span>
|
||||
<div className="w-20">
|
||||
<Progress value={progressPercentage} className="h-1.5" />
|
||||
</div>
|
||||
<span className="text-muted-foreground/70">{Math.round(progressPercentage)}%</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
|
||||
{/* Action Progress */}
|
||||
{totalActionsCount > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5 opacity-70" />
|
||||
{completedActionsCount}/{totalActionsCount} actions
|
||||
</span>
|
||||
<div className="w-16">
|
||||
<Progress value={actionProgress} className="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Trial Stats */}
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 opacity-70" />
|
||||
{eventsCount} events
|
||||
</span>
|
||||
{trialStatus === "in_progress" && (
|
||||
<Badge variant="default" className="h-5 gap-1 bg-emerald-500 px-1.5 text-[10px] font-normal">
|
||||
<Play className="h-2.5 w-2.5" />
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
{trialStatus === "completed" && (
|
||||
<Badge variant="secondary" className="h-5 gap-1 px-1.5 text-[10px] font-normal">
|
||||
<CheckCircle2 className="h-2.5 w-2.5" />
|
||||
Completed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{onAddNote && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={onAddNote}
|
||||
title="Add Quick Note"
|
||||
>
|
||||
<StickyNote className="mr-1.5 h-3.5 w-3.5" />
|
||||
Note
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
|
||||
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Step Progress */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground flex items-center gap-1.5">
|
||||
<GitBranch className="h-3.5 w-3.5 opacity-70" />
|
||||
Step {currentStepIndex + 1}/{totalSteps}
|
||||
</span>
|
||||
<div className="w-20">
|
||||
<Progress value={progressPercentage} className="h-1.5" />
|
||||
</div>
|
||||
);
|
||||
<span className="text-muted-foreground/70">
|
||||
{Math.round(progressPercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
|
||||
{/* Action Progress */}
|
||||
{totalActionsCount > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 opacity-70" />
|
||||
{completedActionsCount}/{totalActionsCount} actions
|
||||
</span>
|
||||
<div className="w-16">
|
||||
<Progress value={actionProgress} className="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Trial Stats */}
|
||||
<div className="text-muted-foreground flex items-center gap-3">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 opacity-70" />
|
||||
{eventsCount} events
|
||||
</span>
|
||||
{trialStatus === "in_progress" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="h-5 gap-1 bg-emerald-500 px-1.5 text-[10px] font-normal"
|
||||
>
|
||||
<Play className="h-2.5 w-2.5" />
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
{trialStatus === "completed" && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 gap-1 px-1.5 text-[10px] font-normal"
|
||||
>
|
||||
<CheckCircle2 className="h-2.5 w-2.5" />
|
||||
Completed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{onAddNote && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={onAddNote}
|
||||
title="Add Quick Note"
|
||||
>
|
||||
<StickyNote className="mr-1.5 h-3.5 w-3.5" />
|
||||
Note
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrialStatusBar;
|
||||
|
||||
@@ -9,295 +9,312 @@ import { AspectRatio } from "~/components/ui/aspect-ratio";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOnly?: boolean; trialId?: string; trialStatus?: string }) {
|
||||
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export function WebcamPanel({
|
||||
readOnly = false,
|
||||
trialId,
|
||||
trialStatus,
|
||||
}: {
|
||||
readOnly?: boolean;
|
||||
trialId?: string;
|
||||
trialStatus?: string;
|
||||
}) {
|
||||
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const webcamRef = useRef<Webcam>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const webcamRef = useRef<Webcam>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
// TRPC mutation for presigned URL
|
||||
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
|
||||
// TRPC mutation for presigned URL
|
||||
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
|
||||
|
||||
// Mutation to save recording metadata to DB
|
||||
const saveRecordingMutation = api.storage.saveRecording.useMutation();
|
||||
const logEventMutation = api.trials.logEvent.useMutation();
|
||||
// Mutation to save recording metadata to DB
|
||||
const saveRecordingMutation = api.storage.saveRecording.useMutation();
|
||||
const logEventMutation = api.trials.logEvent.useMutation();
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleEnableCamera = () => {
|
||||
const handleEnableCamera = () => {
|
||||
setIsCameraEnabled(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleDisableCamera = () => {
|
||||
if (isRecording) {
|
||||
handleStopRecording();
|
||||
}
|
||||
setIsCameraEnabled(false);
|
||||
};
|
||||
|
||||
// Auto-record based on trial status
|
||||
React.useEffect(() => {
|
||||
if (!trialStatus || readOnly) return;
|
||||
|
||||
if (trialStatus === "in_progress") {
|
||||
if (!isCameraEnabled) {
|
||||
console.log("Auto-enabling camera for trial start");
|
||||
setIsCameraEnabled(true);
|
||||
setError(null);
|
||||
};
|
||||
} else if (!isRecording && webcamRef.current?.stream) {
|
||||
handleStartRecording();
|
||||
}
|
||||
} else if (trialStatus === "completed" && isRecording) {
|
||||
handleStopRecording();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [trialStatus, isCameraEnabled, isRecording, readOnly]);
|
||||
|
||||
const handleDisableCamera = () => {
|
||||
if (isRecording) {
|
||||
handleStopRecording();
|
||||
const handleUserMedia = () => {
|
||||
if (trialStatus === "in_progress" && !isRecording && !readOnly) {
|
||||
console.log("Stream ready, auto-starting camera recording");
|
||||
handleStartRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRecording = () => {
|
||||
if (!webcamRef.current?.stream) return;
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state === "recording"
|
||||
) {
|
||||
console.log("Already recording, skipping start");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRecording(true);
|
||||
chunksRef.current = [];
|
||||
|
||||
try {
|
||||
const recorder = new MediaRecorder(webcamRef.current.stream, {
|
||||
mimeType: "video/webm",
|
||||
});
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
setIsCameraEnabled(false);
|
||||
};
|
||||
};
|
||||
|
||||
// Auto-record based on trial status
|
||||
React.useEffect(() => {
|
||||
if (!trialStatus || readOnly) return;
|
||||
recorder.onstop = async () => {
|
||||
const blob = new Blob(chunksRef.current, { type: "video/webm" });
|
||||
await handleUpload(blob);
|
||||
};
|
||||
|
||||
if (trialStatus === "in_progress") {
|
||||
if (!isCameraEnabled) {
|
||||
console.log("Auto-enabling camera for trial start");
|
||||
setIsCameraEnabled(true);
|
||||
} else if (!isRecording && webcamRef.current?.stream) {
|
||||
handleStartRecording();
|
||||
}
|
||||
} else if (trialStatus === "completed" && isRecording) {
|
||||
handleStopRecording();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [trialStatus, isCameraEnabled, isRecording, readOnly]);
|
||||
recorder.start();
|
||||
mediaRecorderRef.current = recorder;
|
||||
if (trialId) {
|
||||
logEventMutation.mutate({
|
||||
trialId,
|
||||
type: "camera_started",
|
||||
data: { action: "recording_started" },
|
||||
});
|
||||
}
|
||||
toast.success("Recording started");
|
||||
} catch (e) {
|
||||
console.error("Failed to start recorder:", e);
|
||||
toast.error("Failed to start recording");
|
||||
setIsRecording(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserMedia = () => {
|
||||
if (trialStatus === "in_progress" && !isRecording && !readOnly) {
|
||||
console.log("Stream ready, auto-starting camera recording");
|
||||
handleStartRecording();
|
||||
}
|
||||
};
|
||||
const handleStopRecording = () => {
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
isRecording &&
|
||||
mediaRecorderRef.current.state === "recording"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
if (trialId) {
|
||||
logEventMutation.mutate({
|
||||
trialId,
|
||||
type: "camera_stopped",
|
||||
data: { action: "recording_stopped" },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRecording = () => {
|
||||
if (!webcamRef.current?.stream) return;
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
|
||||
console.log("Already recording, skipping start");
|
||||
return;
|
||||
}
|
||||
const handleUpload = async (blob: Blob) => {
|
||||
setUploading(true);
|
||||
const filename = `recording-${Date.now()}.webm`;
|
||||
|
||||
setIsRecording(true);
|
||||
chunksRef.current = [];
|
||||
try {
|
||||
// 1. Get Presigned URL
|
||||
const { url } = await getUploadUrlMutation.mutateAsync({
|
||||
filename,
|
||||
contentType: "video/webm",
|
||||
});
|
||||
|
||||
// 2. Upload to S3
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: blob,
|
||||
headers: {
|
||||
"Content-Type": "video/webm",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Upload failed: ${errorText} | Status: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Save metadata to DB
|
||||
if (trialId) {
|
||||
console.log("Attempting to link recording to trial:", trialId);
|
||||
try {
|
||||
const recorder = new MediaRecorder(webcamRef.current.stream, {
|
||||
mimeType: "video/webm"
|
||||
});
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
const blob = new Blob(chunksRef.current, { type: "video/webm" });
|
||||
await handleUpload(blob);
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
mediaRecorderRef.current = recorder;
|
||||
if (trialId) {
|
||||
logEventMutation.mutate({
|
||||
trialId,
|
||||
type: "camera_started",
|
||||
data: { action: "recording_started" }
|
||||
});
|
||||
}
|
||||
toast.success("Recording started");
|
||||
} catch (e) {
|
||||
console.error("Failed to start recorder:", e);
|
||||
toast.error("Failed to start recording");
|
||||
setIsRecording(false);
|
||||
await saveRecordingMutation.mutateAsync({
|
||||
trialId,
|
||||
storagePath: filename,
|
||||
mediaType: "video",
|
||||
format: "webm",
|
||||
fileSize: blob.size,
|
||||
});
|
||||
console.log("Recording successfully linked to trial:", trialId);
|
||||
toast.success("Recording saved to trial log");
|
||||
} catch (mutationError) {
|
||||
console.error("Failed to link recording to trial:", mutationError);
|
||||
toast.error("Video uploaded but failed to link to trial");
|
||||
}
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
"No trialId provided, recording uploaded but not linked. Props:",
|
||||
{ trialId },
|
||||
);
|
||||
toast.warning("Trial ID missing - recording not linked");
|
||||
}
|
||||
|
||||
const handleStopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording && mediaRecorderRef.current.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
if (trialId) {
|
||||
logEventMutation.mutate({
|
||||
trialId,
|
||||
type: "camera_stopped",
|
||||
data: { action: "recording_stopped" }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
toast.success("Recording uploaded successfully");
|
||||
console.log("Uploaded recording:", filename);
|
||||
} catch (e) {
|
||||
console.error("Upload error:", e);
|
||||
toast.error("Failed to upload recording");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (blob: Blob) => {
|
||||
setUploading(true);
|
||||
const filename = `recording-${Date.now()}.webm`;
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="bg-muted/10 flex h-10 shrink-0 items-center justify-end border-b px-2 py-1">
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isCameraEnabled &&
|
||||
(!isRecording ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="animate-in fade-in h-7 px-2 text-xs"
|
||||
onClick={handleStartRecording}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Video className="mr-1 h-3 w-3" />
|
||||
Record
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 border border-red-500 px-2 text-xs text-red-500 hover:bg-red-50"
|
||||
onClick={handleStopRecording}
|
||||
>
|
||||
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
|
||||
Stop Rec
|
||||
</Button>
|
||||
))}
|
||||
|
||||
try {
|
||||
// 1. Get Presigned URL
|
||||
const { url } = await getUploadUrlMutation.mutateAsync({
|
||||
filename,
|
||||
contentType: "video/webm",
|
||||
});
|
||||
{isCameraEnabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={handleDisableCamera}
|
||||
disabled={isRecording}
|
||||
>
|
||||
<CameraOff className="mr-1 h-3 w-3" />
|
||||
Off
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={handleEnableCamera}
|
||||
>
|
||||
<Camera className="mr-1 h-3 w-3" />
|
||||
Start Camera
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
// 2. Upload to S3
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: blob,
|
||||
headers: {
|
||||
"Content-Type": "video/webm",
|
||||
},
|
||||
});
|
||||
<div className="bg-muted/50 relative flex flex-1 items-center justify-center overflow-hidden p-4">
|
||||
{isCameraEnabled ? (
|
||||
<div className="border-border relative w-full overflow-hidden rounded-lg border bg-black shadow-sm">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Webcam
|
||||
ref={webcamRef}
|
||||
audio={false}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onUserMedia={handleUserMedia}
|
||||
onUserMediaError={(err) => setError(String(err))}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</AspectRatio>
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Upload failed: ${errorText} | Status: ${response.status}`);
|
||||
}
|
||||
{/* Recording Overlay */}
|
||||
{isRecording && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2 rounded-full bg-black/50 px-2 py-1 backdrop-blur-sm">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||
<span className="text-[10px] font-medium text-white">REC</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
// 3. Save metadata to DB
|
||||
if (trialId) {
|
||||
console.log("Attempting to link recording to trial:", trialId);
|
||||
try {
|
||||
await saveRecordingMutation.mutateAsync({
|
||||
trialId,
|
||||
storagePath: filename,
|
||||
mediaType: "video",
|
||||
format: "webm",
|
||||
fileSize: blob.size,
|
||||
});
|
||||
console.log("Recording successfully linked to trial:", trialId);
|
||||
toast.success("Recording saved to trial log");
|
||||
} catch (mutationError) {
|
||||
console.error("Failed to link recording to trial:", mutationError);
|
||||
toast.error("Video uploaded but failed to link to trial");
|
||||
}
|
||||
} else {
|
||||
console.warn("No trialId provided, recording uploaded but not linked. Props:", { trialId });
|
||||
toast.warning("Trial ID missing - recording not linked");
|
||||
}
|
||||
{/* Uploading Overlay */}
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-white">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-xs font-medium">Uploading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
toast.success("Recording uploaded successfully");
|
||||
console.log("Uploaded recording:", filename);
|
||||
} catch (e) {
|
||||
console.error("Upload error:", e);
|
||||
toast.error("Failed to upload recording");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-end border-b px-2 py-1 bg-muted/10 h-10 shrink-0">
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{isCameraEnabled && (
|
||||
!isRecording ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs animate-in fade-in"
|
||||
onClick={handleStartRecording}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Video className="mr-1 h-3 w-3" />
|
||||
Record
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs border-red-500 border text-red-500 hover:bg-red-50"
|
||||
onClick={handleStopRecording}
|
||||
>
|
||||
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
|
||||
Stop Rec
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{isCameraEnabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={handleDisableCamera}
|
||||
disabled={isRecording}
|
||||
>
|
||||
<CameraOff className="mr-1 h-3 w-3" />
|
||||
Off
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={handleEnableCamera}
|
||||
>
|
||||
<Camera className="mr-1 h-3 w-3" />
|
||||
Start Camera
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||
<Alert variant="destructive" className="max-w-xs">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground/50 text-center">
|
||||
<div className="bg-muted mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<CameraOff className="h-6 w-6 opacity-50" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden bg-muted/50 p-4 flex items-center justify-center relative">
|
||||
{isCameraEnabled ? (
|
||||
<div className="w-full relative rounded-lg overflow-hidden border border-border shadow-sm bg-black">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Webcam
|
||||
ref={webcamRef}
|
||||
audio={false}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onUserMedia={handleUserMedia}
|
||||
onUserMediaError={(err) => setError(String(err))}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</AspectRatio>
|
||||
|
||||
{/* Recording Overlay */}
|
||||
{isRecording && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2 bg-black/50 px-2 py-1 rounded-full backdrop-blur-sm">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-[10px] font-medium text-white">REC</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading Overlay */}
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-white">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-xs font-medium">Uploading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||
<Alert variant="destructive" className="max-w-xs">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground/50">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<CameraOff className="h-6 w-6 opacity-50" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Camera is disabled</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={handleEnableCamera}
|
||||
>
|
||||
Enable Camera
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
<p className="text-sm font-medium">Camera is disabled</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={handleEnableCamera}
|
||||
>
|
||||
Enable Camera
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,11 +25,7 @@ interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional"; // Updated to match DB enum
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional"; // Updated to match DB enum
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
options?: {
|
||||
@@ -37,7 +33,13 @@ interface StepData {
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
variant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
@@ -109,12 +111,8 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
||||
isStarting = false,
|
||||
readOnly = false,
|
||||
}: WizardControlPanelProps) {
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" id="tour-wizard-controls">
|
||||
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-3">
|
||||
@@ -137,7 +135,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 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
|
||||
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:border-yellow-700/50 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/40"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
@@ -149,7 +147,9 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onExecuteAction("note", { content: "Wizard note" })}
|
||||
onClick={() =>
|
||||
onExecuteAction("note", { content: "Wizard note" })
|
||||
}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<User className="mr-2 h-3 w-3" />
|
||||
@@ -170,16 +170,18 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground p-2 text-center border border-dashed rounded-md bg-muted/20">
|
||||
<div className="text-muted-foreground bg-muted/20 rounded-md border border-dashed p-2 text-center text-xs">
|
||||
Controls available during trial
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<span className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
Navigation
|
||||
</span>
|
||||
<select
|
||||
className="w-full text-xs p-2 rounded-md border bg-background"
|
||||
className="bg-background w-full rounded-md border p-2 text-xs"
|
||||
value={currentStepIndex}
|
||||
onChange={(e) => onNextStep(parseInt(e.target.value, 10))}
|
||||
disabled={readOnly}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import React from "react";
|
||||
import { WizardActionItem } from "./WizardActionItem";
|
||||
import {
|
||||
@@ -23,11 +22,7 @@ interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional";
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
options?: {
|
||||
@@ -35,7 +30,13 @@ interface StepData {
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
variant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
@@ -166,7 +167,7 @@ export function WizardExecutionPanel({
|
||||
if (trial.status === "scheduled") {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="w-full max-w-md space-y-4 text-center">
|
||||
<Clock className="text-muted-foreground mx-auto h-12 w-12 opacity-20" />
|
||||
<div>
|
||||
@@ -219,16 +220,17 @@ export function WizardExecutionPanel({
|
||||
|
||||
// Active trial state
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden relative">
|
||||
<div className="relative flex h-full flex-col overflow-hidden">
|
||||
{/* Paused Overlay */}
|
||||
{isPaused && (
|
||||
<div className="absolute inset-0 z-50 bg-background/60 backdrop-blur-[2px] flex items-center justify-center">
|
||||
<div className="bg-background border shadow-lg rounded-xl p-8 flex flex-col items-center max-w-sm text-center space-y-4">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground" />
|
||||
<div className="bg-background/60 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-[2px]">
|
||||
<div className="bg-background flex max-w-sm flex-col items-center space-y-4 rounded-xl border p-8 text-center shadow-lg">
|
||||
<AlertCircle className="text-muted-foreground h-12 w-12" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">Trial Paused</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The trial execution has been paused. Resume from the control bar to continue interacting.
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
The trial execution has been paused. Resume from the control bar
|
||||
to continue interacting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,48 +238,45 @@ export function WizardExecutionPanel({
|
||||
)}
|
||||
|
||||
{/* Horizontal Step Progress Bar */}
|
||||
<div className="flex-none border-b bg-muted/30 p-3">
|
||||
<div className="bg-muted/30 flex-none border-b p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
{steps.map((step, idx) => {
|
||||
const isCurrent = idx === currentStepIndex;
|
||||
const isSkipped = skippedStepIndices.has(idx);
|
||||
const isCompleted = completedStepIndices.has(idx) || (!isSkipped && idx < currentStepIndex);
|
||||
const isCompleted =
|
||||
completedStepIndices.has(idx) ||
|
||||
(!isSkipped && idx < currentStepIndex);
|
||||
const isUpcoming = idx > currentStepIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center gap-2 flex-shrink-0"
|
||||
className="flex flex-shrink-0 items-center gap-2"
|
||||
>
|
||||
<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
|
||||
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"
|
||||
: isSkipped
|
||||
? "border-muted-foreground/30 bg-muted/20 border-dashed"
|
||||
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
|
||||
}
|
||||
${readOnly ? "cursor-default" : "cursor-pointer"}
|
||||
`}
|
||||
} ${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
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${
|
||||
isCompleted
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isSkipped
|
||||
? "bg-transparent border border-muted-foreground/40 text-muted-foreground"
|
||||
? "border-muted-foreground/40 text-muted-foreground border bg-transparent"
|
||||
: isCurrent
|
||||
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
|
||||
? "bg-primary text-primary-foreground ring-primary/20 ring-2"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}
|
||||
`}
|
||||
} `}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
@@ -288,12 +287,13 @@ export function WizardExecutionPanel({
|
||||
|
||||
{/* Step Name */}
|
||||
<span
|
||||
className={`text-xs font-medium max-w-[120px] truncate ${isCurrent
|
||||
? "text-foreground"
|
||||
: isCompleted
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/60"
|
||||
}`}
|
||||
className={`max-w-[120px] truncate text-xs font-medium ${
|
||||
isCurrent
|
||||
? "text-foreground"
|
||||
: isCompleted
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/60"
|
||||
}`}
|
||||
title={step.name}
|
||||
>
|
||||
{step.name}
|
||||
@@ -303,8 +303,11 @@ export function WizardExecutionPanel({
|
||||
{/* Arrow Connector */}
|
||||
{idx < steps.length - 1 && (
|
||||
<ArrowRight
|
||||
className={`h-4 w-4 flex-shrink-0 ${isCompleted ? "text-primary/40" : "text-muted-foreground/30"
|
||||
}`}
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
isCompleted
|
||||
? "text-primary/40"
|
||||
: "text-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -314,16 +317,20 @@ export function WizardExecutionPanel({
|
||||
</div>
|
||||
|
||||
{/* Current Step Details - NO SCROLL */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="min-h-0 flex-1 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-5xl mx-auto w-full">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-4">
|
||||
{/* Header Info */}
|
||||
<div className="space-y-1 pb-4 border-b">
|
||||
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
|
||||
<div className="space-y-1 border-b pb-4">
|
||||
<h2 className="text-xl font-bold tracking-tight">
|
||||
{currentStep.name}
|
||||
</h2>
|
||||
{currentStep.description && (
|
||||
<div className="text-muted-foreground">{currentStep.description}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{currentStep.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -333,34 +340,38 @@ export function WizardExecutionPanel({
|
||||
{currentStep.actions.map((action, idx) => {
|
||||
const isCompleted = idx < activeActionIndex;
|
||||
const isActive: boolean = idx === activeActionIndex;
|
||||
const isLast = idx === (currentStep.actions?.length || 0) - 1;
|
||||
const isLast =
|
||||
idx === (currentStep.actions?.length || 0) - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="relative pl-8 pb-10 last:pb-0"
|
||||
className="relative pb-10 pl-8 last:pb-0"
|
||||
ref={isActive ? activeActionRef : undefined}
|
||||
>
|
||||
{/* Connecting Line */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className={`absolute left-[11px] top-8 bottom-0 w-[2px] ${isCompleted ? "bg-primary/20" : "bg-border/40"}`}
|
||||
className={`absolute top-8 bottom-0 left-[11px] w-[2px] ${isCompleted ? "bg-primary/20" : "bg-border/40"}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Marker */}
|
||||
<div
|
||||
className={`absolute left-0 top-1 h-6 w-6 rounded-full border-2 flex items-center justify-center z-10 bg-background transition-all duration-300 ${isCompleted
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: isActive
|
||||
? "border-primary ring-4 ring-primary/10 scale-110"
|
||||
: "border-muted-foreground/30 text-muted-foreground"
|
||||
}`}
|
||||
className={`bg-background absolute top-1 left-0 z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 transition-all duration-300 ${
|
||||
isCompleted
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: isActive
|
||||
? "border-primary ring-primary/10 scale-110 ring-4"
|
||||
: "border-muted-foreground/30 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold">{idx + 1}</span>
|
||||
<span className="text-[10px] font-bold">
|
||||
{idx + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -390,21 +401,28 @@ export function WizardExecutionPanel({
|
||||
<div className="mt-6 flex justify-center pb-8">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
|
||||
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-green-600 hover:bg-green-700"
|
||||
}`}
|
||||
onClick={
|
||||
currentStepIndex === steps.length - 1
|
||||
? onCompleteTrial
|
||||
: onNextStep
|
||||
}
|
||||
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${
|
||||
currentStepIndex === steps.length - 1
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-green-600 hover:bg-green-700"
|
||||
}`}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
|
||||
{currentStepIndex === steps.length - 1
|
||||
? "Complete Trial"
|
||||
: "Complete Step"}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground space-y-3">
|
||||
<div className="text-muted-foreground flex h-full flex-col items-center justify-center space-y-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
|
||||
<div className="text-sm">Waiting for trial to start...</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,14 @@ import {
|
||||
Power,
|
||||
PowerOff,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
@@ -64,24 +72,27 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
}: 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");
|
||||
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
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set autonomous life:", error);
|
||||
setAutonomousLife(!checked); // Revert on failure
|
||||
}
|
||||
}
|
||||
}, [onSetAutonomousLife]);
|
||||
},
|
||||
[onSetAutonomousLife],
|
||||
);
|
||||
return (
|
||||
<div className="flex h-full flex-col p-2">
|
||||
{/* Robot Controls - Scrollable */}
|
||||
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="bg-background flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border shadow-sm">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Robot Status */}
|
||||
@@ -92,7 +103,12 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
{rosConnected ? (
|
||||
<Power className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-gray-500 border-gray-300 text-xs text-muted-foreground w-auto px-1.5 py-0">Offline</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground w-auto border-gray-300 px-1.5 py-0 text-xs text-gray-500"
|
||||
>
|
||||
Offline
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,11 +161,16 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
disabled={rosConnecting || rosConnected || readOnly}
|
||||
>
|
||||
<Bot className="mr-1 h-3 w-3" />
|
||||
{rosConnecting
|
||||
? "Connecting..."
|
||||
: rosConnected
|
||||
? "Connected ✓"
|
||||
: "Connect to NAO6"}
|
||||
{rosConnecting ? (
|
||||
"Connecting..."
|
||||
) : rosConnected ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Connected</span>
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
</div>
|
||||
) : (
|
||||
"Connect to NAO6"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -192,7 +213,12 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
{/* 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>
|
||||
<Label
|
||||
htmlFor="autonomous-life"
|
||||
className="text-muted-foreground text-xs font-normal"
|
||||
>
|
||||
Autonomous Life
|
||||
</Label>
|
||||
<Switch
|
||||
id="tour-wizard-autonomous"
|
||||
checked={!!autonomousLife}
|
||||
@@ -235,7 +261,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
↺ Turn L
|
||||
<RotateCcw className="mr-1 h-3 w-3" /> Turn L
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -248,7 +274,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
↑ Forward
|
||||
<ArrowUp className="mr-1 h-3 w-3" /> Forward
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -261,7 +287,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
Turn R ↻
|
||||
Turn R <RotateCw className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{/* Row 2: Left, Stop, Right */}
|
||||
@@ -276,7 +302,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
← Left
|
||||
<ArrowLeft className="mr-1 h-3 w-3" /> Left
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -289,7 +315,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
■ Stop
|
||||
<Square className="mr-1 h-3 w-3 fill-current" /> Stop
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -302,7 +328,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
Right →
|
||||
Right <ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{/* Row 3: Empty, Back, Empty */}
|
||||
@@ -318,7 +344,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
↓ Back
|
||||
<ArrowDown className="mr-1 h-3 w-3" /> Back
|
||||
</Button>
|
||||
<div></div>
|
||||
</div>
|
||||
@@ -337,10 +363,14 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type text to speak..."
|
||||
className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex-1 rounded-md border px-2 py-1 text-xs focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
disabled={readOnly}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.currentTarget.value.trim() && !readOnly) {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
e.currentTarget.value.trim() &&
|
||||
!readOnly
|
||||
) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: e.currentTarget.value.trim(),
|
||||
}).catch(console.error);
|
||||
@@ -353,7 +383,8 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
|
||||
const input = e.currentTarget
|
||||
.previousElementSibling as HTMLInputElement;
|
||||
if (input?.value.trim()) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: input.value.trim(),
|
||||
|
||||
@@ -1,174 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Send, Hash, Tag, Clock, Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-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,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
|
||||
|
||||
interface TrialEvent {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface WizardObservationPaneProps {
|
||||
onAddAnnotation: (
|
||||
description: string,
|
||||
category?: string,
|
||||
tags?: string[],
|
||||
) => Promise<void>;
|
||||
onFlagIntervention?: () => Promise<void> | void;
|
||||
isSubmitting?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
onAddAnnotation: (
|
||||
description: string,
|
||||
category?: string,
|
||||
tags?: string[],
|
||||
) => Promise<void>;
|
||||
onFlagIntervention?: () => Promise<void> | void;
|
||||
isSubmitting?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function WizardObservationPane({
|
||||
onAddAnnotation,
|
||||
onFlagIntervention,
|
||||
isSubmitting = false,
|
||||
trialEvents = [],
|
||||
readOnly = false,
|
||||
onAddAnnotation,
|
||||
onFlagIntervention,
|
||||
isSubmitting = false,
|
||||
trialEvents = [],
|
||||
readOnly = false,
|
||||
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [category, setCategory] = useState("observation");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [currentTag, setCurrentTag] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [category, setCategory] = useState("observation");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [currentTag, setCurrentTag] = useState("");
|
||||
|
||||
const placeholders: Record<string, string> = {
|
||||
observation: "Type your observation here...",
|
||||
participant_behavior: "Describe the participant's behavior...",
|
||||
system_issue: "Describe the system issue...",
|
||||
success: "Describe the success...",
|
||||
failure: "Describe the failure...",
|
||||
};
|
||||
const placeholders: Record<string, string> = {
|
||||
observation: "Type your observation here...",
|
||||
participant_behavior: "Describe the participant's behavior...",
|
||||
system_issue: "Describe the system issue...",
|
||||
success: "Describe the success...",
|
||||
failure: "Describe the failure...",
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!note.trim()) return;
|
||||
const handleSubmit = async () => {
|
||||
if (!note.trim()) return;
|
||||
|
||||
await onAddAnnotation(note, category, tags);
|
||||
setNote("");
|
||||
setTags([]);
|
||||
setCurrentTag("");
|
||||
};
|
||||
await onAddAnnotation(note, category, tags);
|
||||
setNote("");
|
||||
setTags([]);
|
||||
setCurrentTag("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
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("");
|
||||
}
|
||||
};
|
||||
const addTag = () => {
|
||||
const trimmed = currentTag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
setTags([...tags, trimmed]);
|
||||
setCurrentTag("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<div className="flex-1 flex flex-col p-4 m-0 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Textarea
|
||||
placeholder={readOnly ? "Session is read-only" : (placeholders[category] || "Type your observation here...")}
|
||||
className="flex-1 resize-none font-mono text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
return (
|
||||
<div className="bg-background flex h-full flex-col">
|
||||
<div className="m-0 flex flex-1 flex-col overflow-hidden p-4">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Textarea
|
||||
placeholder={
|
||||
readOnly
|
||||
? "Session is read-only"
|
||||
: placeholders[category] || "Type your observation here..."
|
||||
}
|
||||
className="flex-1 resize-none font-mono text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 shrink-0">
|
||||
{/* Top Line: Category & Tags */}
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs shrink-0">
|
||||
<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 shrink-0 flex-col gap-2">
|
||||
{/* Top Line: Category & Tags */}
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={setCategory}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px] shrink-0 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 min-w-[80px] items-center gap-2 rounded-md border px-2 h-8">
|
||||
<Tag className={`h-3 w-3 shrink-0 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={readOnly ? "" : "Add tags..."}
|
||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed w-full min-w-0"
|
||||
value={currentTag}
|
||||
onChange={(e) => setCurrentTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
onBlur={addTag}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Line: Actions */}
|
||||
<div className="flex items-center justify-end gap-2 w-full">
|
||||
{onFlagIntervention && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onFlagIntervention()}
|
||||
disabled={readOnly}
|
||||
className="h-8 shrink-0 flex-1 sm:flex-none border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-3 w-3" />
|
||||
Intervention
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !note.trim() || readOnly}
|
||||
className="h-8 shrink-0 flex-1 sm:flex-none"
|
||||
>
|
||||
<Send className="mr-2 h-3 w-3" />
|
||||
Save Note
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
<div className="flex h-8 min-w-[80px] flex-1 items-center gap-2 rounded-md border px-2">
|
||||
<Tag
|
||||
className={`h-3 w-3 shrink-0 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={readOnly ? "" : "Add tags..."}
|
||||
className="placeholder:text-muted-foreground w-full min-w-0 flex-1 bg-transparent text-xs outline-none disabled:cursor-not-allowed"
|
||||
value={currentTag}
|
||||
onChange={(e) => setCurrentTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
onBlur={addTag}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Line: Actions */}
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
{onFlagIntervention && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onFlagIntervention()}
|
||||
disabled={readOnly}
|
||||
className="h-8 flex-1 shrink-0 border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 sm:flex-none dark:border-yellow-700/50 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/40"
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-3 w-3" />
|
||||
Intervention
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !note.trim() || readOnly}
|
||||
className="h-8 flex-1 shrink-0 sm:flex-none"
|
||||
>
|
||||
<Send className="mr-2 h-3 w-3" />
|
||||
Save Note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="hover:bg-destructive/10 hover:text-destructive cursor-pointer px-1 py-0 text-[10px]"
|
||||
onClick={() => setTags(tags.filter((t) => t !== tag))}
|
||||
>
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user