Break work

This commit is contained in:
2026-01-20 09:38:07 -05:00
parent d83c02759a
commit 4fbd3be324
36 changed files with 3117 additions and 2770 deletions

View File

@@ -367,10 +367,8 @@ export const columns: ColumnDef<Trial>[] = [
function ActionsCell({ row }: { row: { original: Trial } }) {
const trial = row.original;
const router = React.useMemo(() => require("next/navigation").useRouter(), []); // Dynamic import to avoid hook rules in static context? No, this component is rendered in Table.
// Actually, hooks must be at top level. This ActionsCell will be a regular component.
// But useRouter might fail if columns is not in component tree?
// Table cells are rendered by flexRender in React, so they are components.
// ActionsCell is a component rendered by the table.
// importing useRouter is fine.
const utils = api.useUtils();

View File

@@ -0,0 +1,205 @@
"use client";
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
} from "lucide-react";
import {
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")}`;
}
export function EventTimeline() {
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();
if (sortedEvents.length > 0) return new Date(sortedEvents[0]!.timestamp).getTime();
return 0;
}, [contextStartTime, sortedEvents]);
const effectiveDuration = useMemo(() => {
if (duration > 0) return duration * 1000;
if (sortedEvents.length === 0) return 60000; // 1 min default
const end = new Date(sortedEvents[sortedEvents.length - 1]!.timestamp).getTime();
return Math.max(end - startTime, 1000);
}, [duration, sortedEvents, startTime]);
// 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 for "number line" look
// We want a major tick every ~10% or meaningful time interval
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")) return <User className="h-3 w-3" />;
if (type.includes("robot") || type.includes("action")) return <Bot className="h-3 w-3" />;
if (type.includes("completed")) return <CheckCircle className="h-3 w-3" />;
if (type.includes("start")) return <Flag className="h-3 w-3" />;
if (type.includes("note")) return <MessageSquare className="h-3 w-3" />;
if (type.includes("error")) return <AlertTriangle className="h-3 w-3" />;
return <Activity className="h-3 w-3" />;
};
const getEventColor = (type: string) => {
if (type.includes("intervention") || type.includes("wizard")) return "text-orange-500 border-orange-200 bg-orange-50";
if (type.includes("robot") || type.includes("action")) return "text-purple-500 border-purple-200 bg-purple-50";
if (type.includes("completed")) return "text-green-500 border-green-200 bg-green-50";
if (type.includes("start")) return "text-blue-500 border-blue-200 bg-blue-50";
if (type.includes("error")) return "text-red-500 border-red-200 bg-red-50";
return "text-slate-500 border-slate-200 bg-slate-50";
};
return (
<div className="w-full h-full flex flex-col select-none py-2">
<TooltipProvider>
{/* Timeline Track Container */}
<div
ref={containerRef}
className="relative w-full flex-1 min-h-[80px] group cursor-crosshair border-b border-border/50"
onClick={handleSeek}
>
{/* Background Grid/Ticks */}
<div className="absolute inset-0 pointer-events-none">
{/* Major Ticks */}
{ticks.map((tick, i) => (
<div
key={i}
className="absolute top-0 bottom-0 border-l border-border/30 flex flex-col justify-end"
style={{ left: `${tick.pct}%` }}
>
<span className="text-[10px] font-mono text-muted-foreground -ml-3 mb-1 bg-background/80 px-1 rounded">
{tick.label}
</span>
</div>
))}
</div>
{/* Central Axis Line */}
<div className="absolute top-1/2 left-0 right-0 h-px bg-border z-0" />
{/* Progress Fill (Subtle) */}
<div
className="absolute top-0 bottom-0 left-0 bg-primary/5 z-0 pointer-events-none"
style={{ width: `${currentProgress}%` }}
/>
{/* Playhead */}
<div
className="absolute top-0 bottom-0 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
style={{ left: `${currentProgress}%` }}
>
<div className="absolute -top-1 -ml-1.5 p-0.5 bg-red-500 rounded text-[8px] font-bold text-white w-3 h-3 flex items-center justify-center">
</div>
</div>
{/* Events "Lollipops" */}
{sortedEvents.map((event, i) => {
const pct = getPercentage(new Date(event.timestamp).getTime());
const isTop = i % 2 === 0; // Stagger events top/bottom
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
className="absolute z-20 flex flex-col items-center group/event"
style={{
left: `${pct}%`,
top: '50%',
transform: 'translate(-50%, -50%)',
height: '100%'
}}
onClick={(e) => {
e.stopPropagation();
seekTo((new Date(event.timestamp).getTime() - startTime) / 1000);
}}
>
{/* The Stem */}
<div className={cn(
"w-px transition-all duration-200 bg-border group-hover/event:bg-primary group-hover/event:h-full",
isTop ? "h-8 mb-auto" : "h-8 mt-auto"
)} />
{/* The Node */}
<div className={cn(
"absolute w-6 h-6 rounded-full border shadow-sm flex items-center justify-center transition-transform hover:scale-110 cursor-pointer bg-background z-10",
getEventColor(event.eventType),
isTop ? "-top-2" : "-bottom-2"
)}>
{getEventIcon(event.eventType)}
</div>
</div>
</TooltipTrigger>
<TooltipContent side={isTop ? "top" : "bottom"}>
<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>
{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)}
</div>
)}
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
interface TrialEvent {
eventType: string;
timestamp: Date;
data?: unknown;
}
interface PlaybackContextType {
// State
currentTime: number;
duration: number;
isPlaying: boolean;
playbackRate: number;
startTime?: 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
// 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;
}
interface PlaybackProviderProps {
children: React.ReactNode;
events?: TrialEvent[];
startTime?: Date;
}
export function PlaybackProvider({ children, events = [], startTime }: PlaybackProviderProps) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
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;
// 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;
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);
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,
};
return (
<PlaybackContext.Provider value={value}>
{children}
</PlaybackContext.Provider>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import React, { useRef, useEffect } from "react";
import { usePlayback } from "./PlaybackContext";
import { AspectRatio } from "~/components/ui/aspect-ratio";
import { Loader2, Play, Pause, Volume2, VolumeX, Maximize } from "lucide-react";
import { Slider } from "~/components/ui/slider";
import { Button } from "~/components/ui/button";
interface PlaybackPlayerProps {
src: string;
}
export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
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);
// 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]);
// 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;
if (Math.abs(video.currentTime - currentTime) > 0.5) {
video.currentTime = currentTime;
}
}, [currentTime]);
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
setIsBuffering(false);
}
};
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}
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) */}
<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;
setCurrentTime(val);
}
}}
className="cursor-pointer"
/>
</div>
<div className="text-xs font-mono 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>
</div>
);
}
function formatTime(seconds: number) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}

