mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat: Enhance trial event display with improved formatting and icons, refine trial wizard panels, and update dashboard page layouts.
This commit is contained in:
@@ -39,11 +39,15 @@ 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">
|
||||
<span className="font-mono font-medium">
|
||||
<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">
|
||||
@@ -56,6 +60,10 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
|
||||
{
|
||||
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");
|
||||
@@ -63,25 +71,33 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
|
||||
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) Icon = User; // Wizard/Intervention often User
|
||||
else if (isIntervention || isJump) Icon = User; // Jumps are interventions
|
||||
else if (isRobot) Icon = Bot;
|
||||
else if (isStep) Icon = Flag;
|
||||
else if (type.includes("note")) Icon = MessageSquare;
|
||||
else if (type.includes("completed")) Icon = CheckCircle;
|
||||
else if (isObservation) Icon = MessageSquare;
|
||||
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn(
|
||||
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5",
|
||||
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 && "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"
|
||||
)}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
<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) => {
|
||||
@@ -93,14 +109,42 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
|
||||
header: "Details",
|
||||
cell: ({ row }) => {
|
||||
const data = row.original.data;
|
||||
if (!data || Object.keys(data).length === 0) return <span className="text-muted-foreground text-xs">-</span>;
|
||||
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>;
|
||||
}
|
||||
|
||||
// Simplistic view for now: JSON stringify but truncated?
|
||||
// Or meaningful extraction based on event type.
|
||||
return (
|
||||
<code className="text-[10px] font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border block max-w-[400px] truncate">
|
||||
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
|
||||
</code>
|
||||
<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>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,22 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import { type TrialEvent, eventsColumns } from "./events-columns";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import {
|
||||
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 { usePlayback } from "../playback/PlaybackContext";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
User,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
Activity,
|
||||
Video
|
||||
} from "lucide-react";
|
||||
import { type TrialEvent } from "./events-columns";
|
||||
|
||||
interface EventsDataTableProps {
|
||||
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";
|
||||
|
||||
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")}`;
|
||||
}
|
||||
|
||||
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 columns = React.useMemo(() => eventsColumns(startTime), [startTime]);
|
||||
|
||||
// Enhanced filtering logic
|
||||
const filteredData = React.useMemo(() => {
|
||||
return data.filter(event => {
|
||||
@@ -40,46 +79,39 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
||||
});
|
||||
}, [data, eventTypeFilter, globalFilter]);
|
||||
|
||||
// Custom Filters UI
|
||||
const filters = (
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
// 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 }>({});
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* We instruct DataTable to use our filtered data, but DataTable also has internal filtering.
|
||||
Since we implemented custom external filtering for "type" dropdown and "global" search,
|
||||
we pass the filtered data directly.
|
||||
|
||||
However, the shared DataTable component has a `searchKey` prop that drives an internal Input.
|
||||
If we want to use OUR custom search input (to search JSON data), we should probably NOT use
|
||||
DataTable's internal search or pass a custom filter.
|
||||
|
||||
The shared DataTable's `searchKey` only filters a specific column string value.
|
||||
Since "data" is an object, we can't easily use the built-in single-column search.
|
||||
So we'll implement our own search input and pass `filters={filters}` which creates
|
||||
additional dropdowns, but we might want to REPLACE the standard search input.
|
||||
|
||||
Looking at `DataTable` implementation:
|
||||
It renders `<Input ... />` if `searchKey` is provided. If we don't provide `searchKey`,
|
||||
no input is rendered, and we can put ours in `filters`.
|
||||
*/}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<Input
|
||||
@@ -88,16 +120,176 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
{filters}
|
||||
<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>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
// No searchKey, we handle it externally
|
||||
isLoading={false}
|
||||
/>
|
||||
<div 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,20 +83,21 @@ export function EventTimeline() {
|
||||
}, [effectiveDuration]);
|
||||
|
||||
const getEventIcon = (type: string) => {
|
||||
if (type.includes("intervention") || type.includes("wizard")) return <User className="h-4 w-4" />;
|
||||
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")) return <MessageSquare 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")) return "bg-orange-100 text-orange-600 border-orange-200";
|
||||
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";
|
||||
};
|
||||
@@ -132,19 +133,37 @@ export function EventTimeline() {
|
||||
{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"
|
||||
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();
|
||||
seekTo((new Date(event.timestamp).getTime() - startTime) / 1000);
|
||||
// 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-8 w-8 items-center justify-center rounded-full border shadow-sm transition-transform hover:scale-125 hover:z-50 bg-background relative z-20",
|
||||
"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)}
|
||||
@@ -156,9 +175,9 @@ export function EventTimeline() {
|
||||
<div className="text-[10px] font-mono opacity-70 mb-1">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
{!!event.data && (
|
||||
<div className="bg-muted/50 p-1 rounded font-mono text-[9px] max-w-[200px] break-all">
|
||||
{JSON.stringify(event.data as object).slice(0, 100)}
|
||||
{!!details && (
|
||||
<div className="bg-muted/50 p-1.5 rounded text-[10px] max-w-[220px] break-words whitespace-normal border">
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
|
||||
@@ -85,13 +85,14 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
controls
|
||||
muted={muted}
|
||||
className="w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onWaiting={handleWaiting}
|
||||
onPlaying={handlePlaying}
|
||||
onEnded={handleEnded}
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
|
||||
{/* Overlay Controls (Visible on Hover/Pause) */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PageHeader } from "~/components/ui/page-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
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 { useEffect } from "react";
|
||||
@@ -31,7 +32,7 @@ interface TrialAnalysisViewProps {
|
||||
participant: { participantCode: string };
|
||||
eventCount?: number;
|
||||
mediaCount?: number;
|
||||
media?: { url: string; contentType: string }[];
|
||||
media?: { url: string; mediaType: string; format?: string; contentType?: string }[];
|
||||
};
|
||||
backHref: string;
|
||||
}
|
||||
@@ -41,6 +42,8 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
const { data: events = [] } = api.trials.getEvents.useQuery({
|
||||
trialId: trial.id,
|
||||
limit: 1000
|
||||
}, {
|
||||
refetchInterval: 5000
|
||||
});
|
||||
|
||||
// Auto-print effect
|
||||
@@ -54,7 +57,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
|
||||
const videoMedia = trial.media?.find(m => m.mediaType === "video" || (m as any).contentType?.startsWith("video/"));
|
||||
const videoUrl = videoMedia?.url;
|
||||
|
||||
// Metrics
|
||||
@@ -64,7 +67,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
|
||||
return (
|
||||
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
||||
<div id="trial-analysis-content" className="flex h-full flex-col gap-4 p-4 text-sm">
|
||||
<div id="trial-analysis-content" className="flex h-full flex-col gap-2 p-3 text-sm">
|
||||
{/* Header Context */}
|
||||
<PageHeader
|
||||
title={trial.experiment.name}
|
||||
@@ -185,65 +188,56 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Metrics Header */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4" id="tour-trial-metrics">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
|
||||
<Clock className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{trial.duration ? (
|
||||
<span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span>
|
||||
) : (
|
||||
"--:--"
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Total session time</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Robot Actions</CardTitle>
|
||||
<Bot className="h-4 w-4 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{robotActionCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Executed autonomous behaviors</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Interventions</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{interventionCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Manual wizard overrides</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Completeness</CardTitle>
|
||||
<Activity className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{trial.status === 'completed' ? '100%' : 'Incomplete'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
|
||||
)} />
|
||||
{trial.status.charAt(0).toUpperCase() + trial.status.slice(1)}
|
||||
{/* 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="flex flex-row divide-x h-full">
|
||||
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
|
||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
|
||||
<Clock className="h-3.5 w-3.5 text-blue-500" /> Duration
|
||||
</p>
|
||||
<p className="text-base font-bold">
|
||||
{trial.duration ? <span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> : "--:--"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
|
||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
|
||||
<Bot className="h-3.5 w-3.5 text-purple-500" /> Robot Actions
|
||||
</p>
|
||||
<p className="text-base font-bold">{robotActionCount}</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
|
||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-orange-500" /> Interventions
|
||||
</p>
|
||||
<p className="text-base font-bold">{interventionCount}</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col p-3 px-4 justify-center">
|
||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
|
||||
<Activity className="h-3.5 w-3.5 text-green-500" /> Completeness
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-base font-bold">
|
||||
<span className={cn(
|
||||
"inline-block h-2 w-2 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 */}
|
||||
@@ -254,51 +248,89 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
<EventTimeline />
|
||||
</div>
|
||||
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
|
||||
{/* TOP: Video (Optional) */}
|
||||
{videoUrl && (
|
||||
<>
|
||||
<ResizablePanel defaultSize={40} minSize={20} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40" id="tour-trial-video">
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||
<div className="absolute inset-0">
|
||||
<PlaybackPlayer src={videoUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className="bg-border/50" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* BOTTOM: Events Table */}
|
||||
<ResizablePanel defaultSize={videoUrl ? 60 : 100} minSize={20} className="flex flex-col min-h-0 bg-background" id="tour-trial-events">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
{/* 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">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">Event Log</h3>
|
||||
<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 events..."
|
||||
className="h-8 w-[200px]"
|
||||
placeholder="Filter..."
|
||||
className="h-7 w-[150px] text-xs"
|
||||
disabled
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
|
||||
<Badge variant="outline" className="text-[10px] font-normal">{events.length} Total</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
<TabsContent value="events" className="flex-1 min-h-0 mt-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
<div className="p-0">
|
||||
<EventsDataTable
|
||||
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
|
||||
startTime={trial.startedAt ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</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>
|
||||
</div>
|
||||
</PlaybackProvider>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Play,
|
||||
Target,
|
||||
Users,
|
||||
SkipForward
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
@@ -21,15 +22,17 @@ interface TrialProgressProps {
|
||||
id: string;
|
||||
name: string;
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
description?: string;
|
||||
duration?: number;
|
||||
parameters?: Record<string, unknown>;
|
||||
}>;
|
||||
currentStepIndex: number;
|
||||
completedSteps: Set<number>;
|
||||
skippedSteps: Set<number>;
|
||||
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
}
|
||||
|
||||
@@ -71,6 +74,8 @@ const stepTypeConfig = {
|
||||
export function TrialProgress({
|
||||
steps,
|
||||
currentStepIndex,
|
||||
completedSteps,
|
||||
skippedSteps,
|
||||
trialStatus,
|
||||
}: TrialProgressProps) {
|
||||
if (!steps || steps.length === 0) {
|
||||
@@ -93,7 +98,7 @@ export function TrialProgress({
|
||||
? 0
|
||||
: ((currentStepIndex + 1) / steps.length) * 100;
|
||||
|
||||
const completedSteps =
|
||||
const completedCount =
|
||||
trialStatus === "completed"
|
||||
? steps.length
|
||||
: trialStatus === "aborted" || trialStatus === "failed"
|
||||
@@ -102,12 +107,19 @@ export function TrialProgress({
|
||||
|
||||
const getStepStatus = (index: number) => {
|
||||
if (trialStatus === "aborted" || trialStatus === "failed") return "aborted";
|
||||
if (trialStatus === "completed" || index < currentStepIndex)
|
||||
return "completed";
|
||||
if (trialStatus === "completed") return "completed";
|
||||
|
||||
if (skippedSteps.has(index)) return "skipped";
|
||||
if (completedSteps.has(index)) return "completed";
|
||||
|
||||
if (index === currentStepIndex && trialStatus === "in_progress")
|
||||
return "active";
|
||||
if (index === currentStepIndex && trialStatus === "scheduled")
|
||||
return "pending";
|
||||
|
||||
// Default fallback if jumping around without explicitly adding to sets
|
||||
if (index < currentStepIndex && !skippedSteps.has(index)) return "completed";
|
||||
|
||||
return "upcoming";
|
||||
};
|
||||
|
||||
@@ -145,6 +157,14 @@ export function TrialProgress({
|
||||
borderColor: "border-red-300",
|
||||
textColor: "text-red-800",
|
||||
};
|
||||
case "skipped":
|
||||
return {
|
||||
icon: Circle,
|
||||
iconColor: "text-slate-400 opacity-50",
|
||||
bgColor: "bg-slate-50 opacity-50",
|
||||
borderColor: "border-slate-200 border-dashed",
|
||||
textColor: "text-slate-500",
|
||||
};
|
||||
default: // upcoming
|
||||
return {
|
||||
icon: Circle,
|
||||
@@ -171,7 +191,7 @@ export function TrialProgress({
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{completedSteps}/{steps.length} steps
|
||||
{completedCount}/{steps.length} steps
|
||||
</Badge>
|
||||
{totalDuration > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
@@ -191,13 +211,12 @@ export function TrialProgress({
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
className={`h-2 ${
|
||||
trialStatus === "completed"
|
||||
? "bg-green-100"
|
||||
: trialStatus === "aborted" || trialStatus === "failed"
|
||||
? "bg-red-100"
|
||||
: "bg-blue-100"
|
||||
}`}
|
||||
className={`h-2 ${trialStatus === "completed"
|
||||
? "bg-green-100"
|
||||
: trialStatus === "aborted" || trialStatus === "failed"
|
||||
? "bg-red-100"
|
||||
: "bg-blue-100"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>Start</span>
|
||||
@@ -236,51 +255,47 @@ export function TrialProgress({
|
||||
{/* Connection Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`absolute top-12 left-6 h-6 w-0.5 ${
|
||||
getStepStatus(index + 1) === "completed" ||
|
||||
className={`absolute top-12 left-6 h-6 w-0.5 ${getStepStatus(index + 1) === "completed" ||
|
||||
(getStepStatus(index + 1) === "active" &&
|
||||
status === "completed")
|
||||
? "bg-green-300"
|
||||
: "bg-slate-300"
|
||||
}`}
|
||||
? "bg-green-300"
|
||||
: "bg-slate-300"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step Card */}
|
||||
<div
|
||||
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${
|
||||
status === "active"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
||||
: status === "completed"
|
||||
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${status === "active"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
||||
: status === "completed"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: status === "aborted"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: status === "aborted"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: "border-slate-200 bg-slate-50"
|
||||
}`}
|
||||
: "border-slate-200 bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
{/* Step Number & Status */}
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<div
|
||||
className={`flex h-8 w-12 items-center justify-center rounded-lg ${
|
||||
status === "active"
|
||||
? statusConfig.bgColor
|
||||
: status === "completed"
|
||||
? "bg-green-100"
|
||||
: status === "aborted"
|
||||
? "bg-red-100"
|
||||
: "bg-slate-100"
|
||||
}`}
|
||||
className={`flex h-8 w-12 items-center justify-center rounded-lg ${status === "active"
|
||||
? statusConfig.bgColor
|
||||
: status === "completed"
|
||||
? "bg-green-100"
|
||||
: status === "aborted"
|
||||
? "bg-red-100"
|
||||
: "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
status === "active"
|
||||
? statusConfig.textColor
|
||||
: status === "completed"
|
||||
? "text-green-700"
|
||||
: status === "aborted"
|
||||
? "text-red-700"
|
||||
: "text-slate-600"
|
||||
}`}
|
||||
className={`text-sm font-medium ${status === "active"
|
||||
? statusConfig.textColor
|
||||
: status === "completed"
|
||||
? "text-green-700"
|
||||
: status === "aborted"
|
||||
? "text-red-700"
|
||||
: "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
@@ -297,15 +312,14 @@ export function TrialProgress({
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h5
|
||||
className={`truncate font-medium ${
|
||||
status === "active"
|
||||
? "text-slate-900"
|
||||
: status === "completed"
|
||||
? "text-green-900"
|
||||
: status === "aborted"
|
||||
? "text-red-900"
|
||||
: "text-slate-700"
|
||||
}`}
|
||||
className={`truncate font-medium ${status === "active"
|
||||
? "text-slate-900"
|
||||
: status === "completed"
|
||||
? "text-green-900"
|
||||
: status === "aborted"
|
||||
? "text-red-900"
|
||||
: "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</h5>
|
||||
@@ -352,6 +366,12 @@ export function TrialProgress({
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "skipped" && (
|
||||
<div className="mt-2 flex items-center space-x-1 text-sm text-slate-500 opacity-80">
|
||||
<SkipForward className="h-3 w-3" />
|
||||
<span>Skipped</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -365,7 +385,7 @@ export function TrialProgress({
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{completedSteps}
|
||||
{completedCount}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">Completed</div>
|
||||
</div>
|
||||
@@ -378,7 +398,7 @@ export function TrialProgress({
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-600">
|
||||
{steps.length -
|
||||
completedSteps -
|
||||
completedCount -
|
||||
(trialStatus === "in_progress" ? 1 : 0)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">Remaining</div>
|
||||
|
||||
@@ -25,10 +25,10 @@ import Link from "next/link";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { WizardControlPanel } from "./panels/WizardControlPanel";
|
||||
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
|
||||
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
|
||||
import { WizardObservationPane } from "./panels/WizardObservationPane";
|
||||
import { WebcamPanel } from "./panels/WebcamPanel";
|
||||
import { TrialStatusBar } from "./panels/TrialStatusBar";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useWizardRos } from "~/hooks/useWizardRos";
|
||||
@@ -121,20 +121,22 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const [completedActionsCount, setCompletedActionsCount] = useState(0);
|
||||
|
||||
// Collapse state for panels
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const [obsCollapsed, setObsCollapsed] = useState(false);
|
||||
|
||||
// Center tabs (Timeline | Actions)
|
||||
const [centerTab, setCenterTab] = useState<"timeline" | "actions">("timeline");
|
||||
|
||||
// Reset completed actions when step changes
|
||||
useEffect(() => {
|
||||
setCompletedActionsCount(0);
|
||||
}, [currentStepIndex]);
|
||||
|
||||
// Track completed steps
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
||||
const [skippedSteps, setSkippedSteps] = useState<Set<number>>(new Set());
|
||||
|
||||
// Track the last response value from wizard_wait_for_response for branching
|
||||
const [lastResponse, setLastResponse] = useState<string | null>(null);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Get experiment steps from API
|
||||
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
|
||||
@@ -492,16 +494,27 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const handlePauseTrial = async () => {
|
||||
try {
|
||||
await pauseTrialMutation.mutateAsync({ id: trial.id });
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type: "trial_paused",
|
||||
data: { timestamp: new Date() }
|
||||
});
|
||||
setIsPaused(true);
|
||||
toast.info("Trial paused");
|
||||
} catch (error) {
|
||||
console.error("Failed to pause trial:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeTrial = async () => {
|
||||
try {
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type: "trial_resumed",
|
||||
data: { timestamp: new Date() }
|
||||
});
|
||||
setIsPaused(false);
|
||||
toast.success("Trial resumed");
|
||||
} catch (error) {
|
||||
console.error("Failed to resume trial:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = (targetIndex?: number) => {
|
||||
// If explicit target provided (from branching choice), use it
|
||||
if (typeof targetIndex === 'number') {
|
||||
@@ -577,6 +590,24 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}
|
||||
});
|
||||
|
||||
// Mark steps as skipped
|
||||
setSkippedSteps(prev => {
|
||||
const next = new Set(prev);
|
||||
for (let i = currentStepIndex + 1; i < targetIndex; i++) {
|
||||
if (!completedSteps.has(i)) {
|
||||
next.add(i);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// Mark current as complete
|
||||
setCompletedSteps(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(currentStepIndex);
|
||||
return next;
|
||||
});
|
||||
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setCompletedActionsCount(0);
|
||||
return;
|
||||
@@ -590,6 +621,13 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
// Default: Linear progression
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex < steps.length) {
|
||||
// Mark current step as complete
|
||||
setCompletedSteps(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(currentStepIndex);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Log step change
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
@@ -600,6 +638,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
fromStepId: currentStep?.id,
|
||||
toStepId: steps[nextIndex]?.id,
|
||||
stepName: steps[nextIndex]?.name,
|
||||
method: "auto"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -609,14 +648,51 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}
|
||||
};
|
||||
|
||||
const handleStepSelect = (index: number) => {
|
||||
if (index === currentStepIndex) return;
|
||||
|
||||
// Log manual jump
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type: "intervention_step_jump",
|
||||
data: {
|
||||
fromIndex: currentStepIndex,
|
||||
toIndex: index,
|
||||
fromStepId: currentStep?.id,
|
||||
toStepId: steps[index]?.id,
|
||||
stepName: steps[index]?.name,
|
||||
method: "manual"
|
||||
}
|
||||
});
|
||||
|
||||
// Mark current as complete if leaving it?
|
||||
// Maybe better to only mark on "Next" or explicit complete.
|
||||
// If I jump away, I might not be done.
|
||||
// I'll leave 'completedSteps' update to explicit actions or completion.
|
||||
|
||||
setCurrentStepIndex(index);
|
||||
};
|
||||
|
||||
const handleCompleteTrial = async () => {
|
||||
try {
|
||||
// Mark final step as complete
|
||||
setCompletedSteps(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(currentStepIndex);
|
||||
return next;
|
||||
});
|
||||
|
||||
await completeTrialMutation.mutateAsync({ id: trial.id });
|
||||
|
||||
|
||||
// Invalidate queries so the analysis page sees the completed state immediately
|
||||
await utils.trials.get.invalidate({ id: trial.id });
|
||||
await utils.trials.getEvents.invalidate({ trialId: trial.id });
|
||||
|
||||
// Trigger archive in background
|
||||
archiveTrialMutation.mutate({ id: trial.id });
|
||||
|
||||
// Immediately navigate to analysis
|
||||
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
|
||||
} catch (error) {
|
||||
console.error("Failed to complete trial:", error);
|
||||
}
|
||||
@@ -707,12 +783,60 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
category: String(parameters?.category || "quick_note")
|
||||
});
|
||||
} else {
|
||||
// Generic action logging
|
||||
// Generic action logging - now with more details
|
||||
// Find the action definition to get its name
|
||||
let actionName = actionId;
|
||||
let actionType = "unknown";
|
||||
|
||||
// Helper to search recursively
|
||||
const findAction = (actions: ActionData[], id: string): ActionData | undefined => {
|
||||
for (const action of actions) {
|
||||
if (action.id === id) return action;
|
||||
if (action.parameters?.children) {
|
||||
const found = findAction(action.parameters.children as ActionData[], id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Search in current step first
|
||||
let foundAction: ActionData | undefined;
|
||||
if (steps[currentStepIndex]?.actions) {
|
||||
foundAction = findAction(steps[currentStepIndex]!.actions!, actionId);
|
||||
}
|
||||
|
||||
// If not found, search all steps (less efficient but safer)
|
||||
if (!foundAction) {
|
||||
for (const step of steps) {
|
||||
if (step.actions) {
|
||||
foundAction = findAction(step.actions, actionId);
|
||||
if (foundAction) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundAction) {
|
||||
actionName = foundAction.name;
|
||||
actionType = foundAction.type;
|
||||
} else {
|
||||
// Fallback for Wizard Actions (often have label/value in parameters)
|
||||
if (parameters?.label && typeof parameters.label === 'string') {
|
||||
actionName = parameters.label;
|
||||
actionType = "wizard_button";
|
||||
} else if (parameters?.value && typeof parameters.value === 'string') {
|
||||
actionName = parameters.value;
|
||||
actionType = "wizard_input";
|
||||
}
|
||||
}
|
||||
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "action_executed",
|
||||
data: {
|
||||
actionId,
|
||||
actionName,
|
||||
actionType,
|
||||
parameters
|
||||
}
|
||||
});
|
||||
@@ -734,6 +858,22 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
) => {
|
||||
try {
|
||||
setIsExecutingAction(true);
|
||||
|
||||
// Core actions execute directly via tRPC (no ROS needed)
|
||||
if (pluginName === "hristudio-core" || pluginName === "hristudio-woz") {
|
||||
await executeRobotActionMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
pluginName,
|
||||
actionId,
|
||||
parameters,
|
||||
});
|
||||
if (options?.autoAdvance) {
|
||||
handleNextStep();
|
||||
}
|
||||
setIsExecutingAction(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try direct WebSocket execution first for better performance
|
||||
if (rosConnected) {
|
||||
try {
|
||||
@@ -778,18 +918,12 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use tRPC execution if WebSocket not connected
|
||||
await executeRobotActionMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
pluginName,
|
||||
actionId,
|
||||
parameters,
|
||||
});
|
||||
|
||||
toast.success(`Robot action executed: ${actionId}`);
|
||||
if (options?.autoAdvance) {
|
||||
handleNextStep();
|
||||
}
|
||||
// Not connected - show error and don't try to execute
|
||||
const errorMsg = "Robot not connected. Cannot execute action.";
|
||||
toast.error(errorMsg);
|
||||
console.warn(errorMsg);
|
||||
// Throw to stop execution flow
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to execute robot action:", error);
|
||||
@@ -825,7 +959,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
// Generic skip logging
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "action_skipped",
|
||||
type: "intervention_action_skipped",
|
||||
data: {
|
||||
actionId,
|
||||
parameters
|
||||
@@ -842,9 +976,17 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
toast.error("Failed to skip action");
|
||||
}
|
||||
},
|
||||
[logRobotActionMutation, trial.id],
|
||||
[logRobotActionMutation, trial.id, logEventMutation, handleNextStep],
|
||||
);
|
||||
|
||||
const handleLogEvent = useCallback((type: string, data?: any) => {
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type,
|
||||
data
|
||||
});
|
||||
}, [logEventMutation, trial.id]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
@@ -869,13 +1011,13 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
{trial.status === "in_progress" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={isPaused ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handlePauseTrial}
|
||||
onClick={isPaused ? handleResumeTrial : handlePauseTrial}
|
||||
className="gap-2"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
Pause
|
||||
{isPaused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -922,190 +1064,128 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
className="flex-none px-2 pb-2"
|
||||
/>
|
||||
|
||||
{/* Main Grid - 2 rows */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2">
|
||||
{/* Top Row - 3 Column Layout */}
|
||||
<div className="flex-1 min-h-0 flex gap-2">
|
||||
{/* Left Sidebar - Control Panel (Collapsible) */}
|
||||
{!leftCollapsed && (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
||||
<span className="text-sm font-medium">Control</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setLeftCollapsed(true)}
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-controls-wrapper" className="h-full">
|
||||
<WizardControlPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
onStartTrial={handleStartTrial}
|
||||
onPauseTrial={handlePauseTrial}
|
||||
onNextStep={handleNextStep}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
onAbortTrial={handleAbortTrial}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Main Grid - Single Row */}
|
||||
<div className="flex-1 min-h-0 flex gap-2 px-2 pb-2">
|
||||
|
||||
{/* Center - Tabbed Workspace */}
|
||||
{/* Center - Execution Workspace */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
|
||||
{leftCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 mr-2"
|
||||
onClick={() => setLeftCollapsed(false)}
|
||||
title="Open Tools Panel"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Trial Execution</span>
|
||||
{currentStep && (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{currentStep.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="mr-2 text-xs text-muted-foreground font-medium">
|
||||
Step {currentStepIndex + 1} / {steps.length}
|
||||
</div>
|
||||
|
||||
{rightCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setRightCollapsed(false)}
|
||||
title="Open Robot Status"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Center - Execution Workspace */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Trial Execution</span>
|
||||
{currentStep && (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{currentStep.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-timeline" className="h-full">
|
||||
<WizardExecutionPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
trialEvents={trialEvents}
|
||||
onStepSelect={(index: number) => setCurrentStepIndex(index)}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
activeTab={executionPanelTab}
|
||||
onTabChange={setExecutionPanelTab}
|
||||
onSkipAction={handleSkipAction}
|
||||
isExecuting={isExecutingAction}
|
||||
onNextStep={handleNextStep}
|
||||
completedActionsCount={completedActionsCount}
|
||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
rosConnected={rosConnected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Robot Status (Collapsible) */}
|
||||
{!rightCollapsed && (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
||||
<span className="text-sm font-medium">Robot Control & Status</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setRightCollapsed(true)}
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-robot-status" className="h-full">
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
rosConnecting={rosConnecting}
|
||||
rosError={rosError ?? undefined}
|
||||
robotStatus={robotStatus}
|
||||
connectRos={connectRos}
|
||||
disconnectRos={disconnectRos}
|
||||
executeRosAction={executeRosAction}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
trialId={trial.id}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Bottom Row - Observations (Full Width, Collapsible) */}
|
||||
{!obsCollapsed && (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm h-48 flex-none">
|
||||
<div className="flex items-center border-b px-3 py-2 bg-muted/30 gap-3">
|
||||
<span className="text-sm font-medium">Observations</span>
|
||||
<div className="flex-1" />
|
||||
<div className="mr-2 text-xs text-muted-foreground font-medium">
|
||||
Step {currentStepIndex + 1} / {steps.length}
|
||||
</div>
|
||||
|
||||
{rightCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setObsCollapsed(true)}
|
||||
onClick={() => setRightCollapsed(false)}
|
||||
title="Open Status & Tools"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<WizardObservationPane
|
||||
onAddAnnotation={handleAddAnnotation}
|
||||
isSubmitting={addAnnotationMutation.isPending}
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-muted/10 pb-0">
|
||||
<div id="tour-wizard-timeline" className="h-full">
|
||||
<WizardExecutionPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
completedStepIndices={completedSteps}
|
||||
trialEvents={trialEvents}
|
||||
readOnly={trial.status === 'completed'}
|
||||
isPaused={isPaused}
|
||||
onStepSelect={handleStepSelect}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
activeTab={executionPanelTab}
|
||||
onTabChange={setExecutionPanelTab}
|
||||
onSkipAction={handleSkipAction}
|
||||
isExecuting={isExecutingAction}
|
||||
onNextStep={handleNextStep}
|
||||
completedActionsCount={completedActionsCount}
|
||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
rosConnected={rosConnected}
|
||||
onLogEvent={handleLogEvent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
obsCollapsed && (
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Tools Tabs (Collapsible) */}
|
||||
<div className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-[350px] lg:w-[400px]",
|
||||
rightCollapsed && "hidden"
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30 shrink-0">
|
||||
<span className="text-sm font-medium">Tools</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setObsCollapsed(false)}
|
||||
className="w-full flex-none"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setRightCollapsed(true)}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 mr-2" />
|
||||
Show Observations
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
</div >
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden bg-background">
|
||||
<Tabs defaultValue="camera_obs" className="flex flex-col h-full w-full">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-muted/30 px-3 py-1 shrink-0 h-10">
|
||||
<TabsTrigger value="camera_obs" className="text-xs flex-1">Camera & Obs</TabsTrigger>
|
||||
<TabsTrigger value="robot" className="text-xs flex-1">Robot Control</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="camera_obs" className="flex-1 flex flex-col m-0 p-0 h-full overflow-hidden min-h-0">
|
||||
<div className="flex-none bg-muted/30 border-b h-48 sm:h-56 relative group shrink-0">
|
||||
<WebcamPanel readOnly={trial.status === 'completed'} trialId={trial.id} trialStatus={trial.status} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<WizardObservationPane
|
||||
onAddAnnotation={handleAddAnnotation}
|
||||
onFlagIntervention={() => handleExecuteAction("intervene")}
|
||||
isSubmitting={addAnnotationMutation.isPending}
|
||||
trialEvents={trialEvents}
|
||||
readOnly={trial.status === 'completed'}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="robot" className="flex-1 m-0 h-full overflow-hidden">
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
rosConnecting={rosConnecting}
|
||||
rosError={rosError ?? undefined}
|
||||
robotStatus={robotStatus}
|
||||
connectRos={connectRos}
|
||||
disconnectRos={disconnectRos}
|
||||
executeRosAction={executeRosAction}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
trialId={trial.id}
|
||||
trialStatus={trial.status}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AspectRatio } from "~/components/ui/aspect-ratio";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOnly?: boolean; trialId?: string; trialStatus?: string }) {
|
||||
const [deviceId, setDeviceId] = useState<string | null>(null);
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
|
||||
@@ -31,6 +31,10 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
// TRPC mutation for presigned URL
|
||||
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
|
||||
|
||||
// Mutation to save recording metadata to DB
|
||||
const saveRecordingMutation = api.storage.saveRecording.useMutation();
|
||||
const logEventMutation = api.trials.logEvent.useMutation();
|
||||
|
||||
const handleDevices = useCallback(
|
||||
(mediaDevices: MediaDeviceInfo[]) => {
|
||||
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
|
||||
@@ -38,7 +42,10 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
[setDevices],
|
||||
);
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsMounted(true);
|
||||
navigator.mediaDevices.enumerateDevices().then(handleDevices);
|
||||
}, [handleDevices]);
|
||||
|
||||
@@ -54,6 +61,30 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
setIsCameraEnabled(false);
|
||||
};
|
||||
|
||||
// Auto-record based on trial status
|
||||
React.useEffect(() => {
|
||||
if (!trialStatus || readOnly) return;
|
||||
|
||||
if (trialStatus === "in_progress") {
|
||||
if (!isCameraEnabled) {
|
||||
console.log("Auto-enabling camera for trial start");
|
||||
setIsCameraEnabled(true);
|
||||
} else if (!isRecording && webcamRef.current?.stream) {
|
||||
handleStartRecording();
|
||||
}
|
||||
} else if (trialStatus === "completed" && isRecording) {
|
||||
handleStopRecording();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [trialStatus, isCameraEnabled, isRecording, readOnly]);
|
||||
|
||||
const handleUserMedia = () => {
|
||||
if (trialStatus === "in_progress" && !isRecording && !readOnly) {
|
||||
console.log("Stream ready, auto-starting camera recording");
|
||||
handleStartRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRecording = () => {
|
||||
if (!webcamRef.current?.stream) return;
|
||||
|
||||
@@ -78,6 +109,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
|
||||
recorder.start();
|
||||
mediaRecorderRef.current = recorder;
|
||||
if (trialId) {
|
||||
logEventMutation.mutate({
|
||||
trialId,
|
||||
type: "camera_started",
|
||||
data: { action: "recording_started" }
|
||||
});
|
||||
}
|
||||
toast.success("Recording started");
|
||||
} catch (e) {
|
||||
console.error("Failed to start recorder:", e);
|
||||
@@ -90,6 +128,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
if (trialId) {
|
||||
logEventMutation.mutate({
|
||||
trialId,
|
||||
type: "camera_stopped",
|
||||
data: { action: "recording_stopped" }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,7 +159,30 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Upload failed");
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Upload failed: ${errorText} | Status: ${response.status}`);
|
||||
}
|
||||
|
||||
// 3. Save metadata to DB
|
||||
if (trialId) {
|
||||
console.log("Attempting to link recording to trial:", trialId);
|
||||
try {
|
||||
await saveRecordingMutation.mutateAsync({
|
||||
trialId,
|
||||
storagePath: filename,
|
||||
mediaType: "video",
|
||||
format: "webm",
|
||||
fileSize: blob.size,
|
||||
});
|
||||
console.log("Recording successfully linked to trial:", trialId);
|
||||
toast.success("Recording saved to trial log");
|
||||
} catch (mutationError) {
|
||||
console.error("Failed to link recording to trial:", mutationError);
|
||||
toast.error("Video uploaded but failed to link to trial");
|
||||
}
|
||||
} else {
|
||||
console.warn("No trialId provided, recording uploaded but not linked. Props:", { trialId });
|
||||
toast.warning("Trial ID missing - recording not linked");
|
||||
}
|
||||
|
||||
toast.success("Recording uploaded successfully");
|
||||
@@ -137,7 +205,7 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
{devices.length > 0 && (
|
||||
{devices.length > 0 && isMounted && (
|
||||
<Select
|
||||
value={deviceId ?? undefined}
|
||||
onValueChange={setDeviceId}
|
||||
@@ -217,6 +285,7 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
width="100%"
|
||||
height="100%"
|
||||
videoConstraints={{ deviceId: deviceId ?? undefined }}
|
||||
onUserMedia={handleUserMedia}
|
||||
onUserMediaError={(err) => setError(String(err))}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
|
||||
@@ -47,6 +47,7 @@ interface WizardActionItemProps {
|
||||
isExecuting?: boolean;
|
||||
depth?: number;
|
||||
isRobotConnected?: boolean;
|
||||
onLogEvent?: (type: string, data?: any) => void;
|
||||
}
|
||||
|
||||
export function WizardActionItem({
|
||||
@@ -62,6 +63,7 @@ export function WizardActionItem({
|
||||
isExecuting,
|
||||
depth = 0,
|
||||
isRobotConnected = false,
|
||||
onLogEvent,
|
||||
}: WizardActionItemProps): React.JSX.Element {
|
||||
// Local state for container children completion
|
||||
const [completedChildren, setCompletedChildren] = useState<Set<number>>(new Set());
|
||||
@@ -289,13 +291,14 @@ export function WizardActionItem({
|
||||
isExecuting={isExecuting}
|
||||
depth={depth + 1}
|
||||
isRobotConnected={isRobotConnected}
|
||||
onLogEvent={onLogEvent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null) as any}
|
||||
|
||||
{/* Active Action Controls */}
|
||||
{isActive && !readOnly && (
|
||||
{(isActive || (isCompleted && !readOnly)) && (
|
||||
<div className="pt-3 flex flex-wrap items-center gap-3">
|
||||
{/* Parallel Container Controls */}
|
||||
{isContainer && action.type.includes("parallel") ? (
|
||||
@@ -326,20 +329,22 @@ export function WizardActionItem({
|
||||
title={isButtonDisabled && !isExecuting ? "Robot disconnected" : undefined}
|
||||
>
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
Run All
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCompleted();
|
||||
}}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3.5 w-3.5" />
|
||||
Mark Group Complete
|
||||
{isCompleted ? "Rerun All" : "Run All"}
|
||||
</Button>
|
||||
{!isCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCompleted();
|
||||
}}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3.5 w-3.5" />
|
||||
Mark Group Complete
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Standard Single Action Controls */
|
||||
@@ -367,7 +372,7 @@ export function WizardActionItem({
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
);
|
||||
onCompleted();
|
||||
if (!isCompleted) onCompleted();
|
||||
} catch (error) {
|
||||
console.error("Action execution error:", error);
|
||||
} finally {
|
||||
@@ -386,39 +391,50 @@ export function WizardActionItem({
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
Run
|
||||
{isCompleted ? "Rerun" : "Run"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCompleted();
|
||||
}}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3.5 w-3.5" />
|
||||
Mark Complete
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (onSkip) {
|
||||
onSkip(action.pluginId!, action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.parameters || {}, { autoAdvance: false });
|
||||
}
|
||||
onCompleted();
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
{!isCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Log manual completion
|
||||
if (onLogEvent) {
|
||||
onLogEvent("action_marked_complete", {
|
||||
actionId: action.id,
|
||||
formatted: "Action manually marked complete"
|
||||
});
|
||||
}
|
||||
onCompleted();
|
||||
}}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3.5 w-3.5" />
|
||||
Mark Complete
|
||||
</Button>
|
||||
)}
|
||||
{!isCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (onSkip) {
|
||||
onSkip(action.pluginId!, action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.parameters || {}, { autoAdvance: false });
|
||||
}
|
||||
onCompleted();
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Manual/Wizard Actions (Leaf nodes)
|
||||
!isContainer && action.type !== "wizard_wait_for_response" && (
|
||||
!isContainer && action.type !== "wizard_wait_for_response" && !isCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
@@ -437,7 +453,7 @@ export function WizardActionItem({
|
||||
)}
|
||||
|
||||
{/* Branching / Choice UI */}
|
||||
{isActive &&
|
||||
{(isActive || (isCompleted && !readOnly)) &&
|
||||
(action.type === "wizard_wait_for_response" || isBranch) &&
|
||||
action.parameters?.options &&
|
||||
Array.isArray(action.parameters.options) && (
|
||||
|
||||
@@ -113,7 +113,11 @@ interface WizardExecutionPanelProps {
|
||||
completedActionsCount: number;
|
||||
onActionCompleted: () => void;
|
||||
readOnly?: boolean;
|
||||
isPaused?: boolean;
|
||||
rosConnected?: boolean;
|
||||
completedStepIndices?: Set<number>;
|
||||
skippedStepIndices?: Set<number>;
|
||||
onLogEvent?: (type: string, data?: any) => void;
|
||||
}
|
||||
|
||||
export function WizardExecutionPanel({
|
||||
@@ -134,12 +138,17 @@ export function WizardExecutionPanel({
|
||||
completedActionsCount,
|
||||
onActionCompleted,
|
||||
readOnly = false,
|
||||
isPaused = false,
|
||||
rosConnected,
|
||||
completedStepIndices = new Set(),
|
||||
skippedStepIndices = new Set(),
|
||||
onLogEvent,
|
||||
}: WizardExecutionPanelProps) {
|
||||
// Local state removed in favor of parent state to prevent reset on re-render
|
||||
// const [completedCount, setCompletedCount] = React.useState(0);
|
||||
|
||||
const activeActionIndex = completedActionsCount;
|
||||
const isStepCompleted = completedStepIndices.has(currentStepIndex);
|
||||
const activeActionIndex = isStepCompleted ? 9999 : completedActionsCount;
|
||||
|
||||
// Auto-scroll to active action
|
||||
const activeActionRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -210,13 +219,29 @@ export function WizardExecutionPanel({
|
||||
|
||||
// Active trial state
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex h-full flex-col overflow-hidden relative">
|
||||
{/* Paused Overlay */}
|
||||
{isPaused && (
|
||||
<div className="absolute inset-0 z-50 bg-background/60 backdrop-blur-[2px] flex items-center justify-center">
|
||||
<div className="bg-background border shadow-lg rounded-xl p-8 flex flex-col items-center max-w-sm text-center space-y-4">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">Trial Paused</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The trial execution has been paused. Resume from the control bar to continue interacting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Horizontal Step Progress Bar */}
|
||||
<div className="flex-none border-b bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
{steps.map((step, idx) => {
|
||||
const isCurrent = idx === currentStepIndex;
|
||||
const isCompleted = idx < currentStepIndex;
|
||||
const isSkipped = skippedStepIndices.has(idx);
|
||||
const isCompleted = completedStepIndices.has(idx) || (!isSkipped && idx < currentStepIndex);
|
||||
const isUpcoming = idx > currentStepIndex;
|
||||
|
||||
return (
|
||||
@@ -233,7 +258,9 @@ export function WizardExecutionPanel({
|
||||
? "border-primary bg-primary/10 shadow-sm"
|
||||
: isCompleted
|
||||
? "border-primary/30 bg-primary/5 hover:bg-primary/10"
|
||||
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
|
||||
: isSkipped
|
||||
? "border-muted-foreground/30 bg-muted/20 border-dashed"
|
||||
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
|
||||
}
|
||||
${readOnly ? "cursor-default" : "cursor-pointer"}
|
||||
`}
|
||||
@@ -244,9 +271,11 @@ export function WizardExecutionPanel({
|
||||
flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold
|
||||
${isCompleted
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isCurrent
|
||||
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
|
||||
: "bg-muted text-muted-foreground"
|
||||
: isSkipped
|
||||
? "bg-transparent border border-muted-foreground/40 text-muted-foreground"
|
||||
: isCurrent
|
||||
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -348,6 +377,7 @@ export function WizardExecutionPanel({
|
||||
readOnly={readOnly}
|
||||
isExecuting={isExecuting}
|
||||
isRobotConnected={rosConnected}
|
||||
onLogEvent={onLogEvent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { WebcamPanel } from "./WebcamPanel";
|
||||
import { RobotActionsPanel } from "../RobotActionsPanel";
|
||||
|
||||
interface WizardMonitoringPanelProps {
|
||||
@@ -44,6 +43,7 @@ interface WizardMonitoringPanelProps {
|
||||
) => Promise<void>;
|
||||
studyId?: string;
|
||||
trialId?: string;
|
||||
trialStatus?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
onExecuteRobotAction,
|
||||
studyId,
|
||||
trialId,
|
||||
trialStatus,
|
||||
readOnly = false,
|
||||
}: WizardMonitoringPanelProps) {
|
||||
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
||||
@@ -78,12 +79,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
}
|
||||
}, [onSetAutonomousLife]);
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
{/* Camera View - Always Visible */}
|
||||
<div className="shrink-0 bg-muted/30 rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
|
||||
<WebcamPanel readOnly={readOnly} />
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col p-2">
|
||||
{/* Robot Controls - Scrollable */}
|
||||
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b bg-muted/30 flex items-center gap-2">
|
||||
|
||||
@@ -29,6 +29,7 @@ interface WizardObservationPaneProps {
|
||||
category?: string,
|
||||
tags?: string[],
|
||||
) => Promise<void>;
|
||||
onFlagIntervention?: () => Promise<void> | void;
|
||||
isSubmitting?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
@@ -36,6 +37,7 @@ interface WizardObservationPaneProps {
|
||||
|
||||
export function WizardObservationPane({
|
||||
onAddAnnotation,
|
||||
onFlagIntervention,
|
||||
isSubmitting = false,
|
||||
trialEvents = [],
|
||||
readOnly = false,
|
||||
@@ -118,11 +120,23 @@ export function WizardObservationPane({
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !note.trim() || readOnly}
|
||||
className="h-8"
|
||||
className="h-8 shrink-0"
|
||||
>
|
||||
<Send className="mr-2 h-3 w-3" />
|
||||
Add Note
|
||||
</Button>
|
||||
{onFlagIntervention && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onFlagIntervention()}
|
||||
disabled={readOnly}
|
||||
className="h-8 shrink-0 border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-3 w-3" />
|
||||
Intervention
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user