feat: Enhance trial event display with improved formatting and icons, refine trial wizard panels, and update dashboard page layouts.

This commit is contained in:
2026-02-20 00:37:33 -05:00
parent 72971a4b49
commit 60d4fae72c
20 changed files with 1202 additions and 688 deletions

View File

@@ -16,8 +16,19 @@ import { Separator } from "~/components/ui/separator";
import { PageHeader } from "~/components/ui/page-header"; import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { formatRole, getRoleDescription } from "~/lib/auth-client"; import { formatRole, getRoleDescription } from "~/lib/auth-client";
import { User, Shield, Download, Trash2, ExternalLink } from "lucide-react"; import {
User,
Shield,
Download,
Trash2,
ExternalLink,
Lock,
UserCog,
Mail,
Fingerprint
} from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { cn } from "~/lib/utils";
interface ProfileUser { interface ProfileUser {
id: string; id: string;
@@ -32,185 +43,141 @@ interface ProfileUser {
function ProfileContent({ user }: { user: ProfileUser }) { function ProfileContent({ user }: { user: ProfileUser }) {
return ( return (
<div className="space-y-6"> <div className="space-y-8 animate-in fade-in duration-500">
<PageHeader <PageHeader
title="Profile" title={user.name ?? "User"}
description="Manage your account settings and preferences" description={user.email}
icon={User} icon={User}
badges={[
{ label: `ID: ${user.id}`, variant: "outline" },
...(user.roles?.map((r) => ({
label: formatRole(r.role),
variant: "secondary" as const,
})) ?? []),
]}
/> />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Profile Information */} {/* Main Content (Left Column) */}
<div className="space-y-6 lg:col-span-2"> <div className="space-y-8 lg:col-span-2">
{/* Basic Information */}
<Card className="hover:shadow-md transition-shadow duration-200">
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
Your personal account information
</CardDescription>
</CardHeader>
<CardContent>
<ProfileEditForm
user={{
id: user.id,
name: user.name,
email: user.email,
image: user.image,
}}
/>
</CardContent>
</Card>
{/* Password Change */} {/* Personal Information */}
<Card className="hover:shadow-md transition-shadow duration-200"> <section className="space-y-4">
<CardHeader> <div className="flex items-center gap-2 pb-2 border-b">
<CardTitle>Password</CardTitle> <User className="h-5 w-5 text-primary" />
<CardDescription>Change your account password</CardDescription> <h3 className="text-lg font-semibold">Personal Information</h3>
</CardHeader> </div>
<CardContent> <Card className="border-border/60 hover:border-border transition-colors">
<PasswordChangeForm /> <CardHeader>
</CardContent> <CardTitle className="text-base">Contact Details</CardTitle>
</Card> <CardDescription>Update your public profile information</CardDescription>
</CardHeader>
<CardContent>
<ProfileEditForm
user={{
id: user.id,
name: user.name,
email: user.email,
image: user.image,
}}
/>
</CardContent>
</Card>
</section>
{/* Account Actions */} {/* Security */}
<Card> <section className="space-y-4">
<CardHeader> <div className="flex items-center gap-2 pb-2 border-b">
<CardTitle>Account Actions</CardTitle> <Lock className="h-5 w-5 text-primary" />
<CardDescription>Manage your account settings</CardDescription> <h3 className="text-lg font-semibold">Security</h3>
</CardHeader> </div>
<CardContent className="space-y-4"> <Card className="border-border/60 hover:border-border transition-colors">
<div className="flex items-center justify-between"> <CardHeader>
<div> <CardTitle className="text-base">Password</CardTitle>
<h4 className="text-sm font-medium">Export Data</h4> <CardDescription>Ensure your account stays secure</CardDescription>
<p className="text-muted-foreground text-sm"> </CardHeader>
Download all your research data and account information <CardContent>
</p> <PasswordChangeForm />
</div> </CardContent>
<Button variant="outline" disabled> </Card>
<Download className="mr-2 h-4 w-4" /> </section>
Export Data
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<h4 className="text-destructive text-sm font-medium">
Delete Account
</h4>
<p className="text-muted-foreground text-sm">
Permanently delete your account and all associated data
</p>
</div>
<Button variant="destructive" disabled>
<Trash2 className="mr-2 h-4 w-4" />
Delete Account
</Button>
</div>
</CardContent>
</Card>
</div> </div>
{/* Sidebar */} {/* Sidebar (Right Column) */}
<div className="space-y-6"> <div className="space-y-8">
{/* User Summary */}
<Card className="hover:shadow-md transition-shadow duration-200">
<CardHeader>
<CardTitle>Account Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
<span className="text-primary text-lg font-semibold">
{(user.name ?? user.email ?? "U").charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium">{user.name ?? "Unnamed User"}</p>
<p className="text-muted-foreground text-sm">{user.email}</p>
</div>
</div>
<Separator /> {/* Permissions */}
<section className="space-y-4">
<div> <div className="flex items-center gap-2 pb-2 border-b">
<p className="mb-2 text-sm font-medium">User ID</p> <Shield className="h-5 w-5 text-primary" />
<p className="text-muted-foreground bg-muted rounded p-2 font-mono text-xs break-all"> <h3 className="text-lg font-semibold">Permissions</h3>
{user.id} </div>
</p> <Card>
</div> <CardContent className="pt-6">
</CardContent> {user.roles && user.roles.length > 0 ? (
</Card> <div className="space-y-4">
{user.roles.map((roleInfo, index) => (
{/* System Roles */} <div key={index} className="space-y-2">
<Card> <div className="flex items-center justify-between">
<CardHeader> <span className="font-medium text-sm">{formatRole(roleInfo.role)}</span>
<CardTitle className="flex items-center gap-2"> <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
<Shield className="h-4 w-4" /> Since {new Date(roleInfo.grantedAt).toLocaleDateString()}
System Roles </span>
</CardTitle>
<CardDescription>Your current system permissions</CardDescription>
</CardHeader>
<CardContent>
{user.roles && user.roles.length > 0 ? (
<div className="space-y-3">
{user.roles.map((roleInfo, index: number) => (
<div
key={index}
className="flex items-start justify-between"
>
<div className="flex-1">
<div className="mb-1 flex items-center gap-2">
<Badge variant="secondary">
{formatRole(roleInfo.role)}
</Badge>
</div> </div>
<p className="text-muted-foreground text-xs"> <p className="text-xs text-muted-foreground leading-relaxed">
{getRoleDescription(roleInfo.role)} {getRoleDescription(roleInfo.role)}
</p> </p>
<p className="text-muted-foreground/80 mt-1 text-xs"> {index < (user.roles?.length || 0) - 1 && <Separator className="my-2" />}
Granted{" "}
{new Date(roleInfo.grantedAt).toLocaleDateString()}
</p>
</div> </div>
))}
<div className="bg-blue-50/50 dark:bg-blue-900/10 p-3 rounded-lg border border-blue-100 dark:border-blue-900/30 text-xs text-muted-foreground mt-4">
<div className="flex items-center gap-2 mb-1 text-primary font-medium">
<Shield className="h-3 w-3" />
<span>Role Management</span>
</div>
System roles are managed by administrators. Contact support if you need access adjustments.
</div> </div>
))}
<Separator />
<div className="text-center">
<p className="text-muted-foreground text-xs">
Need additional permissions?{" "}
<Button
variant="link"
size="sm"
className="h-auto p-0 text-xs"
>
Contact an administrator
<ExternalLink className="ml-1 h-3 w-3" />
</Button>
</p>
</div> </div>
</div> ) : (
) : ( <div className="text-center py-4">
<div className="py-6 text-center"> <p className="text-sm font-medium">No Roles Assigned</p>
<div className="bg-muted mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg"> <p className="text-xs text-muted-foreground mt-1">Contact an admin to request access.</p>
<Shield className="text-muted-foreground h-6 w-6" /> <Button size="sm" variant="outline" className="mt-3 w-full">Request Access</Button>
</div> </div>
<p className="mb-1 text-sm font-medium">No Roles Assigned</p> )}
<p className="text-muted-foreground text-xs"> </CardContent>
You don&apos;t have any system roles yet. Contact an </Card>
administrator to get access to HRIStudio features. </section>
</p>
<Button size="sm" variant="outline"> {/* Data & Privacy */}
Request Access <section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<Download className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Data & Privacy</h3>
</div>
<Card className="border-destructive/10 bg-destructive/5 overflow-hidden">
<CardContent className="pt-6 space-y-4">
<div>
<h4 className="text-sm font-semibold mb-1">Export Data</h4>
<p className="text-xs text-muted-foreground mb-3">Download a copy of your personal data.</p>
<Button variant="outline" size="sm" className="w-full bg-background" disabled>
<Download className="mr-2 h-3 w-3" />
Download Archive
</Button> </Button>
</div> </div>
)} <Separator className="bg-destructive/10" />
</CardContent> <div>
</Card> <h4 className="text-sm font-semibold text-destructive mb-1">Delete Account</h4>
<p className="text-xs text-muted-foreground mb-3">This action is irreversible.</p>
<Button variant="destructive" size="sm" className="w-full" disabled>
<Trash2 className="mr-2 h-3 w-3" />
Delete Account
</Button>
</div>
</CardContent>
</Card>
</section>
</div> </div>
</div> </div>
</div> </div>
@@ -218,13 +185,17 @@ function ProfileContent({ user }: { user: ProfileUser }) {
} }
export default function ProfilePage() { export default function ProfilePage() {
const { data: session } = useSession(); const { data: session, status } = useSession();
useBreadcrumbsEffect([ useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" }, { label: "Dashboard", href: "/dashboard" },
{ label: "Profile" }, { label: "Profile" },
]); ]);
if (status === "loading") {
return <div className="p-8 text-muted-foreground animate-pulse">Loading profile...</div>;
}
if (!session?.user) { if (!session?.user) {
redirect("/auth/signin"); redirect("/auth/signin");
} }

View File

@@ -1,12 +1,13 @@
"use client"; "use client";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react"; import { Calendar, Clock, Edit, Play, Settings, Users, TestTube } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/ui/page-header";
import { import {
EntityView, EntityView,
EntityViewHeader, EntityViewHeader,
@@ -183,18 +184,19 @@ export default function ExperimentDetailPage({
return ( return (
<EntityView> <EntityView>
<EntityViewHeader <PageHeader
title={displayName} title={displayName}
subtitle={description ?? undefined} description={description ?? undefined}
icon="TestTube" icon={TestTube}
status={{ badges={[
label: statusInfo?.label ?? "Unknown", {
variant: statusInfo?.variant ?? "secondary", label: statusInfo?.label ?? "Unknown",
icon: statusInfo?.icon ?? "TestTube", variant: statusInfo?.variant ?? "secondary",
}} }
]}
actions={ actions={
canEdit ? ( canEdit ? (
<> <div className="flex items-center gap-2">
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}> <Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
@@ -209,7 +211,7 @@ export default function ExperimentDetailPage({
Start Trial Start Trial
</Link> </Link>
</Button> </Button>
</> </div>
) : undefined ) : undefined
} }
/> />

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { Plus, Settings, Shield } from "lucide-react"; import { Plus, Settings, Shield, Building } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -16,6 +16,7 @@ import {
QuickActions, QuickActions,
StatsGrid, StatsGrid,
} from "~/components/ui/entity-view"; } from "~/components/ui/entity-view";
import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
@@ -167,17 +168,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
return ( return (
<EntityView> <EntityView>
{/* Header */} {/* Header */}
<EntityViewHeader <PageHeader
title={study.name} title={study.name}
subtitle={study.description ?? undefined} description={study.description ?? undefined}
icon="Building" icon={Building}
status={{ badges={[
label: statusInfo?.label ?? "Unknown", {
variant: statusInfo?.variant ?? "secondary", label: statusInfo?.label ?? "Unknown",
icon: statusInfo?.icon ?? "FileText", variant: statusInfo?.variant ?? "secondary",
}} }
]}
actions={ actions={
<> <div className="flex items-center gap-2">
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/studies/${study.id}/edit`}> <Link href={`/studies/${study.id}/edit`}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
@@ -190,7 +192,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
New Experiment New Experiment
</Link> </Link>
</Button> </Button>
</> </div>
} }
/> />
@@ -271,10 +273,10 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</h4> </h4>
<span <span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft" className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft"
? "bg-gray-100 text-gray-800" ? "bg-gray-100 text-gray-800"
: experiment.status === "ready" : experiment.status === "ready"
? "bg-green-100 text-green-800" ? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800" : "bg-blue-100 text-blue-800"
}`} }`}
> >
{experiment.status} {experiment.status}

View File

@@ -10,8 +10,9 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/com
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Edit } from "lucide-react"; import { Edit, Users } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { PageHeader } from "~/components/ui/page-header";
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager"; import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
@@ -37,14 +38,16 @@ export default async function ParticipantDetailPage({
return ( return (
<EntityView> <EntityView>
<EntityViewHeader <PageHeader
title={participant.participantCode} title={participant.participantCode}
subtitle={participant.name ?? "Unnamed Participant"} description={participant.name ?? "Unnamed Participant"}
icon="Users" icon={Users}
status={{ badges={[
label: participant.consentGiven ? "Consent Given" : "No Consent", {
variant: participant.consentGiven ? "default" : "secondary" label: participant.consentGiven ? "Consent Given" : "No Consent",
}} variant: participant.consentGiven ? "default" : "secondary"
}
]}
actions={ actions={
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link href={`/studies/${studyId}/participants/${participantId}/edit`}> <Link href={`/studies/${studyId}/participants/${participantId}/edit`}>

View File

@@ -86,7 +86,7 @@ function AnalysisPageContent() {
); );
} }
const trialData = { const customTrialData = {
...trial, ...trial,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null, startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null, completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
@@ -96,7 +96,7 @@ function AnalysisPageContent() {
return ( return (
<TrialAnalysisView <TrialAnalysisView
trial={trialData} trial={customTrialData}
backHref={`/studies/${studyId}/trials/${trialId}`} backHref={`/studies/${studyId}/trials/${trialId}`}
/> />
); );

View File

@@ -140,6 +140,12 @@ function TrialDetailContent() {
title={`Trial: ${trial.participant.participantCode}`} title={`Trial: ${trial.participant.participantCode}`}
description={`${trial.experiment.name} - Session ${trial.sessionNumber}`} description={`${trial.experiment.name} - Session ${trial.sessionNumber}`}
icon={Play} icon={Play}
badges={[
{
label: trial.status.replace("_", " ").toUpperCase(),
variant: getStatusBadgeVariant(trial.status),
}
]}
actions={ actions={
<div className="flex gap-2"> <div className="flex gap-2">
{trial.status === "scheduled" && ( {trial.status === "scheduled" && (

View File

@@ -39,11 +39,15 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
id: "timestamp", id: "timestamp",
header: "Time", header: "Time",
accessorKey: "timestamp", accessorKey: "timestamp",
size: 90,
meta: {
style: { width: '90px', minWidth: '90px' }
},
cell: ({ row }) => { cell: ({ row }) => {
const date = new Date(row.original.timestamp); const date = new Date(row.original.timestamp);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col py-0.5">
<span className="font-mono font-medium"> <span className="font-mono font-medium text-xs">
{formatRelativeTime(row.original.timestamp, startTime)} {formatRelativeTime(row.original.timestamp, startTime)}
</span> </span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block"> <span className="text-[10px] text-muted-foreground hidden group-hover:block">
@@ -56,6 +60,10 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
{ {
accessorKey: "eventType", accessorKey: "eventType",
header: "Event Type", header: "Event Type",
size: 160,
meta: {
style: { width: '160px', minWidth: '160px' }
},
cell: ({ row }) => { cell: ({ row }) => {
const type = row.getValue("eventType") as string; const type = row.getValue("eventType") as string;
const isError = type.includes("error"); const isError = type.includes("error");
@@ -63,25 +71,33 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
const isRobot = type.includes("robot"); const isRobot = type.includes("robot");
const isStep = type.includes("step"); 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; let Icon = Activity;
if (isError) Icon = AlertTriangle; 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 (isRobot) Icon = Bot;
else if (isStep) Icon = Flag; else if (isStep) Icon = Flag;
else if (type.includes("note")) Icon = MessageSquare; else if (isObservation) Icon = MessageSquare;
else if (type.includes("completed")) Icon = CheckCircle; else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
return ( return (
<Badge variant="outline" className={cn( <div className="flex items-center py-0.5">
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5", <Badge variant="outline" className={cn(
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400", "capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
isIntervention && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400", isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-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", (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",
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-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" /> isObservation && "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
{type.replace(/_/g, " ")} isActionComplete && "border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400"
</Badge> )}>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
</div>
); );
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
@@ -93,14 +109,42 @@ export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
header: "Details", header: "Details",
cell: ({ row }) => { cell: ({ row }) => {
const data = row.original.data; 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 ( 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"> <Wrapper>
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()} <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">
</code> {JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
</code>
</Wrapper>
); );
}, },
}, },

View File

@@ -1,22 +1,61 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { DataTable } from "~/components/ui/data-table"; import {
import { type TrialEvent, eventsColumns } from "./events-columns"; Table,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input"; 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 { interface EventsDataTableProps {
data: TrialEvent[]; data: TrialEvent[];
startTime?: Date; 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) { export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
const { seekTo, events, currentEventIndex } = usePlayback();
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all"); const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
const [globalFilter, setGlobalFilter] = React.useState<string>(""); const [globalFilter, setGlobalFilter] = React.useState<string>("");
const columns = React.useMemo(() => eventsColumns(startTime), [startTime]);
// Enhanced filtering logic // Enhanced filtering logic
const filteredData = React.useMemo(() => { const filteredData = React.useMemo(() => {
return data.filter(event => { return data.filter(event => {
@@ -40,46 +79,39 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
}); });
}, [data, eventTypeFilter, globalFilter]); }, [data, eventTypeFilter, globalFilter]);
// Custom Filters UI // Active Event Logic & Auto-scroll
const filters = ( // Match filtered events with global playback "active event" via ID
<div className="flex items-center gap-2"> const activeEventId = React.useMemo(() => {
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}> if (currentEventIndex >= 0 && currentEventIndex < events.length) {
<SelectTrigger className="h-8 w-[160px]"> // We need to match the type of ID used in data/events
<SelectValue placeholder="All Events" /> // Assuming events from context are TrialEvent compatible
</SelectTrigger> // eslint-disable-next-line @typescript-eslint/no-explicit-any
<SelectContent> const evt = events[currentEventIndex] as any;
<SelectItem value="all">All Events</SelectItem> return evt?.id;
<SelectItem value="action_executed">Actions</SelectItem> }
<SelectItem value="action_skipped">Skipped Actions</SelectItem> return null;
<SelectItem value="intervention">Interventions</SelectItem> }, [events, currentEventIndex]);
<SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem> const rowRefs = React.useRef<{ [key: string]: HTMLTableRowElement | null }>({});
<SelectItem value="error">Errors</SelectItem>
</SelectContent> React.useEffect(() => {
</Select> if (activeEventId && rowRefs.current[activeEventId]) {
</div> 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 ( return (
<div className="space-y-4"> <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 items-center justify-between">
<div className="flex flex-1 items-center space-x-2"> <div className="flex flex-1 items-center space-x-2">
<Input <Input
@@ -88,16 +120,176 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
onChange={(e) => setGlobalFilter(e.target.value)} onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 w-[150px] lg:w-[250px]" 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>
</div> </div>
<DataTable <div className="rounded-md border bg-background">
columns={columns} <div>
data={filteredData} <Table className="w-full">
// No searchKey, we handle it externally <TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
isLoading={false} <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> </div>
); );
} }

View File

@@ -83,20 +83,21 @@ export function EventTimeline() {
}, [effectiveDuration]); }, [effectiveDuration]);
const getEventIcon = (type: string) => { 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("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("completed")) return <CheckCircle className="h-4 w-4" />;
if (type.includes("start")) return <Flag 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" />; if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
return <Activity className="h-4 w-4" />; return <Activity className="h-4 w-4" />;
}; };
const getEventColor = (type: string) => { 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("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("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("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"; if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
return "bg-slate-100 text-slate-600 border-slate-200"; return "bg-slate-100 text-slate-600 border-slate-200";
}; };
@@ -132,19 +133,37 @@ export function EventTimeline() {
{sortedEvents.map((event, i) => { {sortedEvents.map((event, i) => {
const pct = getPercentage(new Date(event.timestamp).getTime()); 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 ( return (
<Tooltip key={i}> <Tooltip key={i}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <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}%` }} style={{ left: `${pct}%` }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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( <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) getEventColor(event.eventType)
)}> )}>
{getEventIcon(event.eventType)} {getEventIcon(event.eventType)}
@@ -156,9 +175,9 @@ export function EventTimeline() {
<div className="text-[10px] font-mono opacity-70 mb-1"> <div className="text-[10px] font-mono opacity-70 mb-1">
{new Date(event.timestamp).toLocaleTimeString()} {new Date(event.timestamp).toLocaleTimeString()}
</div> </div>
{!!event.data && ( {!!details && (
<div className="bg-muted/50 p-1 rounded font-mono text-[9px] max-w-[200px] break-all"> <div className="bg-muted/50 p-1.5 rounded text-[10px] max-w-[220px] break-words whitespace-normal border">
{JSON.stringify(event.data as object).slice(0, 100)} {details}
</div> </div>
)} )}
</TooltipContent> </TooltipContent>

View File

@@ -85,13 +85,14 @@ export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
<video <video
ref={videoRef} ref={videoRef}
src={src} src={src}
controls
muted={muted}
className="w-full h-full object-contain" className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={handleLoadedMetadata}
onWaiting={handleWaiting} onWaiting={handleWaiting}
onPlaying={handlePlaying} onPlaying={handlePlaying}
onEnded={handleEnded} onEnded={handleEnded}
onClick={togglePlay}
/> />
{/* Overlay Controls (Visible on Hover/Pause) */} {/* Overlay Controls (Visible on Hover/Pause) */}

View File

@@ -4,6 +4,7 @@ import { PageHeader } from "~/components/ui/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import Link from "next/link"; import Link from "next/link";
import { LineChart, BarChart, Printer, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react"; import { LineChart, BarChart, Printer, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -31,7 +32,7 @@ interface TrialAnalysisViewProps {
participant: { participantCode: string }; participant: { participantCode: string };
eventCount?: number; eventCount?: number;
mediaCount?: number; mediaCount?: number;
media?: { url: string; contentType: string }[]; media?: { url: string; mediaType: string; format?: string; contentType?: string }[];
}; };
backHref: string; backHref: string;
} }
@@ -41,6 +42,8 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
const { data: events = [] } = api.trials.getEvents.useQuery({ const { data: events = [] } = api.trials.getEvents.useQuery({
trialId: trial.id, trialId: trial.id,
limit: 1000 limit: 1000
}, {
refetchInterval: 5000
}); });
// Auto-print effect // 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; const videoUrl = videoMedia?.url;
// Metrics // Metrics
@@ -64,7 +67,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
return ( return (
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}> <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 */} {/* Header Context */}
<PageHeader <PageHeader
title={trial.experiment.name} title={trial.experiment.name}
@@ -185,65 +188,56 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
} }
/> />
{/* Metrics Header */} {/* Top Section: Metrics & Optional Video Grid */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4" id="tour-trial-metrics"> <div className="flex flex-col xl:flex-row gap-3 shrink-0">
<Card> <Card id="tour-trial-metrics" className="shadow-sm flex-1">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0"> <CardContent className="p-0 h-full">
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle> <div className="flex flex-row divide-x h-full">
<Clock className="h-4 w-4 text-blue-500" /> <div className="flex-1 flex flex-col p-3 px-4 justify-center">
</CardHeader> <p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<CardContent> <Clock className="h-3.5 w-3.5 text-blue-500" /> Duration
<div className="text-2xl font-bold"> </p>
{trial.duration ? ( <p className="text-base font-bold">
<span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> {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">
</div> <p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<p className="text-xs text-muted-foreground">Total session time</p> <Bot className="h-3.5 w-3.5 text-purple-500" /> Robot Actions
</CardContent> </p>
</Card> <p className="text-base font-bold">{robotActionCount}</p>
</div>
<Card> <div className="flex-1 flex flex-col p-3 px-4 justify-center">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0"> <p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
<CardTitle className="text-sm font-medium text-muted-foreground">Robot Actions</CardTitle> <AlertTriangle className="h-3.5 w-3.5 text-orange-500" /> Interventions
<Bot className="h-4 w-4 text-purple-500" /> </p>
</CardHeader> <p className="text-base font-bold">{interventionCount}</p>
<CardContent> </div>
<div className="text-2xl font-bold">{robotActionCount}</div> <div className="flex-1 flex flex-col p-3 px-4 justify-center">
<p className="text-xs text-muted-foreground">Executed autonomous behaviors</p> <p className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-1">
</CardContent> <Activity className="h-3.5 w-3.5 text-green-500" /> Completeness
</Card> </p>
<div className="flex items-center gap-1.5 text-base font-bold">
<Card> <span className={cn(
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0"> "inline-block h-2 w-2 rounded-full",
<CardTitle className="text-sm font-medium text-muted-foreground">Interventions</CardTitle> trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
<AlertTriangle className="h-4 w-4 text-orange-500" /> )} />
</CardHeader> {trial.status === 'completed' ? '100%' : 'Incomplete'}
<CardContent> </div>
<div className="text-2xl font-bold">{interventionCount}</div> </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)}
</div> </div>
</CardContent> </CardContent>
</Card> </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> </div>
{/* Main Workspace: Vertical Layout */} {/* Main Workspace: Vertical Layout */}
@@ -254,51 +248,89 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
<EventTimeline /> <EventTimeline />
</div> </div>
<ResizablePanelGroup direction="vertical"> {/* BOTTOM: Events Table */}
<div className="flex-1 flex flex-col min-h-0 bg-background" id="tour-trial-events">
{/* TOP: Video (Optional) */} <Tabs defaultValue="events" className="flex flex-col h-full">
{videoUrl && ( <div className="flex items-center justify-between px-3 py-2 border-b shrink-0 bg-muted/10">
<>
<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">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-primary" /> <TabsList className="h-8">
<h3 className="font-semibold text-sm">Event Log</h3> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
placeholder="Filter events..." placeholder="Filter..."
className="h-8 w-[200px]" className="h-7 w-[150px] text-xs"
disabled disabled
style={{ display: 'none' }} 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> </div>
<div className="flex-1 min-h-0">
<TabsContent value="events" className="flex-1 min-h-0 mt-0">
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="p-4"> <div className="p-0">
<EventsDataTable <EventsDataTable
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))} data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
startTime={trial.startedAt ?? undefined} startTime={trial.startedAt ?? undefined}
/> />
</div> </div>
</ScrollArea> </ScrollArea>
</div> </TabsContent>
</ResizablePanel>
</ResizablePanelGroup> <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>
</div> </div>
</PlaybackProvider> </PlaybackProvider>

View File

@@ -10,6 +10,7 @@ import {
Play, Play,
Target, Target,
Users, Users,
SkipForward
} from "lucide-react"; } from "lucide-react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -21,15 +22,17 @@ interface TrialProgressProps {
id: string; id: string;
name: string; name: string;
type: type:
| "wizard_action" | "wizard_action"
| "robot_action" | "robot_action"
| "parallel_steps" | "parallel_steps"
| "conditional_branch"; | "conditional_branch";
description?: string; description?: string;
duration?: number; duration?: number;
parameters?: Record<string, unknown>; parameters?: Record<string, unknown>;
}>; }>;
currentStepIndex: number; currentStepIndex: number;
completedSteps: Set<number>;
skippedSteps: Set<number>;
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed"; trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
} }
@@ -71,6 +74,8 @@ const stepTypeConfig = {
export function TrialProgress({ export function TrialProgress({
steps, steps,
currentStepIndex, currentStepIndex,
completedSteps,
skippedSteps,
trialStatus, trialStatus,
}: TrialProgressProps) { }: TrialProgressProps) {
if (!steps || steps.length === 0) { if (!steps || steps.length === 0) {
@@ -93,7 +98,7 @@ export function TrialProgress({
? 0 ? 0
: ((currentStepIndex + 1) / steps.length) * 100; : ((currentStepIndex + 1) / steps.length) * 100;
const completedSteps = const completedCount =
trialStatus === "completed" trialStatus === "completed"
? steps.length ? steps.length
: trialStatus === "aborted" || trialStatus === "failed" : trialStatus === "aborted" || trialStatus === "failed"
@@ -102,12 +107,19 @@ export function TrialProgress({
const getStepStatus = (index: number) => { const getStepStatus = (index: number) => {
if (trialStatus === "aborted" || trialStatus === "failed") return "aborted"; if (trialStatus === "aborted" || trialStatus === "failed") return "aborted";
if (trialStatus === "completed" || index < currentStepIndex) if (trialStatus === "completed") return "completed";
return "completed";
if (skippedSteps.has(index)) return "skipped";
if (completedSteps.has(index)) return "completed";
if (index === currentStepIndex && trialStatus === "in_progress") if (index === currentStepIndex && trialStatus === "in_progress")
return "active"; return "active";
if (index === currentStepIndex && trialStatus === "scheduled") if (index === currentStepIndex && trialStatus === "scheduled")
return "pending"; return "pending";
// Default fallback if jumping around without explicitly adding to sets
if (index < currentStepIndex && !skippedSteps.has(index)) return "completed";
return "upcoming"; return "upcoming";
}; };
@@ -145,6 +157,14 @@ export function TrialProgress({
borderColor: "border-red-300", borderColor: "border-red-300",
textColor: "text-red-800", 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 default: // upcoming
return { return {
icon: Circle, icon: Circle,
@@ -171,7 +191,7 @@ export function TrialProgress({
</CardTitle> </CardTitle>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{completedSteps}/{steps.length} steps {completedCount}/{steps.length} steps
</Badge> </Badge>
{totalDuration > 0 && ( {totalDuration > 0 && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
@@ -191,13 +211,12 @@ export function TrialProgress({
</div> </div>
<Progress <Progress
value={progress} value={progress}
className={`h-2 ${ className={`h-2 ${trialStatus === "completed"
trialStatus === "completed" ? "bg-green-100"
? "bg-green-100" : trialStatus === "aborted" || trialStatus === "failed"
: trialStatus === "aborted" || trialStatus === "failed" ? "bg-red-100"
? "bg-red-100" : "bg-blue-100"
: "bg-blue-100" }`}
}`}
/> />
<div className="flex justify-between text-xs text-slate-500"> <div className="flex justify-between text-xs text-slate-500">
<span>Start</span> <span>Start</span>
@@ -236,51 +255,47 @@ export function TrialProgress({
{/* Connection Line */} {/* Connection Line */}
{index < steps.length - 1 && ( {index < steps.length - 1 && (
<div <div
className={`absolute top-12 left-6 h-6 w-0.5 ${ className={`absolute top-12 left-6 h-6 w-0.5 ${getStepStatus(index + 1) === "completed" ||
getStepStatus(index + 1) === "completed" ||
(getStepStatus(index + 1) === "active" && (getStepStatus(index + 1) === "active" &&
status === "completed") status === "completed")
? "bg-green-300" ? "bg-green-300"
: "bg-slate-300" : "bg-slate-300"
}`} }`}
/> />
)} )}
{/* Step Card */} {/* Step Card */}
<div <div
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${ className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${status === "active"
status === "active" ? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200` : status === "completed"
: status === "completed" ? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: status === "aborted"
? `${statusConfig.bgColor} ${statusConfig.borderColor}` ? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: status === "aborted" : "border-slate-200 bg-slate-50"
? `${statusConfig.bgColor} ${statusConfig.borderColor}` }`}
: "border-slate-200 bg-slate-50"
}`}
> >
{/* Step Number & Status */} {/* Step Number & Status */}
<div className="flex-shrink-0 space-y-1"> <div className="flex-shrink-0 space-y-1">
<div <div
className={`flex h-8 w-12 items-center justify-center rounded-lg ${ className={`flex h-8 w-12 items-center justify-center rounded-lg ${status === "active"
status === "active" ? statusConfig.bgColor
? statusConfig.bgColor : status === "completed"
: status === "completed" ? "bg-green-100"
? "bg-green-100" : status === "aborted"
: status === "aborted" ? "bg-red-100"
? "bg-red-100" : "bg-slate-100"
: "bg-slate-100" }`}
}`}
> >
<span <span
className={`text-sm font-medium ${ className={`text-sm font-medium ${status === "active"
status === "active" ? statusConfig.textColor
? statusConfig.textColor : status === "completed"
: status === "completed" ? "text-green-700"
? "text-green-700" : status === "aborted"
: status === "aborted" ? "text-red-700"
? "text-red-700" : "text-slate-600"
: "text-slate-600" }`}
}`}
> >
{index + 1} {index + 1}
</span> </span>
@@ -297,15 +312,14 @@ export function TrialProgress({
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h5 <h5
className={`truncate font-medium ${ className={`truncate font-medium ${status === "active"
status === "active" ? "text-slate-900"
? "text-slate-900" : status === "completed"
: status === "completed" ? "text-green-900"
? "text-green-900" : status === "aborted"
: status === "aborted" ? "text-red-900"
? "text-red-900" : "text-slate-700"
: "text-slate-700" }`}
}`}
> >
{step.name} {step.name}
</h5> </h5>
@@ -352,6 +366,12 @@ export function TrialProgress({
<span>Completed</span> <span>Completed</span>
</div> </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> </div>
</div> </div>
@@ -365,7 +385,7 @@ export function TrialProgress({
<div className="grid grid-cols-3 gap-4 text-center"> <div className="grid grid-cols-3 gap-4 text-center">
<div> <div>
<div className="text-2xl font-bold text-green-600"> <div className="text-2xl font-bold text-green-600">
{completedSteps} {completedCount}
</div> </div>
<div className="text-xs text-slate-600">Completed</div> <div className="text-xs text-slate-600">Completed</div>
</div> </div>
@@ -378,7 +398,7 @@ export function TrialProgress({
<div> <div>
<div className="text-2xl font-bold text-slate-600"> <div className="text-2xl font-bold text-slate-600">
{steps.length - {steps.length -
completedSteps - completedCount -
(trialStatus === "in_progress" ? 1 : 0)} (trialStatus === "in_progress" ? 1 : 0)}
</div> </div>
<div className="text-xs text-slate-600">Remaining</div> <div className="text-xs text-slate-600">Remaining</div>

View File

@@ -25,10 +25,10 @@ import Link from "next/link";
import { Progress } from "~/components/ui/progress"; import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel"; import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel"; import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
import { WizardObservationPane } from "./panels/WizardObservationPane"; import { WizardObservationPane } from "./panels/WizardObservationPane";
import { WebcamPanel } from "./panels/WebcamPanel";
import { TrialStatusBar } from "./panels/TrialStatusBar"; import { TrialStatusBar } from "./panels/TrialStatusBar";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos"; import { useWizardRos } from "~/hooks/useWizardRos";
@@ -121,20 +121,22 @@ export const WizardInterface = React.memo(function WizardInterface({
const [completedActionsCount, setCompletedActionsCount] = useState(0); const [completedActionsCount, setCompletedActionsCount] = useState(0);
// Collapse state for panels // Collapse state for panels
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = 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 // Reset completed actions when step changes
useEffect(() => { useEffect(() => {
setCompletedActionsCount(0); setCompletedActionsCount(0);
}, [currentStepIndex]); }, [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 // Track the last response value from wizard_wait_for_response for branching
const [lastResponse, setLastResponse] = useState<string | null>(null); const [lastResponse, setLastResponse] = useState<string | null>(null);
const [isPaused, setIsPaused] = useState(false);
const utils = api.useUtils();
// Get experiment steps from API // Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery( const { data: experimentSteps } = api.experiments.getSteps.useQuery(
@@ -492,16 +494,27 @@ export const WizardInterface = React.memo(function WizardInterface({
const handlePauseTrial = async () => { const handlePauseTrial = async () => {
try { try {
await pauseTrialMutation.mutateAsync({ id: trial.id }); await pauseTrialMutation.mutateAsync({ id: trial.id });
logEventMutation.mutate({ setIsPaused(true);
trialId: trial.id, toast.info("Trial paused");
type: "trial_paused",
data: { timestamp: new Date() }
});
} catch (error) { } catch (error) {
console.error("Failed to pause trial:", 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) => { const handleNextStep = (targetIndex?: number) => {
// If explicit target provided (from branching choice), use it // If explicit target provided (from branching choice), use it
if (typeof targetIndex === 'number') { 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); setCurrentStepIndex(targetIndex);
setCompletedActionsCount(0); setCompletedActionsCount(0);
return; return;
@@ -590,6 +621,13 @@ export const WizardInterface = React.memo(function WizardInterface({
// Default: Linear progression // Default: Linear progression
const nextIndex = currentStepIndex + 1; const nextIndex = currentStepIndex + 1;
if (nextIndex < steps.length) { if (nextIndex < steps.length) {
// Mark current step as complete
setCompletedSteps(prev => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
// Log step change // Log step change
logEventMutation.mutate({ logEventMutation.mutate({
trialId: trial.id, trialId: trial.id,
@@ -600,6 +638,7 @@ export const WizardInterface = React.memo(function WizardInterface({
fromStepId: currentStep?.id, fromStepId: currentStep?.id,
toStepId: steps[nextIndex]?.id, toStepId: steps[nextIndex]?.id,
stepName: steps[nextIndex]?.name, 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 () => { const handleCompleteTrial = async () => {
try { try {
// Mark final step as complete
setCompletedSteps(prev => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
});
await completeTrialMutation.mutateAsync({ id: trial.id }); 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 // Trigger archive in background
archiveTrialMutation.mutate({ id: trial.id }); archiveTrialMutation.mutate({ id: trial.id });
// Immediately navigate to analysis
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
} catch (error) { } catch (error) {
console.error("Failed to complete trial:", 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") category: String(parameters?.category || "quick_note")
}); });
} else { } 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({ await logEventMutation.mutateAsync({
trialId: trial.id, trialId: trial.id,
type: "action_executed", type: "action_executed",
data: { data: {
actionId, actionId,
actionName,
actionType,
parameters parameters
} }
}); });
@@ -734,6 +858,22 @@ export const WizardInterface = React.memo(function WizardInterface({
) => { ) => {
try { try {
setIsExecutingAction(true); 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 // Try direct WebSocket execution first for better performance
if (rosConnected) { if (rosConnected) {
try { try {
@@ -778,18 +918,12 @@ export const WizardInterface = React.memo(function WizardInterface({
} }
} }
} else { } else {
// Use tRPC execution if WebSocket not connected // Not connected - show error and don't try to execute
await executeRobotActionMutation.mutateAsync({ const errorMsg = "Robot not connected. Cannot execute action.";
trialId: trial.id, toast.error(errorMsg);
pluginName, console.warn(errorMsg);
actionId, // Throw to stop execution flow
parameters, throw new Error(errorMsg);
});
toast.success(`Robot action executed: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
} }
} catch (error) { } catch (error) {
console.error("Failed to execute robot action:", error); console.error("Failed to execute robot action:", error);
@@ -825,7 +959,7 @@ export const WizardInterface = React.memo(function WizardInterface({
// Generic skip logging // Generic skip logging
await logEventMutation.mutateAsync({ await logEventMutation.mutateAsync({
trialId: trial.id, trialId: trial.id,
type: "action_skipped", type: "intervention_action_skipped",
data: { data: {
actionId, actionId,
parameters parameters
@@ -842,9 +976,17 @@ export const WizardInterface = React.memo(function WizardInterface({
toast.error("Failed to skip action"); 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 ( return (
@@ -869,13 +1011,13 @@ export const WizardInterface = React.memo(function WizardInterface({
{trial.status === "in_progress" && ( {trial.status === "in_progress" && (
<> <>
<Button <Button
variant="outline" variant={isPaused ? "default" : "outline"}
size="sm" size="sm"
onClick={handlePauseTrial} onClick={isPaused ? handleResumeTrial : handlePauseTrial}
className="gap-2" className="gap-2"
> >
<Pause className="h-4 w-4" /> {isPaused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
Pause {isPaused ? "Resume" : "Pause"}
</Button> </Button>
<Button <Button
@@ -922,190 +1064,128 @@ export const WizardInterface = React.memo(function WizardInterface({
className="flex-none px-2 pb-2" className="flex-none px-2 pb-2"
/> />
{/* Main Grid - 2 rows */} {/* Main Grid - Single Row */}
<div className="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2"> <div className="flex-1 min-h-0 flex 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>
)}
{/* Center - Tabbed Workspace */} {/* Center - Execution Workspace */}
{/* Center - Execution Workspace */} <div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
<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 border-b px-3 py-2 bg-muted/30 min-h-[45px]"> <div className="flex items-center gap-2">
{leftCollapsed && ( <span className="text-sm font-medium">Trial Execution</span>
<Button {currentStep && (
variant="ghost" <Badge variant="outline" className="text-xs font-normal">
size="icon" {currentStep.name}
className="h-6 w-6 mr-2" </Badge>
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>
)} )}
</div> </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) */} <div className="flex-1" />
{!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>
{/* Bottom Row - Observations (Full Width, Collapsible) */} <div className="mr-2 text-xs text-muted-foreground font-medium">
{!obsCollapsed && ( Step {currentStepIndex + 1} / {steps.length}
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm h-48 flex-none"> </div>
<div className="flex items-center border-b px-3 py-2 bg-muted/30 gap-3">
<span className="text-sm font-medium">Observations</span> {rightCollapsed && (
<div className="flex-1" />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" 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> </Button>
</div> )}
<div className="flex-1 overflow-auto min-h-0 bg-muted/10"> </div>
<WizardObservationPane <div className="flex-1 overflow-auto bg-muted/10 pb-0">
onAddAnnotation={handleAddAnnotation} <div id="tour-wizard-timeline" className="h-full">
isSubmitting={addAnnotationMutation.isPending} <WizardExecutionPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
completedStepIndices={completedSteps}
trialEvents={trialEvents} 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>
</div> </div>
)} </div>
{
obsCollapsed && ( {/* 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 <Button
variant="outline" variant="ghost"
size="sm" size="icon"
onClick={() => setObsCollapsed(false)} className="h-6 w-6"
className="w-full flex-none" onClick={() => setRightCollapsed(true)}
> >
<ChevronUp className="h-4 w-4 mr-2" /> <PanelRightClose className="h-4 w-4" />
Show Observations
</Button> </Button>
) </div>
} <div className="flex-1 overflow-hidden bg-background">
</div > <Tabs defaultValue="camera_obs" className="flex flex-col h-full w-full">
</div > <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>
); );
}); });

View File

@@ -16,7 +16,7 @@ import { AspectRatio } from "~/components/ui/aspect-ratio";
import { toast } from "sonner"; import { toast } from "sonner";
import { api } from "~/trpc/react"; 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 [deviceId, setDeviceId] = useState<string | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]); const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [isCameraEnabled, setIsCameraEnabled] = useState(false); const [isCameraEnabled, setIsCameraEnabled] = useState(false);
@@ -31,6 +31,10 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
// TRPC mutation for presigned URL // TRPC mutation for presigned URL
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation(); 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( const handleDevices = useCallback(
(mediaDevices: MediaDeviceInfo[]) => { (mediaDevices: MediaDeviceInfo[]) => {
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== "")); setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
@@ -38,7 +42,10 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
[setDevices], [setDevices],
); );
const [isMounted, setIsMounted] = useState(false);
React.useEffect(() => { React.useEffect(() => {
setIsMounted(true);
navigator.mediaDevices.enumerateDevices().then(handleDevices); navigator.mediaDevices.enumerateDevices().then(handleDevices);
}, [handleDevices]); }, [handleDevices]);
@@ -54,6 +61,30 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
setIsCameraEnabled(false); 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 = () => { const handleStartRecording = () => {
if (!webcamRef.current?.stream) return; if (!webcamRef.current?.stream) return;
@@ -78,6 +109,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
recorder.start(); recorder.start();
mediaRecorderRef.current = recorder; mediaRecorderRef.current = recorder;
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_started",
data: { action: "recording_started" }
});
}
toast.success("Recording started"); toast.success("Recording started");
} catch (e) { } catch (e) {
console.error("Failed to start recorder:", e); console.error("Failed to start recorder:", e);
@@ -90,6 +128,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
if (mediaRecorderRef.current && isRecording) { if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop(); mediaRecorderRef.current.stop();
setIsRecording(false); 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) { 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"); toast.success("Recording uploaded successfully");
@@ -137,7 +205,7 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
{!readOnly && ( {!readOnly && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{devices.length > 0 && ( {devices.length > 0 && isMounted && (
<Select <Select
value={deviceId ?? undefined} value={deviceId ?? undefined}
onValueChange={setDeviceId} onValueChange={setDeviceId}
@@ -217,6 +285,7 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
width="100%" width="100%"
height="100%" height="100%"
videoConstraints={{ deviceId: deviceId ?? undefined }} videoConstraints={{ deviceId: deviceId ?? undefined }}
onUserMedia={handleUserMedia}
onUserMediaError={(err) => setError(String(err))} onUserMediaError={(err) => setError(String(err))}
className="object-contain w-full h-full" className="object-contain w-full h-full"
/> />

View File

@@ -47,6 +47,7 @@ interface WizardActionItemProps {
isExecuting?: boolean; isExecuting?: boolean;
depth?: number; depth?: number;
isRobotConnected?: boolean; isRobotConnected?: boolean;
onLogEvent?: (type: string, data?: any) => void;
} }
export function WizardActionItem({ export function WizardActionItem({
@@ -62,6 +63,7 @@ export function WizardActionItem({
isExecuting, isExecuting,
depth = 0, depth = 0,
isRobotConnected = false, isRobotConnected = false,
onLogEvent,
}: WizardActionItemProps): React.JSX.Element { }: WizardActionItemProps): React.JSX.Element {
// Local state for container children completion // Local state for container children completion
const [completedChildren, setCompletedChildren] = useState<Set<number>>(new Set()); const [completedChildren, setCompletedChildren] = useState<Set<number>>(new Set());
@@ -289,13 +291,14 @@ export function WizardActionItem({
isExecuting={isExecuting} isExecuting={isExecuting}
depth={depth + 1} depth={depth + 1}
isRobotConnected={isRobotConnected} isRobotConnected={isRobotConnected}
onLogEvent={onLogEvent}
/> />
))} ))}
</div> </div>
) : null) as any} ) : null) as any}
{/* Active Action Controls */} {/* Active Action Controls */}
{isActive && !readOnly && ( {(isActive || (isCompleted && !readOnly)) && (
<div className="pt-3 flex flex-wrap items-center gap-3"> <div className="pt-3 flex flex-wrap items-center gap-3">
{/* Parallel Container Controls */} {/* Parallel Container Controls */}
{isContainer && action.type.includes("parallel") ? ( {isContainer && action.type.includes("parallel") ? (
@@ -326,20 +329,22 @@ export function WizardActionItem({
title={isButtonDisabled && !isExecuting ? "Robot disconnected" : undefined} title={isButtonDisabled && !isExecuting ? "Robot disconnected" : undefined}
> >
<Play className="mr-2 h-3.5 w-3.5" /> <Play className="mr-2 h-3.5 w-3.5" />
Run All {isCompleted ? "Rerun All" : "Run All"}
</Button>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
onCompleted();
}}
disabled={isExecuting}
>
<CheckCircle className="mr-2 h-3.5 w-3.5" />
Mark Group Complete
</Button> </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 */ /* Standard Single Action Controls */
@@ -367,7 +372,7 @@ export function WizardActionItem({
action.parameters || {}, action.parameters || {},
{ autoAdvance: false } { autoAdvance: false }
); );
onCompleted(); if (!isCompleted) onCompleted();
} catch (error) { } catch (error) {
console.error("Action execution error:", error); console.error("Action execution error:", error);
} finally { } finally {
@@ -386,39 +391,50 @@ export function WizardActionItem({
) : ( ) : (
<> <>
<Play className="mr-2 h-3.5 w-3.5" /> <Play className="mr-2 h-3.5 w-3.5" />
Run {isCompleted ? "Rerun" : "Run"}
</> </>
)} )}
</Button> </Button>
<Button {!isCompleted && (
size="sm" <Button
variant="outline" size="sm"
onClick={(e) => { variant="outline"
e.preventDefault(); onClick={(e) => {
onCompleted(); e.preventDefault();
}} // Log manual completion
disabled={isExecuting} if (onLogEvent) {
> onLogEvent("action_marked_complete", {
<CheckCircle className="mr-2 h-3.5 w-3.5" /> actionId: action.id,
Mark Complete formatted: "Action manually marked complete"
</Button> });
<Button }
size="sm" onCompleted();
variant="ghost" }}
onClick={(e) => { disabled={isExecuting}
e.preventDefault(); >
if (onSkip) { <CheckCircle className="mr-2 h-3.5 w-3.5" />
onSkip(action.pluginId!, action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.parameters || {}, { autoAdvance: false }); Mark Complete
} </Button>
onCompleted(); )}
}} {!isCompleted && (
> <Button
Skip size="sm"
</Button> 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) // Manual/Wizard Actions (Leaf nodes)
!isContainer && action.type !== "wizard_wait_for_response" && ( !isContainer && action.type !== "wizard_wait_for_response" && !isCompleted && (
<Button <Button
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
@@ -437,7 +453,7 @@ export function WizardActionItem({
)} )}
{/* Branching / Choice UI */} {/* Branching / Choice UI */}
{isActive && {(isActive || (isCompleted && !readOnly)) &&
(action.type === "wizard_wait_for_response" || isBranch) && (action.type === "wizard_wait_for_response" || isBranch) &&
action.parameters?.options && action.parameters?.options &&
Array.isArray(action.parameters.options) && ( Array.isArray(action.parameters.options) && (

View File

@@ -113,7 +113,11 @@ interface WizardExecutionPanelProps {
completedActionsCount: number; completedActionsCount: number;
onActionCompleted: () => void; onActionCompleted: () => void;
readOnly?: boolean; readOnly?: boolean;
isPaused?: boolean;
rosConnected?: boolean; rosConnected?: boolean;
completedStepIndices?: Set<number>;
skippedStepIndices?: Set<number>;
onLogEvent?: (type: string, data?: any) => void;
} }
export function WizardExecutionPanel({ export function WizardExecutionPanel({
@@ -134,12 +138,17 @@ export function WizardExecutionPanel({
completedActionsCount, completedActionsCount,
onActionCompleted, onActionCompleted,
readOnly = false, readOnly = false,
isPaused = false,
rosConnected, rosConnected,
completedStepIndices = new Set(),
skippedStepIndices = new Set(),
onLogEvent,
}: WizardExecutionPanelProps) { }: WizardExecutionPanelProps) {
// Local state removed in favor of parent state to prevent reset on re-render // Local state removed in favor of parent state to prevent reset on re-render
// const [completedCount, setCompletedCount] = React.useState(0); // const [completedCount, setCompletedCount] = React.useState(0);
const activeActionIndex = completedActionsCount; const isStepCompleted = completedStepIndices.has(currentStepIndex);
const activeActionIndex = isStepCompleted ? 9999 : completedActionsCount;
// Auto-scroll to active action // Auto-scroll to active action
const activeActionRef = React.useRef<HTMLDivElement>(null); const activeActionRef = React.useRef<HTMLDivElement>(null);
@@ -210,13 +219,29 @@ export function WizardExecutionPanel({
// Active trial state // Active trial state
return ( 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 */} {/* Horizontal Step Progress Bar */}
<div className="flex-none border-b bg-muted/30 p-3"> <div className="flex-none border-b bg-muted/30 p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-2"> <div className="flex items-center gap-2 overflow-x-auto pb-2">
{steps.map((step, idx) => { {steps.map((step, idx) => {
const isCurrent = idx === currentStepIndex; 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; const isUpcoming = idx > currentStepIndex;
return ( return (
@@ -233,7 +258,9 @@ export function WizardExecutionPanel({
? "border-primary bg-primary/10 shadow-sm" ? "border-primary bg-primary/10 shadow-sm"
: isCompleted : isCompleted
? "border-primary/30 bg-primary/5 hover:bg-primary/10" ? "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"} ${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 flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold
${isCompleted ${isCompleted
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: isCurrent : isSkipped
? "bg-primary text-primary-foreground ring-2 ring-primary/20" ? "bg-transparent border border-muted-foreground/40 text-muted-foreground"
: "bg-muted 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} readOnly={readOnly}
isExecuting={isExecuting} isExecuting={isExecuting}
isRobotConnected={rosConnected} isRobotConnected={rosConnected}
onLogEvent={onLogEvent}
/> />
</div> </div>
); );

View File

@@ -14,7 +14,6 @@ import { Alert, AlertDescription } from "~/components/ui/alert";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { WebcamPanel } from "./WebcamPanel";
import { RobotActionsPanel } from "../RobotActionsPanel"; import { RobotActionsPanel } from "../RobotActionsPanel";
interface WizardMonitoringPanelProps { interface WizardMonitoringPanelProps {
@@ -44,6 +43,7 @@ interface WizardMonitoringPanelProps {
) => Promise<void>; ) => Promise<void>;
studyId?: string; studyId?: string;
trialId?: string; trialId?: string;
trialStatus?: string;
readOnly?: boolean; readOnly?: boolean;
} }
@@ -59,6 +59,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
onExecuteRobotAction, onExecuteRobotAction,
studyId, studyId,
trialId, trialId,
trialStatus,
readOnly = false, readOnly = false,
}: WizardMonitoringPanelProps) { }: WizardMonitoringPanelProps) {
const [autonomousLife, setAutonomousLife] = React.useState(true); const [autonomousLife, setAutonomousLife] = React.useState(true);
@@ -78,12 +79,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
} }
}, [onSetAutonomousLife]); }, [onSetAutonomousLife]);
return ( return (
<div className="flex h-full flex-col gap-2 p-2"> <div className="flex h-full flex-col 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>
{/* Robot Controls - Scrollable */} {/* Robot Controls - Scrollable */}
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col"> <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"> <div className="px-3 py-2 border-b bg-muted/30 flex items-center gap-2">

View File

@@ -29,6 +29,7 @@ interface WizardObservationPaneProps {
category?: string, category?: string,
tags?: string[], tags?: string[],
) => Promise<void>; ) => Promise<void>;
onFlagIntervention?: () => Promise<void> | void;
isSubmitting?: boolean; isSubmitting?: boolean;
readOnly?: boolean; readOnly?: boolean;
@@ -36,6 +37,7 @@ interface WizardObservationPaneProps {
export function WizardObservationPane({ export function WizardObservationPane({
onAddAnnotation, onAddAnnotation,
onFlagIntervention,
isSubmitting = false, isSubmitting = false,
trialEvents = [], trialEvents = [],
readOnly = false, readOnly = false,
@@ -118,11 +120,23 @@ export function WizardObservationPane({
size="sm" size="sm"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting || !note.trim() || readOnly} disabled={isSubmitting || !note.trim() || readOnly}
className="h-8" className="h-8 shrink-0"
> >
<Send className="mr-2 h-3 w-3" /> <Send className="mr-2 h-3 w-3" />
Add Note Add Note
</Button> </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> </div>
{tags.length > 0 && ( {tags.length > 0 && (

View File

@@ -8,12 +8,12 @@ import { uuid } from "drizzle-orm/pg-core";
import { eq, desc } from "drizzle-orm"; import { eq, desc } from "drizzle-orm";
// Initialize MinIO client // Initialize MinIO client
// Note: In production, ensure these ENV vars are set. const minioUrl = new URL(env.MINIO_ENDPOINT ?? "http://localhost:9000");
// For development with docker-compose, we use localhost:9000
const minioClient = new Minio.Client({ const minioClient = new Minio.Client({
endPoint: (env.MINIO_ENDPOINT ?? "localhost").split(":")[0] ?? "localhost", endPoint: minioUrl.hostname,
port: parseInt((env.MINIO_ENDPOINT ?? "9000").split(":")[1] ?? "9000"), port: parseInt(minioUrl.port) || 9000,
useSSL: false, // Default to false for local dev; adjust for prod useSSL: minioUrl.protocol === "https:",
accessKey: env.MINIO_ACCESS_KEY ?? "minioadmin", accessKey: env.MINIO_ACCESS_KEY ?? "minioadmin",
secretKey: env.MINIO_SECRET_KEY ?? "minioadmin", secretKey: env.MINIO_SECRET_KEY ?? "minioadmin",
}); });

View File

@@ -300,7 +300,12 @@ export const trialsRouter = createTRPCRouter({
return { return {
...m, ...m,
url, // Add the signed URL to the response url, // Add the signed URL to the response
contentType: m.format === 'webm' ? 'video/webm' : 'application/octet-stream', // Infer or store content type contentType: m.format === 'webm' ? 'video/webm'
: m.format === 'mp4' ? 'video/mp4'
: m.format === 'mkv' ? 'video/x-matroska'
: m.storagePath.endsWith('.webm') ? 'video/webm'
: m.storagePath.endsWith('.mp4') ? 'video/mp4'
: 'application/octet-stream', // Infer or store content type
}; };
})), })),
}; };
@@ -597,11 +602,23 @@ export const trialsRouter = createTRPCRouter({
"wizard", "wizard",
]); ]);
const [currentTrial] = await db
.select()
.from(trials)
.where(eq(trials.id, input.id))
.limit(1);
let durationSeconds = null;
if (currentTrial?.startedAt) {
durationSeconds = Math.floor((new Date().getTime() - currentTrial.startedAt.getTime()) / 1000);
}
const [trial] = await db const [trial] = await db
.update(trials) .update(trials)
.set({ .set({
status: "completed", status: "completed",
completedAt: new Date(), completedAt: new Date(),
duration: durationSeconds,
notes: input.notes, notes: input.notes,
}) })
.where(eq(trials.id, input.id)) .where(eq(trials.id, input.id))