View File

@@ -1,10 +1,18 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { LineChart, BarChart, Clock, Database, FileText } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react";
import { PlaybackProvider } from "../playback/PlaybackContext";
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
import { EventTimeline } from "../playback/EventTimeline";
import { api } from "~/trpc/react";
import { ScrollArea } from "~/components/ui/scroll-area";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
interface TrialAnalysisViewProps {
trial: {
@@ -17,108 +25,165 @@ interface TrialAnalysisViewProps {
participant: { participantCode: string };
eventCount?: number;
mediaCount?: number;
media?: { url: string; contentType: string }[];
};
}
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
// Fetch events for timeline
const { data: events = [] } = api.trials.getEvents.useQuery({
trialId: trial.id,
limit: 1000
});
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
const videoUrl = videoMedia?.url;
return (
<div className="container mx-auto p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Status</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold capitalize">{trial.status.replace("_", " ")}</div>
<p className="text-xs text-muted-foreground">
{trial.completedAt
? `Completed ${formatDistanceToNow(new Date(trial.completedAt), { addSuffix: true })}`
: "Not completed"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Duration</CardTitle>
<BarChart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trial.duration ? `${Math.floor(trial.duration / 60)}m ${trial.duration % 60}s` : "N/A"}
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
<div className="h-[calc(100vh-8rem)] flex flex-col bg-background rounded-lg border shadow-sm overflow-hidden">
{/* Header Context */}
<div className="flex items-center justify-between p-3 border-b bg-muted/20 flex-none h-14">
<div className="flex items-center gap-4">
<div className="flex flex-col">
<h1 className="text-base font-semibold leading-none">
{trial.experiment.name}
</h1>
<p className="text-xs text-muted-foreground mt-1">
{trial.participant.participantCode} Session {trial.id.slice(0, 4)}...
</p>
</div>
<p className="text-xs text-muted-foreground">
Total execution time
</p>
</CardContent>
</Card>
<div className="h-8 w-px bg-border" />
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
<span>{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}</span>
</div>
{trial.duration && (
<Badge variant="secondary" className="text-[10px] font-mono">
{Math.floor(trial.duration / 60)}m {trial.duration % 60}s
</Badge>
)}
</div>
</div>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Events Logged</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{trial.eventCount ?? 0}</div>
<p className="text-xs text-muted-foreground">
System & user events
</p>
</CardContent>
</Card>
{/* Main Resizable Workspace */}
<div className="flex-1 min-h-0">
<ResizablePanelGroup direction="horizontal">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Media Files</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{trial.mediaCount ?? 0}</div>
<p className="text-xs text-muted-foreground">
Recordings & snapshots
</p>
</CardContent>
</Card>
{/* LEFT: Video & Timeline */}
<ResizablePanel defaultSize={65} minSize={30} className="flex flex-col min-h-0">
<ResizablePanelGroup direction="vertical">
{/* Top: Video Player */}
<ResizablePanel defaultSize={75} minSize={20} className="bg-black relative">
{videoUrl ? (
<div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} />
</div>
) : (
<div className="h-full w-full flex flex-col items-center justify-center text-slate-500">
<VideoOff className="h-12 w-12 mb-3 opacity-20" />
<p className="text-sm">No recording available.</p>
</div>
)}
</ResizablePanel>
<ResizableHandle withHandle />
{/* Bottom: Timeline Track */}
<ResizablePanel defaultSize={25} minSize={10} className="bg-background flex flex-col min-h-0">
<div className="p-2 border-b flex-none bg-muted/10 flex items-center gap-2">
<Info className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Timeline Track</span>
</div>
<div className="flex-1 min-h-0 relative">
<div className="absolute inset-0 p-2 overflow-hidden">
<EventTimeline />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
{/* RIGHT: Logs & Metrics */}
<ResizablePanel defaultSize={35} minSize={20} className="flex flex-col min-h-0 border-l bg-muted/5">
{/* Metrics Strip */}
<div className="grid grid-cols-2 gap-2 p-3 border-b bg-background flex-none">
<Card className="shadow-none border-dashed bg-transparent">
<CardContent className="p-3 py-2">
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Interventions</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{events.filter(e => e.eventType.includes("intervention")).length}
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
</div>
</CardContent>
</Card>
<Card className="shadow-none border-dashed bg-transparent">
<CardContent className="p-3 py-2">
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Status</div>
<div className="text-xl font-mono font-bold flex items-center gap-2">
{trial.status === 'completed' ? 'PASS' : 'INC'}
<div className={`h-2 w-2 rounded-full ${trial.status === 'completed' ? 'bg-green-500' : 'bg-orange-500'}`} />
</div>
</CardContent>
</Card>
</div>
{/* Log Title */}
<div className="p-2 px-3 border-b bg-muted/20 flex items-center justify-between flex-none">
<span className="text-xs font-semibold flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-primary" />
Event Log
</span>
<Badge variant="outline" className="text-[10px] h-5">{events.length} Events</Badge>
</div>
{/* Scrollable Event List */}
<div className="flex-1 min-h-0 relative bg-background/50">
<ScrollArea className="h-full">
<div className="divide-y divide-border/50">
{events.map((event, i) => (
<div key={i} className="p-3 py-2 text-sm hover:bg-accent/50 transition-colors cursor-pointer group flex gap-3 items-start">
<div className="font-mono text-[10px] text-muted-foreground mt-0.5 min-w-[3rem]">
{formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium text-xs text-foreground group-hover:text-primary transition-colors">
{event.eventType.replace(/_/g, " ")}
</span>
</div>
{event.data && (
<div className="text-[10px] text-muted-foreground bg-muted p-1.5 rounded border font-mono whitespace-pre-wrap break-all opacity-80 group-hover:opacity-100">
{JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
</div>
)}
</div>
</div>
))}
{events.length === 0 && (
<div className="p-8 text-center text-xs text-muted-foreground italic">
No events found in log.
</div>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="events">Event Log</TabsTrigger>
<TabsTrigger value="charts">Charts</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Analysis Overview</CardTitle>
<CardDescription>
Summary of trial execution for {trial.participant.participantCode} in experiment {trial.experiment.name}.
</CardDescription>
</CardHeader>
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
<div className="text-center text-muted-foreground">
<LineChart className="h-10 w-10 mx-auto mb-2 opacity-20" />
<p>Detailed analysis visualizations coming soon.</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="events">
<Card>
<CardHeader>
<CardTitle>Event Log</CardTitle>
<CardDescription>
Chronological record of all trial events.
</CardDescription>
</CardHeader>
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
<div className="text-center text-muted-foreground">
<p>Event log view placeholder.</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</PlaybackProvider>
);
}
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")}`;
}

View File

@@ -1,366 +0,0 @@
"use client";
import {
Activity,
AlertTriangle,
Battery,
BatteryLow,
Bot,
CheckCircle,
Clock,
RefreshCw,
Signal,
SignalHigh,
SignalLow,
SignalMedium,
WifiOff,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
interface RobotStatusProps {
trialId: string;
}
interface RobotStatus {
id: string;
name: string;
connectionStatus: "connected" | "disconnected" | "connecting" | "error";
batteryLevel?: number;
signalStrength?: number;
currentMode: string;
lastHeartbeat?: Date;
errorMessage?: string;
capabilities: string[];
communicationProtocol: string;
isMoving: boolean;
position?: {
x: number;
y: number;
z?: number;
orientation?: number;
};
sensors?: Record<string, string>;
}
export function RobotStatus({ trialId: _trialId }: RobotStatusProps) {
const [robotStatus, setRobotStatus] = useState<RobotStatus | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [refreshing, setRefreshing] = useState(false);
// Mock robot status - in real implementation, this would come from API/WebSocket
useEffect(() => {
// Simulate robot status updates
const mockStatus: RobotStatus = {
id: "robot_001",
name: "TurtleBot3 Burger",
connectionStatus: "connected",
batteryLevel: 85,
signalStrength: 75,
currentMode: "autonomous_navigation",
lastHeartbeat: new Date(),
capabilities: ["navigation", "manipulation", "speech", "vision"],
communicationProtocol: "ROS2",
isMoving: false,
position: {
x: 1.2,
y: 0.8,
orientation: 45,
},
sensors: {
lidar: "operational",
camera: "operational",
imu: "operational",
odometry: "operational",
},
};
setRobotStatus(mockStatus);
// Simulate periodic updates
const interval = setInterval(() => {
setRobotStatus((prev) => {
if (!prev) return prev;
return {
...prev,
batteryLevel: Math.max(
0,
(prev.batteryLevel ?? 0) - Math.random() * 0.5,
),
signalStrength: Math.max(
0,
Math.min(
100,
(prev.signalStrength ?? 0) + (Math.random() - 0.5) * 10,
),
),
lastHeartbeat: new Date(),
position: prev.position
? {
...prev.position,
x: prev.position.x + (Math.random() - 0.5) * 0.1,
y: prev.position.y + (Math.random() - 0.5) * 0.1,
}
: undefined,
};
});
setLastUpdate(new Date());
}, 3000);
return () => clearInterval(interval);
}, []);
const getConnectionStatusConfig = (status: string) => {
switch (status) {
case "connected":
return {
icon: CheckCircle,
color: "text-green-600",
bgColor: "bg-green-100",
label: "Connected",
};
case "connecting":
return {
icon: RefreshCw,
color: "text-blue-600",
bgColor: "bg-blue-100",
label: "Connecting",
};
case "disconnected":
return {
icon: WifiOff,
color: "text-gray-600",
bgColor: "bg-gray-100",
label: "Disconnected",
};
case "error":
return {
icon: AlertTriangle,
color: "text-red-600",
bgColor: "bg-red-100",
label: "Error",
};
default:
return {
icon: WifiOff,
color: "text-gray-600",
bgColor: "bg-gray-100",
label: "Unknown",
};
}
};
const getSignalIcon = (strength: number) => {
if (strength >= 75) return SignalHigh;
if (strength >= 50) return SignalMedium;
if (strength >= 25) return SignalLow;
return Signal;
};
const getBatteryIcon = (level: number) => {
return level <= 20 ? BatteryLow : Battery;
};
const handleRefreshStatus = async () => {
setRefreshing(true);
// Simulate API call
setTimeout(() => {
setRefreshing(false);
setLastUpdate(new Date());
}, 1000);
};
if (!robotStatus) {
return (
<div className="space-y-4">
<div className="rounded-lg border p-4 text-center">
<div className="text-slate-500">
<Bot className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="text-sm">No robot connected</p>
</div>
</div>
</div>
);
}
const statusConfig = getConnectionStatusConfig(robotStatus.connectionStatus);
const StatusIcon = statusConfig.icon;
const SignalIcon = getSignalIcon(robotStatus.signalStrength ?? 0);
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel ?? 0);
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="sm"
onClick={handleRefreshStatus}
disabled={refreshing}
>
<RefreshCw
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
</div>
{/* Main Status Card */}
<div className="rounded-lg border p-4">
<div className="space-y-3">
{/* Robot Info */}
<div className="flex items-center justify-between">
<div className="font-medium text-slate-900">{robotStatus.name}</div>
<Badge
className={`${statusConfig.bgColor} ${statusConfig.color}`}
variant="secondary"
>
<StatusIcon className="mr-1 h-3 w-3" />
{statusConfig.label}
</Badge>
</div>
{/* Connection Details */}
<div className="text-sm text-slate-600">
Protocol: {robotStatus.communicationProtocol}
</div>
{/* Status Indicators */}
<div className="grid grid-cols-2 gap-3">
{/* Battery */}
{robotStatus.batteryLevel !== undefined && (
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-slate-600">
<BatteryIcon className="h-3 w-3" />
<span>Battery</span>
</div>
<div className="flex items-center space-x-2">
<Progress
value={robotStatus.batteryLevel}
className="h-1.5 flex-1"
/>
<span className="w-8 text-xs font-medium">
{Math.round(robotStatus.batteryLevel)}%
</span>
</div>
</div>
)}
{/* Signal Strength */}
{robotStatus.signalStrength !== undefined && (
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-slate-600">
<SignalIcon className="h-3 w-3" />
<span>Signal</span>
</div>
<div className="flex items-center space-x-2">
<Progress
value={robotStatus.signalStrength}
className="h-1.5 flex-1"
/>
<span className="w-8 text-xs font-medium">
{Math.round(robotStatus.signalStrength)}%
</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* Current Mode */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Activity className="h-3 w-3 text-slate-600" />
<span className="text-sm text-slate-600">Mode:</span>
</div>
<Badge variant="outline" className="text-xs">
{robotStatus.currentMode
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())}
</Badge>
</div>
{robotStatus.isMoving && (
<div className="mt-2 flex items-center space-x-1 text-xs">
<div className="h-1.5 w-1.5 animate-pulse rounded-full"></div>
<span>Robot is moving</span>
</div>
)}
</div>
{/* Position Info */}
{robotStatus.position && (
<div className="rounded-lg border p-4">
<div className="mb-3 text-sm font-medium text-slate-700">
Position
</div>
<div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-slate-600">X:</span>
<span className="font-mono">
{robotStatus.position.x.toFixed(2)}m
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Y:</span>
<span className="font-mono">
{robotStatus.position.y.toFixed(2)}m
</span>
</div>
{robotStatus.position.orientation !== undefined && (
<div className="col-span-2 flex justify-between">
<span className="text-slate-600">Orientation:</span>
<span className="font-mono">
{Math.round(robotStatus.position.orientation)}°
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Sensors Status */}
{robotStatus.sensors && (
<div className="rounded-lg border p-4">
<div className="mb-3 text-sm font-medium text-slate-700">Sensors</div>
<div>
<div className="space-y-1">
{Object.entries(robotStatus.sensors).map(([sensor, status]) => (
<div
key={sensor}
className="flex items-center justify-between text-xs"
>
<span className="text-slate-600 capitalize">{sensor}:</span>
<Badge variant="outline" className="text-xs">
{status}
</Badge>
</div>
))}
</div>
</div>
</div>
)}
{/* Error Alert */}
{robotStatus.errorMessage && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
{robotStatus.errorMessage}
</AlertDescription>
</Alert>
)}
{/* Last Update */}
<div className="flex items-center space-x-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
<span>Last update: {lastUpdate.toLocaleTimeString()}</span>
</div>
</div>
);
}

View File

@@ -195,21 +195,28 @@ export const WizardInterface = React.memo(function WizardInterface({
}
);
// Update local trial state from polling
// Update local trial state from polling only if changed
useEffect(() => {
if (pollingData) {
setTrial((prev) => ({
...prev,
status: pollingData.status,
startedAt: pollingData.startedAt
? new Date(pollingData.startedAt)
: prev.startedAt,
completedAt: pollingData.completedAt
? new Date(pollingData.completedAt)
: prev.completedAt,
}));
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 ||
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
setTrial((prev) => ({
...prev,
status: pollingData.status,
startedAt: pollingData.startedAt
? new Date(pollingData.startedAt)
: prev.startedAt,
completedAt: pollingData.completedAt
? new Date(pollingData.completedAt)
: prev.completedAt,
}));
}
}
}, [pollingData]);
}, [pollingData, trial]);
// Auto-start trial on mount if scheduled
useEffect(() => {
@@ -675,6 +682,7 @@ export const WizardInterface = React.memo(function WizardInterface({
onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending}
onSetAutonomousLife={setAutonomousLife}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
}
center={
@@ -695,6 +703,7 @@ export const WizardInterface = React.memo(function WizardInterface({
completedActionsCount={completedActionsCount}
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
}
right={
@@ -706,6 +715,7 @@ export const WizardInterface = React.memo(function WizardInterface({
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
}
showDividers={true}
@@ -720,6 +730,9 @@ export const WizardInterface = React.memo(function WizardInterface({
onAddAnnotation={handleAddAnnotation}
isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents}
// Observation pane is where observers usually work, so not readOnly for them?
// But maybe we want 'readOnly' for completed trials.
readOnly={trial.status === 'completed'}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@@ -0,0 +1,268 @@
"use client";
import React, { useCallback, useRef, useState } from "react";
import Webcam from "react-webcam";
import { Camera, CameraOff, Video, StopCircle, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { AspectRatio } from "~/components/ui/aspect-ratio";
import { toast } from "sonner";
import { api } from "~/trpc/react";
export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
const [deviceId, setDeviceId] = useState<string | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
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[]>([]);
// TRPC mutation for presigned URL
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
const handleDevices = useCallback(
(mediaDevices: MediaDeviceInfo[]) => {
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
},
[setDevices],
);
React.useEffect(() => {
navigator.mediaDevices.enumerateDevices().then(handleDevices);
}, [handleDevices]);
const handleEnableCamera = () => {
setIsCameraEnabled(true);
setError(null);
};
const handleDisableCamera = () => {
if (isRecording) {
handleStopRecording();
}
setIsCameraEnabled(false);
};
const handleStartRecording = () => {
if (!webcamRef.current?.stream) 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);
}
};
recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: "video/webm" });
await handleUpload(blob);
};
recorder.start();
mediaRecorderRef.current = recorder;
toast.success("Recording started");
} catch (e) {
console.error("Failed to start recorder:", e);
toast.error("Failed to start recording");
setIsRecording(false);
}
};
const handleStopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
};
const handleUpload = async (blob: Blob) => {
setUploading(true);
const filename = `recording-${Date.now()}.webm`;
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) {
throw new Error("Upload failed");
}
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-between border-b p-3">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Camera className="h-4 w-4" />
Webcam Feed
</h2>
{!readOnly && (
<div className="flex items-center gap-2">
{devices.length > 0 && (
<Select
value={deviceId ?? undefined}
onValueChange={setDeviceId}
disabled={!isCameraEnabled || isRecording}
>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="Select Camera" />
</SelectTrigger>
<SelectContent>
{devices.map((device, key) => (
<SelectItem key={key} value={device.deviceId} className="text-xs">
{device.label || `Camera ${key + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{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>
)}
</div>
<div className="flex-1 overflow-hidden bg-black p-4 flex items-center justify-center relative">
{isCameraEnabled ? (
<div className="w-full relative rounded-lg overflow-hidden border border-slate-800">
<AspectRatio ratio={16 / 9}>
<Webcam
ref={webcamRef}
audio={false}
width="100%"
height="100%"
videoConstraints={{ deviceId: deviceId ?? undefined }}
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-slate-500">
<CameraOff className="mx-auto mb-2 h-12 w-12 opacity-20" />
<p className="text-sm">Camera is disabled</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={handleEnableCamera}
>
Enable Camera
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -98,6 +98,7 @@ interface WizardControlPanelProps {
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
isStarting?: boolean;
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
readOnly?: boolean;
}
export function WizardControlPanel({
@@ -118,6 +119,7 @@ export function WizardControlPanel({
onTabChange,
isStarting = false,
onSetAutonomousLife,
readOnly = false,
}: WizardControlPanelProps) {
const [autonomousLife, setAutonomousLife] = React.useState(true);
@@ -187,7 +189,7 @@ export function WizardControlPanel({
}}
className="w-full"
size="sm"
disabled={isStarting}
disabled={isStarting || readOnly}
>
<Play className="mr-2 h-4 w-4" />
{isStarting ? "Starting..." : "Start Trial"}
@@ -201,14 +203,14 @@ export function WizardControlPanel({
onClick={onPauseTrial}
variant="outline"
size="sm"
disabled={false}
disabled={readOnly}
>
<Pause className="mr-1 h-3 w-3" />
Pause
</Button>
<Button
onClick={onNextStep}
disabled={currentStepIndex >= steps.length - 1}
disabled={(currentStepIndex >= steps.length - 1) || readOnly}
size="sm"
>
<SkipForward className="mr-1 h-3 w-3" />
@@ -223,6 +225,7 @@ export function WizardControlPanel({
variant="outline"
className="w-full"
size="sm"
disabled={readOnly}
>
<CheckCircle className="mr-2 h-4 w-4" />
Complete Trial
@@ -233,6 +236,7 @@ export function WizardControlPanel({
variant="destructive"
className="w-full"
size="sm"
disabled={readOnly}
>
<X className="mr-2 h-4 w-4" />
Abort Trial
@@ -277,7 +281,7 @@ export function WizardControlPanel({
id="autonomous-life"
checked={autonomousLife}
onCheckedChange={handleAutonomousLifeChange}
disabled={!_isConnected}
disabled={!_isConnected || readOnly}
className="scale-75"
/>
</div>
@@ -368,7 +372,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Acknowledge clicked");
onExecuteAction("acknowledge");
}}
disabled={false}
disabled={readOnly}
>
<CheckCircle className="mr-2 h-3 w-3" />
Acknowledge
@@ -382,7 +386,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Intervene clicked");
onExecuteAction("intervene");
}}
disabled={false}
disabled={readOnly}
>
<AlertCircle className="mr-2 h-3 w-3" />
Intervene
@@ -396,7 +400,7 @@ export function WizardControlPanel({
console.log("[WizardControlPanel] Add Note clicked");
onExecuteAction("note", { content: "Wizard note" });
}}
disabled={false}
disabled={readOnly}
>
<User className="mr-2 h-3 w-3" />
Add Note
@@ -412,7 +416,7 @@ export function WizardControlPanel({
size="sm"
className="w-full justify-start"
onClick={() => onExecuteAction("step_complete")}
disabled={false}
disabled={readOnly}
>
<CheckCircle className="mr-2 h-3 w-3" />
Mark Complete
@@ -441,11 +445,13 @@ export function WizardControlPanel({
<ScrollArea className="h-full">
<div className="p-3">
{studyId && onExecuteRobotAction ? (
<RobotActionsPanel
studyId={studyId}
trialId={trial.id}
onExecuteAction={onExecuteRobotAction}
/>
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
<RobotActionsPanel
studyId={studyId}
trialId={trial.id}
onExecuteAction={onExecuteRobotAction}
/>
</div>
) : (
<Alert>
<AlertCircle className="h-4 w-4" />

View File

@@ -10,18 +10,13 @@ import {
User,
Activity,
Zap,
Eye,
List,
Loader2,
ArrowRight,
AlertTriangle,
RotateCcw,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface StepData {
id: string;
@@ -107,6 +102,7 @@ interface WizardExecutionPanelProps {
onCompleteTrial?: () => void;
completedActionsCount: number;
onActionCompleted: () => void;
readOnly?: boolean;
}
export function WizardExecutionPanel({
@@ -126,47 +122,13 @@ export function WizardExecutionPanel({
onCompleteTrial,
completedActionsCount,
onActionCompleted,
readOnly = false,
}: WizardExecutionPanelProps) {
// Local state removed in favor of parent state to prevent reset on re-render
// const [completedCount, setCompletedCount] = React.useState(0);
const activeActionIndex = completedActionsCount;
const getStepIcon = (type: string) => {
switch (type) {
case "wizard_action":
return User;
case "robot_action":
return Bot;
case "parallel_steps":
return Activity;
case "conditional_branch":
return AlertCircle;
default:
return Play;
}
};
const getStepStatus = (stepIndex: number) => {
if (stepIndex < currentStepIndex) return "completed";
if (stepIndex === currentStepIndex && trial.status === "in_progress")
return "active";
return "pending";
};
const getStepVariant = (status: string) => {
switch (status) {
case "completed":
return "default";
case "active":
return "secondary";
case "pending":
return "outline";
default:
return "outline";
}
};
// Pre-trial state
if (trial.status === "scheduled") {
return (
@@ -252,7 +214,7 @@ export function WizardExecutionPanel({
</div>
{/* Simplified Content - Sequential Focus */}
<div className="flex-1 overflow-hidden">
<div className="relative flex-1 overflow-hidden">
<ScrollArea className="h-full">
{currentStep ? (
<div className="flex flex-col gap-6 p-6">
@@ -281,7 +243,6 @@ export function WizardExecutionPanel({
{currentStep.actions.map((action, idx) => {
const isCompleted = idx < activeActionIndex;
const isActive = idx === activeActionIndex;
const isPending = idx > activeActionIndex;
return (
<div
@@ -328,6 +289,7 @@ export function WizardExecutionPanel({
);
onActionCompleted();
}}
disabled={readOnly}
>
Skip
</Button>
@@ -348,6 +310,7 @@ export function WizardExecutionPanel({
);
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
<Play className="mr-2 h-4 w-4" />
Execute
@@ -364,6 +327,7 @@ export function WizardExecutionPanel({
e.preventDefault();
onActionCompleted();
}}
disabled={readOnly || isExecuting}
>
Mark Done
</Button>
@@ -394,6 +358,7 @@ export function WizardExecutionPanel({
{ autoAdvance: false },
);
}}
disabled={readOnly || isExecuting}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
@@ -410,6 +375,7 @@ export function WizardExecutionPanel({
category: "system_issue"
});
}}
disabled={readOnly}
>
<AlertTriangle className="h-3.5 w-3.5" />
</Button>
@@ -432,6 +398,7 @@ export function WizardExecutionPanel({
? "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"}
<ArrowRight className="ml-2 h-5 w-5" />
@@ -445,22 +412,15 @@ export function WizardExecutionPanel({
{currentStep.type === "wizard_action" && (
<div className="rounded-xl border border-dashed p-6 space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3">
<Button
variant="outline"
className="h-12 justify-start"
onClick={() => onExecuteAction("acknowledge")}
>
<CheckCircle className="mr-2 h-4 w-4" />
Acknowledge
</Button>
<Button
variant="outline"
className="h-12 justify-start"
className="h-12 justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
onClick={() => onExecuteAction("intervene")}
disabled={readOnly}
>
<Zap className="mr-2 h-4 w-4" />
Intervene
Flag Issue / Intervention
</Button>
</div>
</div>
@@ -472,6 +432,8 @@ export function WizardExecutionPanel({
</div>
)}
</ScrollArea>
{/* Scroll Hint Fade */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-background to-transparent z-10" />
</div>
</div >
);

View File

@@ -11,8 +11,8 @@ import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Progress } from "~/components/ui/progress";
import { Button } from "~/components/ui/button";
import { WebcamPanel } from "./WebcamPanel";
interface WizardMonitoringPanelProps {
rosConnected: boolean;
@@ -33,6 +33,7 @@ interface WizardMonitoringPanelProps {
actionId: string,
parameters: Record<string, unknown>,
) => Promise<unknown>;
readOnly?: boolean;
}
const WizardMonitoringPanel = function WizardMonitoringPanel({
@@ -43,296 +44,315 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
connectRos,
disconnectRos,
executeRosAction,
readOnly = false,
}: WizardMonitoringPanelProps) {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b p-3">
<h2 className="text-sm font-semibold">Robot Control</h2>
<div className="flex h-full flex-col gap-2 p-2">
{/* Camera View - Always Visible */}
<div className="shrink-0 bg-black rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
<WebcamPanel readOnly={readOnly} />
</div>
{/* Robot Status and Controls */}
<ScrollArea className="flex-1">
<div className="space-y-4 p-3">
{/* Robot Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Robot Status</div>
<div className="flex items-center gap-1">
{rosConnected ? (
<Power className="h-3 w-3 text-green-600" />
) : (
<PowerOff className="h-3 w-3 text-gray-400" />
)}
</div>
</div>
{/* 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">
<Bot className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Robot Control</span>
</div>
<ScrollArea className="flex-1">
<div className="space-y-4 p-3">
{/* Robot Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
ROS Bridge
</span>
<div className="text-sm font-medium">Robot Status</div>
<div className="flex items-center gap-1">
<Badge
variant={
rosConnected
? "default"
: rosError
? "destructive"
: "outline"
}
className="text-xs"
>
{rosConnecting
? "Connecting..."
: rosConnected
? "Ready"
: rosError
? "Failed"
: "Offline"}
</Badge>
{rosConnected && (
<span className="animate-pulse text-xs text-green-600">
</span>
)}
{rosConnecting && (
<span className="animate-spin text-xs text-blue-600">
</span>
{rosConnected ? (
<Power className="h-3 w-3 text-green-600" />
) : (
<PowerOff className="h-3 w-3 text-gray-400" />
)}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
ROS Bridge
</span>
<div className="flex items-center gap-1">
<Badge
variant={
rosConnected
? "default"
: rosError
? "destructive"
: "outline"
}
className="text-xs"
>
{rosConnecting
? "Connecting..."
: rosConnected
? "Ready"
: rosError
? "Failed"
: "Offline"}
</Badge>
{rosConnected && (
<span className="animate-pulse text-xs text-green-600">
</span>
)}
{rosConnecting && (
<span className="animate-spin text-xs text-blue-600">
</span>
)}
</div>
</div>
</div>
{/* ROS Connection Controls */}
<div className="pt-2">
{!rosConnected ? (
<Button
size="sm"
variant="outline"
className="w-full text-xs"
onClick={() => connectRos()}
disabled={rosConnecting || rosConnected}
>
<Bot className="mr-1 h-3 w-3" />
{rosConnecting
? "Connecting..."
: rosConnected
? "Connected ✓"
: "Connect to NAO6"}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs"
onClick={() => disconnectRos()}
>
<PowerOff className="mr-1 h-3 w-3" />
Disconnect
</Button>
{/* ROS Connection Controls */}
<div className="pt-2">
{!rosConnected ? (
<Button
size="sm"
variant="outline"
className="w-full text-xs"
onClick={() => connectRos()}
disabled={rosConnecting || rosConnected || readOnly}
>
<Bot className="mr-1 h-3 w-3" />
{rosConnecting
? "Connecting..."
: rosConnected
? "Connected ✓"
: "Connect to NAO6"}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="w-full text-xs"
onClick={() => disconnectRos()}
disabled={readOnly}
>
<PowerOff className="mr-1 h-3 w-3" />
Disconnect
</Button>
)}
</div>
{rosError && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
{rosError}
</AlertDescription>
</Alert>
)}
{!rosConnected && !rosConnecting && (
<div className="mt-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
Connect to ROS bridge for live robot monitoring and
control.
</AlertDescription>
</Alert>
</div>
)}
</div>
{rosError && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
{rosError}
</AlertDescription>
</Alert>
<Separator />
{/* Movement Controls */}
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Movement</div>
<div className="grid grid-cols-3 gap-2">
{/* Row 1: Turn Left, Forward, Turn Right */}
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "turn_left", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Turn L
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_forward", {
speed: 0.5,
}).catch(console.error);
}}
disabled={readOnly}
>
Forward
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "turn_right", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Turn R
</Button>
{/* Row 2: Left, Stop, Right */}
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_left", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Left
</Button>
<Button
size="sm"
variant="destructive"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
console.error,
);
}}
disabled={readOnly}
>
Stop
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_right", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Right
</Button>
{/* Row 3: Empty, Back, Empty */}
<div></div>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_backward", {
speed: 0.3,
}).catch(console.error);
}}
disabled={readOnly}
>
Back
</Button>
<div></div>
</div>
</div>
)}
{!rosConnected && !rosConnecting && (
<div className="mt-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
Connect to ROS bridge for live robot monitoring and
control.
</AlertDescription>
</Alert>
<Separator />
{/* Quick Actions */}
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Quick Actions</div>
{/* TTS Input */}
<div className="flex gap-2">
<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"
disabled={readOnly}
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim() && !readOnly) {
executeRosAction("nao6-ros2", "say_text", {
text: e.currentTarget.value.trim(),
}).catch(console.error);
e.currentTarget.value = "";
}
}}
/>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={(e) => {
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
if (input?.value.trim()) {
executeRosAction("nao6-ros2", "say_text", {
text: input.value.trim(),
}).catch(console.error);
input.value = "";
}
}}
disabled={readOnly}
>
Say
</Button>
</div>
{/* Preset Actions */}
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Hello! I am NAO!",
}).catch(console.error);
}
}}
disabled={readOnly}
>
Say Hello
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "I am ready!",
}).catch(console.error);
}
}}
disabled={readOnly}
>
Say Ready
</Button>
</div>
</div>
)}
</div>
<Separator />
{/* Movement Controls */}
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Movement</div>
<div className="grid grid-cols-3 gap-2">
{/* Row 1: Turn Left, Forward, Turn Right */}
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "turn_left", {
speed: 0.3,
}).catch(console.error);
}}
>
Turn L
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_forward", {
speed: 0.5,
}).catch(console.error);
}}
>
Forward
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "turn_right", {
speed: 0.3,
}).catch(console.error);
}}
>
Turn R
</Button>
{/* Row 2: Left, Stop, Right */}
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_left", {
speed: 0.3,
}).catch(console.error);
}}
>
Left
</Button>
<Button
size="sm"
variant="destructive"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
console.error,
);
}}
>
Stop
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "strafe_right", {
speed: 0.3,
}).catch(console.error);
}}
>
Right
</Button>
{/* Row 3: Empty, Back, Empty */}
<div></div>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
executeRosAction("nao6-ros2", "walk_backward", {
speed: 0.3,
}).catch(console.error);
}}
>
Back
</Button>
<div></div>
</div>
</div>
)}
<Separator />
{/* Quick Actions */}
{rosConnected && (
<div className="space-y-2">
<div className="text-sm font-medium">Quick Actions</div>
{/* TTS Input */}
<div className="flex gap-2">
<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"
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
executeRosAction("nao6-ros2", "say_text", {
text: e.currentTarget.value.trim(),
}).catch(console.error);
e.currentTarget.value = "";
}
}}
/>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={(e) => {
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
if (input?.value.trim()) {
executeRosAction("nao6-ros2", "say_text", {
text: input.value.trim(),
}).catch(console.error);
input.value = "";
}
}}
>
Say
</Button>
</div>
{/* Preset Actions */}
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "Hello! I am NAO!",
}).catch(console.error);
}
}}
>
Say Hello
</Button>
<Button
size="sm"
variant="outline"
className="text-xs"
onClick={() => {
if (rosConnected) {
executeRosAction("nao6-ros2", "say_text", {
text: "I am ready!",
}).catch(console.error);
}
}}
>
Say Ready
</Button>
</div>
</div>
)}
</div>
</ScrollArea>
</ScrollArea>
</div>
</div>
);
};